전체적인 리팩토링

This commit is contained in:
2026-03-12 09:26:27 +09:00
parent 406af7408a
commit e51d767878
97 changed files with 13651 additions and 363 deletions

View File

@@ -47,6 +47,8 @@ description: 기능 개발/버그 수정/리팩토링 같은 구현 요청에서
[3. 리팩토링/성능/가독성] [3. 리팩토링/성능/가독성]
- ... - ...
- 파일 상단 역할 주석 반영 여부
- 핵심 입력 데이터 흐름 추적표 포함 여부
[4. 테스트] [4. 테스트]
- ... - ...
@@ -54,4 +56,9 @@ description: 기능 개발/버그 수정/리팩토링 같은 구현 요청에서
[5. 계획 대비 완료체크] [5. 계획 대비 완료체크]
- 완료/부분 완료/미완료 - 완료/부분 완료/미완료
- 최종 판정: 배포 가능/보완 필요 - 최종 판정: 배포 가능/보완 필요
[6. 핵심 입력 흐름 추적표]
- 입력값: (예: 전략 프롬프트)
- UI 입력 -> 핸들러 -> 훅/서비스 -> API -> route -> provider -> 결과 반영
- 각 단계는 파일/라인 링크 포함
``` ```

View File

@@ -96,12 +96,75 @@ description: 구현 완료 직후 가독성·데이터 흐름·성능을 다듬
1. 주석 보강 작업은 코드 로직(타입/런타임/동작/변수명/import)을 바꾸지 않는다. 1. 주석 보강 작업은 코드 로직(타입/런타임/동작/변수명/import)을 바꾸지 않는다.
2. 함수/API/쿼리 주석은 `[목적]`, `[사용처]`, `[데이터 흐름]` 중심으로 쉽게 쓴다. 2. 함수/API/쿼리 주석은 `[목적]`, `[사용처]`, `[데이터 흐름]` 중심으로 쉽게 쓴다.
3. 상태(`useState`, `useRef`, store)는 "화면에 어떤 영향을 주는지" 한 줄 주석을 단다. 3. 상태(`useState`, `useRef`, `useMemo`, store 파생 상태)는 반드시 `[State]`, `[Ref]` 형식으로 역할 주석을 단다.
4. 복잡한 로직/핸들러는 `1.`, `2.`, `3.` 단계 주석으로 흐름을 나눈다. - 예: `// [State] 자동매매 실행 중 여부 (배너/버튼 상태에 사용)`
- 예: `// [Ref] 마지막 신호 요청 시각 (요청 과다 방지용)`
4. 복잡한 로직/핸들러는 반드시 `[Step 1]`, `[Step 2]`, `[Step 3]` 형식으로 흐름을 나눈다.
- 예: `// [Step 1] 입력값 유효성 검증`
5. 긴 JSX는 화면 구역 주석으로 나눠서 읽기 쉽게 만든다. 5. 긴 JSX는 화면 구역 주석으로 나눠서 읽기 쉽게 만든다.
- 예: `{/* ===== 1. 상단: 페이지 제목 및 액션 버튼 ===== */}` - 예: `{/* ========== 1. 상단: 상태/액션 영역 ========== */}`
6. `@param`, `@see`, `@remarks` 같은 딱딱한 TSDoc 태그는 강제하지 않는다. 6. 데이터 흐름이 중요한 입력(UI prompt, 검색어, 주문 설정값)은 입력 지점에 "어디 API로 가는지"를 한 줄로 명시한다.
7. 결과 기준은 "주니어가 5분 내 파악 가능한지"로 잡는다. - 예: `// [데이터 흐름] textarea -> patchSetupForm -> compile API -> AI provider(OpenAI/CLI)`
7. `@param`, `@see`, `@remarks` 같은 딱딱한 TSDoc 태그는 강제하지 않는다.
8. 결과 기준은 "주니어가 5분 내 파악 가능한지"로 잡는다.
### 파일 상단 역할 주석 (필수)
1. 핵심 파일(`components`, `hooks`, `apis`, `lib`, `route.ts`)은 import 위(또는 `"use client"` 바로 아래)에 파일 역할 주석을 단다.
2. 형식은 아래 템플릿을 따른다.
```ts
/**
* [파일 역할]
* 이 파일이 시스템에서 맡는 역할
*
* [주요 책임]
* - 책임 1
* - 책임 2
* - 책임 3
*/
```
### 흐름 추적 문서화 규칙 (필수)
1. 사용자가 "이 값이 어디로 가는지"를 물으면 반드시 함수 체인을 파일/라인으로 답한다.
2. 형식은 `UI 입력 -> 핸들러 -> 훅/서비스 -> API 클라이언트 -> route -> provider -> 결과 반영` 순서를 유지한다.
3. 최종 답변에 최소 1개 이상의 "핵심 입력 흐름 추적표"를 포함한다.
4. 라인 표기는 `절대경로:라인` 링크 형식으로 제공한다.
### 필수 주석 패턴 (컴포넌트/훅)
1. State/Ref 선언부
```ts
// [State] 자동매매 설정 모달 열림 여부
const [panelOpen, setPanelOpen] = useState(false);
// [Ref] 최근 가격 캐시 (신호 생성용)
const recentPricesRef = useRef<number[]>([]);
```
2. 핸들러/비즈니스 함수
```ts
const handleStart = async () => {
// [Step 1] 필수 입력값 검증
// [Step 2] 전략 컴파일/검증 API 호출
// [Step 3] 세션 시작 및 UI 상태 갱신
};
```
3. JSX 섹션 구분
```tsx
return (
<>
{/* ========== 1. 상단: 상태 및 액션 ========== */}
{/* ========== 2. 본문: 설정 입력 영역 ========== */}
{/* ========== 3. 하단: 검증/시작 버튼 영역 ========== */}
</>
);
```
## UI/브랜드/문구 규칙 ## UI/브랜드/문구 규칙
@@ -134,6 +197,10 @@ description: 구현 완료 직후 가독성·데이터 흐름·성능을 다듬
[데이터 흐름 정리] [데이터 흐름 정리]
- 어느 UI -> A 함수 호출 -> B 함수 호출 -> 리턴값 반영 - 어느 UI -> A 함수 호출 -> B 함수 호출 -> 리턴값 반영
[핵심 입력 흐름 추적표]
- 입력값: (예: 전략 프롬프트)
- [파일:라인] -> 함수 -> 다음 호출
[회귀 위험 점검] [회귀 위험 점검]
- ... - ...
``` ```

View File

@@ -7,3 +7,36 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=
# 세션 타임아웃(분 단위) # 세션 타임아웃(분 단위)
NEXT_PUBLIC_SESSION_TIMEOUT_MINUTES=30 NEXT_PUBLIC_SESSION_TIMEOUT_MINUTES=30
# 자동매매/AI 설정
OPENAI_API_KEY=
AUTOTRADE_AI_MODEL=gpt-4o-mini
# auto | openai_api | subscription_cli | rule_fallback
AUTOTRADE_AI_MODE=auto
# subscription_cli 모드에서 사용할 CLI 선택값(auto | gemini | codex)
AUTOTRADE_SUBSCRIPTION_CLI=auto
# subscription_cli 공통 모델(옵션): vendor 전용 설정이 없을 때 fallback으로 사용
AUTOTRADE_SUBSCRIPTION_CLI_MODEL=
# Codex CLI 전용 모델(옵션): 예) gpt-5-codex
AUTOTRADE_CODEX_MODEL=
# Gemini CLI 전용 모델(옵션): 예) auto | pro | flash | flash-lite | gemini-2.5-pro
AUTOTRADE_GEMINI_MODEL=
# subscription_cli 호출 타임아웃(ms)
AUTOTRADE_SUBSCRIPTION_CLI_TIMEOUT_MS=60000
# subscription_cli 디버그 로그(1/true/on): Next 서버 콘솔에 CLI 호출/시도 로그 출력
AUTOTRADE_SUBSCRIPTION_CLI_DEBUG=0
# Codex CLI 실행 파일 경로(옵션): PATH 인식 문제 시 절대경로 지정
AUTOTRADE_CODEX_COMMAND=
# Gemini CLI 실행 파일 경로(옵션): PATH 인식 문제 시 절대경로 지정
AUTOTRADE_GEMINI_COMMAND=
AUTOTRADE_HEARTBEAT_TTL_SEC=90
AUTOTRADE_MAX_DAILY_ORDERS_DEFAULT=20
AUTOTRADE_CONFIDENCE_THRESHOLD_DEFAULT=0.65
AUTOTRADE_DEV_BYPASS_TOKEN=autotrade-dev-bypass
# 워커 인증 토큰: 직접 랜덤 문자열 생성해서 앱/워커에 동일하게 넣어 주세요.
# 예) openssl rand -hex 32
AUTOTRADE_WORKER_TOKEN=autotrade-worker-local
# 워커 점검 주기(ms)
AUTOTRADE_WORKER_POLL_MS=5000
# 워커가 호출할 Next.js 앱 주소
AUTOTRADE_APP_URL=http://127.0.0.1:3001

View File

@@ -15,3 +15,11 @@
4. `dev-test-gate` 4. `dev-test-gate`
5. `dev-plan-completion-checker` 5. `dev-plan-completion-checker`
- 단순 설명/문서 요약/잡담 요청에는 파이프라인 스킬을 강제하지 않는다. - 단순 설명/문서 요약/잡담 요청에는 파이프라인 스킬을 강제하지 않는다.
## 설명 방식 규칙
- 사용자 설명은 어려운 용어보다 쉬운 한국어를 우선 사용한다.
- 기술 용어를 써야 할 때는 바로 아래 줄에 쉬운 말로 다시 풀어쓴다.
- 데이터 흐름 설명은 항상 `입력 -> 처리 -> 결과` 순서의 짧은 단계로 말한다.
- 사용자가 헷갈린 상황에서는 추상 설명보다 "지금 화면에서 확인할 것"을 먼저 안내한다.
- 요청/응답 설명 시에는 핵심 필드 3~5개만 먼저 보여주고, 필요 시 상세를 추가한다.

View File

@@ -21,21 +21,21 @@ interface StartStep {
const START_STEPS: StartStep[] = [ const START_STEPS: StartStep[] = [
{ {
step: "01", step: "01",
title: "1분이면 충분해요", title: "앱키 연결, 1분이면 ",
description: description:
"복잡한 서류나 방문 없이, 쓰던 계좌 그대로 안전하게 연결할 수 있어요.", "복잡한 절차 없이, 지금 쓰는 계좌로 바로 시작할 수 있어요.",
}, },
{ {
step: "02", step: "02",
title: "내 스타일대로 골라보세요", title: "투자금/손실선만 입력하세요",
description: description:
"공격적인 투자부터 안정적인 관리까지, 나에게 딱 맞는 전략이 준비되어 있어요.", "어렵게 계산할 필요 없이, 내가 감당 가능한 금액만 정하면 돼요.",
}, },
{ {
step: "03", step: "03",
title: "이제 일상을 즐기세요", title: "신호 확인 후 자동 실행",
description: description:
"차트는 JOORIN-E가 하루 종일 보고 있을게요. 마음 편히 본업에 집중하세요.", "차트 감시는 JOORIN-E가 맡고, 당신은 중요한 순간만 확인하면 됩니다.",
}, },
]; ];
@@ -50,7 +50,7 @@ export default async function HomePage() {
} = await supabase.auth.getUser(); } = await supabase.auth.getUser();
const primaryCtaHref = user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.SIGNUP; const primaryCtaHref = user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.SIGNUP;
const primaryCtaLabel = user ? "시작하기" : "지금 무료로 시작하기"; const primaryCtaLabel = user ? "내 전략 시작하기" : "무료로 시작하기";
return ( return (
<div className="flex min-h-screen flex-col overflow-x-hidden bg-black text-white selection:bg-brand-500/30"> <div className="flex min-h-screen flex-col overflow-x-hidden bg-black text-white selection:bg-brand-500/30">
@@ -69,21 +69,21 @@ export default async function HomePage() {
<div className="flex flex-col items-center text-center"> <div className="flex flex-col items-center text-center">
<span className="inline-flex animate-in fade-in-0 slide-in-from-top-2 items-center gap-2 rounded-full border border-brand-400/30 bg-white/5 px-4 py-1.5 text-xs font-medium tracking-wide text-brand-200 backdrop-blur-md duration-1000"> <span className="inline-flex animate-in fade-in-0 slide-in-from-top-2 items-center gap-2 rounded-full border border-brand-400/30 bg-white/5 px-4 py-1.5 text-xs font-medium tracking-wide text-brand-200 backdrop-blur-md duration-1000">
<Sparkles className="h-3.5 w-3.5" /> <Sparkles className="h-3.5 w-3.5" />
, JOORIN-E , JOORIN-E
</span> </span>
<h1 className="mt-8 animate-in slide-in-from-bottom-4 text-5xl font-black tracking-tight text-white duration-1000 md:text-8xl"> <h1 className="mt-8 animate-in slide-in-from-bottom-4 text-5xl font-black tracking-tight text-white duration-1000 md:text-8xl">
,
<br /> <br />
<span className="bg-linear-to-b from-brand-300 via-brand-200 to-brand-500 bg-clip-text text-transparent"> <span className="bg-linear-to-b from-brand-300 via-brand-200 to-brand-500 bg-clip-text text-transparent">
. .
</span> </span>
</h1> </h1>
<p className="mt-8 max-w-2xl animate-in slide-in-from-bottom-4 text-sm leading-relaxed text-white/60 duration-1000 md:text-xl"> <p className="mt-8 max-w-2xl animate-in slide-in-from-bottom-4 text-sm leading-relaxed text-white/60 duration-1000 md:text-xl">
, . , .
<br className="hidden md:block" /> <br className="hidden md:block" />
24 . , .
</p> </p>
<div className="mt-12 flex animate-in slide-in-from-bottom-6 flex-col gap-4 duration-1000 sm:flex-row"> <div className="mt-12 flex animate-in slide-in-from-bottom-6 flex-col gap-4 duration-1000 sm:flex-row">
@@ -111,14 +111,14 @@ export default async function HomePage() {
<div className="flex flex-col items-center gap-16 md:flex-row md:items-start"> <div className="flex flex-col items-center gap-16 md:flex-row md:items-start">
<div className="flex-1 text-center md:text-left"> <div className="flex-1 text-center md:text-left">
<h2 className="text-3xl font-black md:text-5xl"> <h2 className="text-3xl font-black md:text-5xl">
<br /> <br />
<span className="text-brand-300"> 3 .</span> <span className="text-brand-300">3 .</span>
</h2> </h2>
<p className="mt-6 text-sm leading-relaxed text-white/50 md:text-lg"> <p className="mt-6 text-sm leading-relaxed text-white/50 md:text-lg">
JOORIN-E가 . -&gt; / -&gt; .
<br /> <br />
&apos;&apos; . , .
</p> </p>
</div> </div>
@@ -166,20 +166,18 @@ export default async function HomePage() {
</div> </div>
<div> <div>
<h3 className="text-lg font-bold text-brand-100"> <h3 className="text-lg font-bold text-brand-100">
, ? /, ?
</h3> </h3>
<p className="mt-2 text-sm leading-relaxed text-brand-200/70"> <p className="mt-2 text-sm leading-relaxed text-brand-200/70">
<strong className="text-brand-200"> <strong className="text-brand-200">
, . .
</strong> </strong>
<br /> <br />
JOORIN-E는 API JOORIN-E는 ,
.
<br className="hidden md:block" /> <br className="hidden md:block" />
() API .
,
<br className="hidden md:block" /> <br className="hidden md:block" />
. .
</p> </p>
</div> </div>
</div> </div>
@@ -190,9 +188,9 @@ export default async function HomePage() {
<section className="container mx-auto max-w-5xl px-4 py-32"> <section className="container mx-auto max-w-5xl px-4 py-32">
<div className="relative overflow-hidden rounded-[2.5rem] border border-brand-500/20 bg-linear-to-b from-brand-500/10 to-transparent p-12 text-center md:p-24"> <div className="relative overflow-hidden rounded-[2.5rem] border border-brand-500/20 bg-linear-to-b from-brand-500/10 to-transparent p-12 text-center md:p-24">
<h2 className="text-3xl font-black md:text-6xl"> <h2 className="text-3xl font-black md:text-6xl">
.
<br /> <br />
. .
</h2> </h2>
<div className="mt-12 flex justify-center"> <div className="mt-12 flex justify-center">
<Button <Button

View File

@@ -0,0 +1,227 @@
import { NextResponse } from "next/server";
import { hasKisConfig } from "@/lib/kis/config";
import {
readKisAccountParts,
readKisCredentialsFromHeaders,
} from "@/app/api/kis/domestic/_shared";
import type {
AutotradeSessionInfo,
AutotradeStopReason,
} from "@/features/autotrade/types/autotrade.types";
import { createClient } from "@/utils/supabase/server";
export const AUTOTRADE_DEV_BYPASS_HEADER = "x-autotrade-dev-bypass";
export const AUTOTRADE_WORKER_TOKEN_HEADER = "x-autotrade-worker-token";
export const AUTOTRADE_API_ERROR_CODE = {
AUTH_REQUIRED: "AUTOTRADE_AUTH_REQUIRED",
INVALID_REQUEST: "AUTOTRADE_INVALID_REQUEST",
CREDENTIAL_REQUIRED: "AUTOTRADE_CREDENTIAL_REQUIRED",
SESSION_NOT_FOUND: "AUTOTRADE_SESSION_NOT_FOUND",
CONFLICT: "AUTOTRADE_CONFLICT",
INTERNAL: "AUTOTRADE_INTERNAL",
} as const;
export type AutotradeApiErrorCode =
(typeof AUTOTRADE_API_ERROR_CODE)[keyof typeof AUTOTRADE_API_ERROR_CODE];
export interface AutotradeSessionRecord extends AutotradeSessionInfo {
userId: string;
strategySummary: string;
}
declare global {
var __autotradeSessionMap: Map<string, AutotradeSessionRecord> | undefined;
}
function getSessionMap() {
if (!globalThis.__autotradeSessionMap) {
globalThis.__autotradeSessionMap = new Map<string, AutotradeSessionRecord>();
}
return globalThis.__autotradeSessionMap;
}
export function createAutotradeErrorResponse(options: {
status: number;
code: AutotradeApiErrorCode;
message: string;
extra?: Record<string, unknown>;
}) {
return NextResponse.json(
{
ok: false,
errorCode: options.code,
message: options.message,
...(options.extra ?? {}),
},
{ status: options.status },
);
}
export async function getAutotradeUserId(headers?: Headers) {
if (isAutotradeDevBypass(headers)) {
return "dev-autotrade-user";
}
const supabase = await createClient();
const {
data: { user },
error,
} = await supabase.auth.getUser();
if (error || !user) return null;
return user.id;
}
export async function readJsonBody(request: Request) {
const text = await request.text();
if (!text.trim()) return null;
try {
return JSON.parse(text) as unknown;
} catch {
return null;
}
}
export function hasAutotradeKisRuntimeHeaders(headers: Headers) {
if (isAutotradeDevBypass(headers)) {
return true;
}
const credentials = readKisCredentialsFromHeaders(headers);
const account = readKisAccountParts(headers);
return Boolean(hasKisConfig(credentials) && account);
}
export function upsertAutotradeSession(record: AutotradeSessionRecord) {
const map = getSessionMap();
map.set(record.userId, record);
return record;
}
export function getAutotradeSession(userId: string) {
const map = getSessionMap();
const record = map.get(userId) ?? null;
if (!record) return null;
if (record.runtimeState === "RUNNING" && isHeartbeatExpired(record.lastHeartbeatAt)) {
const stoppedRecord = {
...record,
runtimeState: "STOPPED" as const,
stopReason: "heartbeat_timeout" as const,
endedAt: new Date().toISOString(),
};
map.set(userId, stoppedRecord);
return stoppedRecord;
}
return record;
}
export function listAutotradeSessions() {
return Array.from(getSessionMap().values()).sort((a, b) =>
b.startedAt.localeCompare(a.startedAt),
);
}
export function stopAutotradeSession(userId: string, reason: AutotradeStopReason) {
const map = getSessionMap();
const record = map.get(userId);
if (!record) return null;
const stoppedRecord: AutotradeSessionRecord = {
...record,
runtimeState: "STOPPED",
stopReason: reason,
endedAt: new Date().toISOString(),
lastHeartbeatAt: new Date().toISOString(),
};
map.set(userId, stoppedRecord);
return stoppedRecord;
}
export function sweepExpiredAutotradeSessions() {
const map = getSessionMap();
let expiredCount = 0;
for (const [userId, record] of map.entries()) {
if (record.runtimeState !== "RUNNING") continue;
if (!isHeartbeatExpired(record.lastHeartbeatAt)) continue;
const stoppedRecord: AutotradeSessionRecord = {
...record,
runtimeState: "STOPPED",
stopReason: "heartbeat_timeout",
endedAt: new Date().toISOString(),
lastHeartbeatAt: new Date().toISOString(),
};
map.set(userId, stoppedRecord);
expiredCount += 1;
}
return {
totalSessionCount: map.size,
expiredCount,
};
}
export function getAutotradeHeartbeatTtlSec() {
const parsed = Number.parseInt(process.env.AUTOTRADE_HEARTBEAT_TTL_SEC ?? "90", 10);
if (!Number.isFinite(parsed)) return 90;
return Math.min(300, Math.max(30, parsed));
}
export function isHeartbeatExpired(lastHeartbeatAt: string) {
const lastHeartbeatMs = new Date(lastHeartbeatAt).getTime();
if (!Number.isFinite(lastHeartbeatMs)) return true;
return Date.now() - lastHeartbeatMs > getAutotradeHeartbeatTtlSec() * 1000;
}
export function sanitizeAutotradeError(error: unknown, fallback: string) {
const message = error instanceof Error ? error.message : fallback;
return maskSensitiveTokens(message) || fallback;
}
export function maskSensitiveTokens(value: string) {
return value
.replace(/([A-Za-z0-9]{4})[A-Za-z0-9]{8,}([A-Za-z0-9]{4})/g, "$1********$2")
.replace(/(x-kis-app-secret\s*[:=]\s*)([^\s]+)/gi, "$1********")
.replace(/(x-kis-app-key\s*[:=]\s*)([^\s]+)/gi, "$1********");
}
export function isAutotradeWorkerAuthorized(headers: Headers) {
const providedToken = headers.get(AUTOTRADE_WORKER_TOKEN_HEADER)?.trim();
if (!providedToken) return false;
const expectedToken = process.env.AUTOTRADE_WORKER_TOKEN?.trim();
if (expectedToken) {
return providedToken === expectedToken;
}
// 운영 환경에서는 토큰 미설정 상태를 허용하지 않습니다.
if (process.env.NODE_ENV === "production") {
return false;
}
return providedToken === "autotrade-worker-local";
}
function isAutotradeDevBypass(headers?: Headers) {
if (!headers || process.env.NODE_ENV === "production") {
return false;
}
const providedToken = headers.get(AUTOTRADE_DEV_BYPASS_HEADER)?.trim();
if (!providedToken) return false;
const expectedToken =
process.env.AUTOTRADE_DEV_BYPASS_TOKEN?.trim() || "autotrade-dev-bypass";
return providedToken === expectedToken;
}

View File

@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import {
AUTOTRADE_API_ERROR_CODE,
createAutotradeErrorResponse,
getAutotradeSession,
getAutotradeUserId,
} from "@/app/api/autotrade/_shared";
export async function GET(request: Request) {
const userId = await getAutotradeUserId(request.headers);
if (!userId) {
return createAutotradeErrorResponse({
status: 401,
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const session = getAutotradeSession(userId);
return NextResponse.json({
ok: true,
session: session && session.runtimeState === "RUNNING" ? session : null,
});
}

View File

@@ -0,0 +1,73 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import {
AUTOTRADE_API_ERROR_CODE,
createAutotradeErrorResponse,
getAutotradeSession,
getAutotradeUserId,
readJsonBody,
sanitizeAutotradeError,
upsertAutotradeSession,
} from "@/app/api/autotrade/_shared";
const heartbeatRequestSchema = z.object({
sessionId: z.string().uuid(),
leaderTabId: z.string().trim().min(1).max(100).optional(),
});
export async function POST(request: Request) {
const userId = await getAutotradeUserId(request.headers);
if (!userId) {
return createAutotradeErrorResponse({
status: 401,
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const rawBody = await readJsonBody(request);
const parsed = heartbeatRequestSchema.safeParse(rawBody);
if (!parsed.success) {
return createAutotradeErrorResponse({
status: 400,
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
message: parsed.error.issues[0]?.message ?? "heartbeat 요청값이 올바르지 않습니다.",
});
}
try {
const session = getAutotradeSession(userId);
if (!session || session.runtimeState !== "RUNNING") {
return createAutotradeErrorResponse({
status: 404,
code: AUTOTRADE_API_ERROR_CODE.SESSION_NOT_FOUND,
message: "실행 중인 자동매매 세션이 없습니다.",
});
}
if (session.sessionId !== parsed.data.sessionId) {
return createAutotradeErrorResponse({
status: 409,
code: AUTOTRADE_API_ERROR_CODE.CONFLICT,
message: "세션 식별자가 일치하지 않습니다.",
});
}
const updated = upsertAutotradeSession({
...session,
lastHeartbeatAt: new Date().toISOString(),
leaderTabId: parsed.data.leaderTabId ?? session.leaderTabId,
});
return NextResponse.json({
ok: true,
session: updated,
});
} catch (error) {
return createAutotradeErrorResponse({
status: 500,
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
message: sanitizeAutotradeError(error, "heartbeat 처리 중 오류가 발생했습니다."),
});
}
}

View File

@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import {
AUTOTRADE_API_ERROR_CODE,
createAutotradeErrorResponse,
getAutotradeUserId,
hasAutotradeKisRuntimeHeaders,
readJsonBody,
sanitizeAutotradeError,
upsertAutotradeSession,
} from "@/app/api/autotrade/_shared";
const startRequestSchema = z.object({
symbol: z.string().trim().regex(/^\d{6}$/),
leaderTabId: z.string().trim().min(1).max(100),
effectiveAllocationAmount: z.number().int().positive(),
effectiveDailyLossLimit: z.number().int().positive(),
strategySummary: z.string().trim().min(1).max(320),
});
export async function POST(request: Request) {
const userId = await getAutotradeUserId(request.headers);
if (!userId) {
return createAutotradeErrorResponse({
status: 401,
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
if (!hasAutotradeKisRuntimeHeaders(request.headers)) {
return createAutotradeErrorResponse({
status: 400,
code: AUTOTRADE_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message: "자동매매 시작에는 KIS 인증 헤더가 필요합니다.",
});
}
const rawBody = await readJsonBody(request);
const parsed = startRequestSchema.safeParse(rawBody);
if (!parsed.success) {
return createAutotradeErrorResponse({
status: 400,
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
message: parsed.error.issues[0]?.message ?? "세션 시작 입력값이 올바르지 않습니다.",
});
}
try {
const now = new Date().toISOString();
const session = upsertAutotradeSession({
userId,
sessionId: crypto.randomUUID(),
symbol: parsed.data.symbol,
runtimeState: "RUNNING",
leaderTabId: parsed.data.leaderTabId,
startedAt: now,
lastHeartbeatAt: now,
endedAt: null,
stopReason: null,
effectiveAllocationAmount: parsed.data.effectiveAllocationAmount,
effectiveDailyLossLimit: parsed.data.effectiveDailyLossLimit,
strategySummary: parsed.data.strategySummary,
});
return NextResponse.json({
ok: true,
session,
});
} catch (error) {
return createAutotradeErrorResponse({
status: 500,
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
message: sanitizeAutotradeError(error, "자동매매 세션 시작 중 오류가 발생했습니다."),
});
}
}

View File

@@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import {
AUTOTRADE_API_ERROR_CODE,
createAutotradeErrorResponse,
getAutotradeSession,
getAutotradeUserId,
readJsonBody,
sanitizeAutotradeError,
stopAutotradeSession,
} from "@/app/api/autotrade/_shared";
import type { AutotradeStopReason } from "@/features/autotrade/types/autotrade.types";
const stopRequestSchema = z.object({
sessionId: z.string().uuid().optional(),
reason: z
.enum([
"browser_exit",
"external_leave",
"manual",
"emergency",
"heartbeat_timeout",
])
.optional(),
});
export async function POST(request: Request) {
const userId = await getAutotradeUserId(request.headers);
if (!userId) {
return createAutotradeErrorResponse({
status: 401,
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const rawBody = await readJsonBody(request);
const parsed = stopRequestSchema.safeParse(rawBody ?? {});
if (!parsed.success) {
return createAutotradeErrorResponse({
status: 400,
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
message: parsed.error.issues[0]?.message ?? "세션 종료 입력값이 올바르지 않습니다.",
});
}
try {
const session = getAutotradeSession(userId);
if (!session) {
return NextResponse.json({
ok: true,
session: null,
});
}
if (parsed.data.sessionId && parsed.data.sessionId !== session.sessionId) {
return createAutotradeErrorResponse({
status: 409,
code: AUTOTRADE_API_ERROR_CODE.CONFLICT,
message: "세션 식별자가 일치하지 않습니다.",
});
}
const reason: AutotradeStopReason = parsed.data.reason ?? "manual";
const stopped = stopAutotradeSession(userId, reason);
return NextResponse.json({
ok: true,
session: stopped,
});
} catch (error) {
return createAutotradeErrorResponse({
status: 500,
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
message: sanitizeAutotradeError(error, "세션 종료 중 오류가 발생했습니다."),
});
}
}

View File

@@ -0,0 +1,440 @@
/**
* [파일 역할]
* 컴파일된 전략 + 시세 스냅샷으로 매수/매도/대기 신호를 생성하는 API 라우트입니다.
*
* [주요 책임]
* - 요청 검증(strategy/snapshot)
* - provider 분기(OpenAI/구독형 CLI/fallback)
* - 실패 시 fallback 신호로 대체
*/
import { NextResponse } from "next/server";
import { z } from "zod";
import {
AUTOTRADE_API_ERROR_CODE,
createAutotradeErrorResponse,
getAutotradeUserId,
readJsonBody,
sanitizeAutotradeError,
} from "@/app/api/autotrade/_shared";
import { AUTOTRADE_TECHNIQUE_IDS } from "@/features/autotrade/types/autotrade.types";
import {
generateSignalWithSubscriptionCliDetailed,
summarizeSubscriptionCliExecution,
} from "@/lib/autotrade/cli-provider";
import { generateSignalWithOpenAi, isOpenAiConfigured } from "@/lib/autotrade/openai";
import { createFallbackSignalCandidate } from "@/lib/autotrade/strategy";
export const runtime = "nodejs";
const strategySchema = z.object({
provider: z.enum(["openai", "fallback", "subscription_cli"]),
summary: z.string().trim().min(1).max(320),
selectedTechniques: z.array(z.enum(AUTOTRADE_TECHNIQUE_IDS)).default([]),
confidenceThreshold: z.number().min(0.45).max(0.95),
maxDailyOrders: z.number().int().min(1).max(200),
cooldownSec: z.number().int().min(10).max(600),
maxOrderAmountRatio: z.number().min(0.05).max(1),
createdAt: z.string().trim().min(1),
});
const signalRequestSchema = z.object({
aiMode: z
.enum(["auto", "openai_api", "subscription_cli", "rule_fallback"])
.default("auto"),
subscriptionCliVendor: z.enum(["auto", "codex", "gemini"]).optional(),
subscriptionCliModel: z.string().trim().max(80).optional(),
prompt: z.string().trim().max(1200).default(""),
strategy: strategySchema,
snapshot: z.object({
symbol: z.string().trim().regex(/^\d{6}$/),
stockName: z.string().trim().max(120).optional(),
market: z.enum(["KOSPI", "KOSDAQ"]).optional(),
requestAtIso: z.string().trim().max(40).optional(),
requestAtKst: z.string().trim().max(40).optional(),
tickTime: z.string().trim().max(12).optional(),
executionClassCode: z.string().trim().max(10).optional(),
isExpected: z.boolean().optional(),
trId: z.string().trim().max(32).optional(),
currentPrice: z.number().positive(),
prevClose: z.number().nonnegative().optional(),
changeRate: z.number(),
open: z.number().nonnegative(),
high: z.number().nonnegative(),
low: z.number().nonnegative(),
tradeVolume: z.number().nonnegative(),
accumulatedVolume: z.number().nonnegative(),
tradeStrength: z.number().optional(),
askPrice1: z.number().nonnegative().optional(),
bidPrice1: z.number().nonnegative().optional(),
askSize1: z.number().nonnegative().optional(),
bidSize1: z.number().nonnegative().optional(),
totalAskSize: z.number().nonnegative().optional(),
totalBidSize: z.number().nonnegative().optional(),
buyExecutionCount: z.number().int().optional(),
sellExecutionCount: z.number().int().optional(),
netBuyExecutionCount: z.number().int().optional(),
spread: z.number().nonnegative().optional(),
spreadRate: z.number().optional(),
dayRangePercent: z.number().nonnegative().optional(),
dayRangePosition: z.number().min(0).max(1).optional(),
volumeRatio: z.number().nonnegative().optional(),
recentTradeCount: z.number().int().nonnegative().optional(),
recentTradeVolumeSum: z.number().nonnegative().optional(),
recentAverageTradeVolume: z.number().nonnegative().optional(),
accumulatedVolumeDelta: z.number().nonnegative().optional(),
netBuyExecutionDelta: z.number().optional(),
orderBookImbalance: z.number().min(-1).max(1).optional(),
liquidityDepth: z.number().nonnegative().optional(),
topLevelOrderBookImbalance: z.number().min(-1).max(1).optional(),
buySellExecutionRatio: z.number().nonnegative().optional(),
recentPriceHigh: z.number().positive().optional(),
recentPriceLow: z.number().positive().optional(),
recentPriceRangePercent: z.number().nonnegative().optional(),
recentTradeVolumes: z.array(z.number().nonnegative()).max(20).optional(),
recentNetBuyTrail: z.array(z.number()).max(20).optional(),
recentTickAgesSec: z.array(z.number().nonnegative()).max(20).optional(),
intradayMomentum: z.number().optional(),
recentReturns: z.array(z.number()).max(12).optional(),
recentPrices: z.array(z.number().positive()).min(3).max(30),
marketDataLatencySec: z.number().nonnegative().optional(),
recentMinuteCandles: z
.array(
z.object({
time: z.string().trim().max(32),
open: z.number().positive(),
high: z.number().positive(),
low: z.number().positive(),
close: z.number().positive(),
volume: z.number().nonnegative(),
timestamp: z.number().int().optional(),
}),
)
.max(30)
.optional(),
minutePatternContext: z
.object({
timeframe: z.literal("1m"),
candleCount: z.number().int().min(1).max(30),
impulseDirection: z.enum(["up", "down", "flat"]),
impulseBarCount: z.number().int().min(1).max(20),
consolidationBarCount: z.number().int().min(1).max(12),
impulseChangeRate: z.number().optional(),
impulseRangePercent: z.number().nonnegative().optional(),
consolidationRangePercent: z.number().nonnegative().optional(),
consolidationCloseClusterPercent: z.number().nonnegative().optional(),
consolidationVolumeRatio: z.number().nonnegative().optional(),
breakoutUpper: z.number().positive().optional(),
breakoutLower: z.number().positive().optional(),
})
.optional(),
budgetContext: z
.object({
setupAllocationPercent: z.number().nonnegative(),
setupAllocationAmount: z.number().nonnegative(),
effectiveAllocationAmount: z.number().nonnegative(),
strategyMaxOrderAmountRatio: z.number().min(0).max(1),
effectiveOrderBudgetAmount: z.number().nonnegative(),
estimatedBuyUnitCost: z.number().nonnegative(),
estimatedBuyableQuantity: z.number().int().nonnegative(),
})
.optional(),
portfolioContext: z
.object({
holdingQuantity: z.number().int().nonnegative(),
sellableQuantity: z.number().int().nonnegative(),
averagePrice: z.number().nonnegative(),
estimatedSellableNetAmount: z.number().optional(),
})
.optional(),
executionCostProfile: z
.object({
buyFeeRate: z.number().nonnegative(),
sellFeeRate: z.number().nonnegative(),
sellTaxRate: z.number().nonnegative(),
})
.optional(),
}),
});
const signalResultSchema = z.object({
signal: z.enum(["buy", "sell", "hold"]),
confidence: z.number().min(0).max(1),
reason: z.string().min(1).max(160),
ttlSec: z.number().int().min(5).max(300),
riskFlags: z.array(z.string()).max(10).default([]),
proposedOrder: z
.object({
symbol: z.string().trim().regex(/^\d{6}$/),
side: z.enum(["buy", "sell"]),
orderType: z.enum(["limit", "market"]),
price: z.number().positive().optional(),
quantity: z.number().int().positive().optional(),
})
.optional(),
});
export async function POST(request: Request) {
const userId = await getAutotradeUserId(request.headers);
if (!userId) {
return createAutotradeErrorResponse({
status: 401,
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const rawBody = await readJsonBody(request);
const parsed = signalRequestSchema.safeParse(rawBody);
if (!parsed.success) {
return createAutotradeErrorResponse({
status: 400,
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
message: parsed.error.issues[0]?.message ?? "신호 생성 요청값이 올바르지 않습니다.",
});
}
try {
// [Step 1] 안전망: 우선 규칙 기반 fallback 신호를 준비합니다.
const fallbackSignal = createFallbackSignalCandidate({
strategy: parsed.data.strategy,
snapshot: parsed.data.snapshot,
});
// [Step 2] 규칙 기반 강제 모드
if (parsed.data.aiMode === "rule_fallback") {
return NextResponse.json({
ok: true,
signal: fallbackSignal,
});
}
// [Step 3] OpenAI 모드(auto/openai_api): 성공 시 해당 신호를 그대로 반환
const shouldUseOpenAi = parsed.data.aiMode === "openai_api" || parsed.data.aiMode === "auto";
if (shouldUseOpenAi && isOpenAiConfigured()) {
const aiSignal = await generateSignalWithOpenAi({
prompt: parsed.data.prompt,
strategy: parsed.data.strategy,
snapshot: parsed.data.snapshot,
});
if (aiSignal) {
const localizedReason = ensureKoreanReason(aiSignal.reason, aiSignal.signal);
return NextResponse.json({
ok: true,
signal: {
...aiSignal,
reason: localizedReason,
},
});
}
}
// [Step 4] 구독형 CLI 모드(subscription_cli/auto-fallback): codex/gemini CLI 자동판단
const shouldUseCli =
parsed.data.aiMode === "subscription_cli" ||
(parsed.data.aiMode === "auto" && !isOpenAiConfigured());
if (shouldUseCli) {
const cliResult = await generateSignalWithSubscriptionCliDetailed({
prompt: parsed.data.prompt,
strategy: parsed.data.strategy,
snapshot: parsed.data.snapshot,
preferredVendor: parsed.data.subscriptionCliVendor,
preferredModel:
parsed.data.subscriptionCliVendor && parsed.data.subscriptionCliVendor !== "auto"
? parsed.data.subscriptionCliModel
: undefined,
});
const normalizedCliSignal = normalizeCliSignalCandidate(
cliResult.parsed,
parsed.data.snapshot.symbol,
);
const cliParsed = signalResultSchema.safeParse(normalizedCliSignal);
if (cliParsed.success) {
const localizedReason = ensureKoreanReason(
cliParsed.data.reason,
cliParsed.data.signal,
);
return NextResponse.json({
ok: true,
signal: {
...cliParsed.data,
reason: localizedReason,
source: "subscription_cli",
providerVendor: cliResult.vendor ?? undefined,
providerModel: cliResult.model ?? undefined,
},
});
}
const cliExecutionSummary = summarizeSubscriptionCliExecution(cliResult);
return NextResponse.json({
ok: true,
signal: {
...fallbackSignal,
// CLI 응답이 비정상이어도 주문 엔진이 멈추지 않도록 fallback 신호로 대체합니다.
reason: `구독형 CLI 응답을 해석하지 못해 규칙 기반 신호로 대체했습니다. (${cliExecutionSummary})`,
providerVendor: cliResult.vendor ?? undefined,
providerModel: cliResult.model ?? undefined,
},
});
}
return NextResponse.json({
ok: true,
signal: fallbackSignal,
});
} catch (error) {
return createAutotradeErrorResponse({
status: 500,
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
message: sanitizeAutotradeError(error, "신호 생성 중 오류가 발생했습니다."),
});
}
}
function normalizeCliSignalCandidate(raw: unknown, defaultSymbol: string) {
const source = resolveSignalPayloadSource(raw);
if (!source) return raw;
const signal = normalizeSignalValue(source.signal ?? source.action ?? source.side);
const confidence = clampNumber(source.confidence ?? source.score ?? source.probability, 0, 1);
const reason = normalizeReasonText(source.reason ?? source.rationale ?? source.comment);
const ttlSec = normalizeInteger(source.ttlSec ?? source.ttl, 20, 5, 300);
const riskFlags = normalizeRiskFlags(source.riskFlags ?? source.risks);
const proposedOrder = normalizeProposedOrder(source.proposedOrder ?? source.order, defaultSymbol);
return {
signal: signal ?? source.signal,
confidence,
reason,
ttlSec,
riskFlags,
proposedOrder,
};
}
function resolveSignalPayloadSource(raw: unknown): Record<string, unknown> | null {
if (!raw || typeof raw !== "object") return null;
const source = raw as Record<string, unknown>;
if (source.signal || source.action || source.side || source.proposedOrder || source.order) {
return source;
}
const nestedCandidate =
source.decision ??
source.result ??
source.data ??
source.output ??
source.payload;
if (!nestedCandidate || typeof nestedCandidate !== "object") {
return null;
}
return nestedCandidate as Record<string, unknown>;
}
function normalizeSignalValue(raw: unknown) {
if (typeof raw !== "string") return null;
const normalized = raw.trim().toLowerCase();
if (normalized === "buy" || normalized === "sell" || normalized === "hold") {
return normalized;
}
return null;
}
function clampNumber(raw: unknown, min: number, max: number) {
const value = typeof raw === "number" ? raw : Number.parseFloat(String(raw ?? ""));
if (!Number.isFinite(value)) return 0.5;
return Math.min(max, Math.max(min, value));
}
function normalizeInteger(raw: unknown, fallback: number, min: number, max: number) {
const value = Number.parseInt(String(raw ?? ""), 10);
if (!Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, value));
}
function normalizeReasonText(raw: unknown) {
const value = typeof raw === "string" ? raw.trim() : "";
if (!value) return "신호 사유가 없어 hold로 처리했습니다.";
return value.slice(0, 160);
}
function ensureKoreanReason(
reason: string,
signal: "buy" | "sell" | "hold",
) {
const normalized = normalizeReasonText(reason);
if (/[가-힣]/.test(normalized)) {
return normalized;
}
if (signal === "buy") {
return "상승 신호가 확인되어 매수 관점으로 판단했습니다.";
}
if (signal === "sell") {
return "하락 또는 과열 신호가 확인되어 매도 관점으로 판단했습니다.";
}
return "명확한 방향성이 부족해 대기 신호로 판단했습니다.";
}
function normalizeRiskFlags(raw: unknown) {
if (Array.isArray(raw)) {
return raw
.map((item) => String(item).trim())
.filter((item) => item.length > 0)
.slice(0, 10);
}
if (typeof raw === "string") {
return raw
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0)
.slice(0, 10);
}
return [];
}
function normalizeProposedOrder(raw: unknown, defaultSymbol: string) {
if (!raw || typeof raw !== "object") return undefined;
const source = raw as Record<string, unknown>;
const side = normalizeSignalValue(source.side);
if (side !== "buy" && side !== "sell") return undefined;
const orderTypeRaw = String(source.orderType ?? source.type ?? "limit")
.trim()
.toLowerCase();
const orderType = orderTypeRaw === "market" ? "market" : "limit";
const symbolRaw = String(source.symbol ?? defaultSymbol).trim();
const symbol = /^\d{6}$/.test(symbolRaw) ? symbolRaw : defaultSymbol;
const price = parseOptionalPositiveNumber(source.price);
const quantity = parseOptionalPositiveInteger(source.quantity ?? source.qty);
return {
symbol,
side,
orderType,
price,
quantity,
};
}
function parseOptionalPositiveNumber(raw: unknown) {
if (raw === undefined || raw === null || raw === "") return undefined;
const value = typeof raw === "number" ? raw : Number.parseFloat(String(raw));
if (!Number.isFinite(value) || value <= 0) return undefined;
return value;
}
function parseOptionalPositiveInteger(raw: unknown) {
if (raw === undefined || raw === null || raw === "") return undefined;
const value = Number.parseInt(String(raw), 10);
if (!Number.isFinite(value) || value <= 0) return undefined;
return value;
}

View File

@@ -0,0 +1,411 @@
/**
* [파일 역할]
* 전략 프롬프트를 실행 가능한 자동매매 전략(JSON)으로 컴파일하는 API 라우트입니다.
*
* [주요 책임]
* - 요청 검증(aiMode/prompt/기법/신뢰도)
* - provider 분기(OpenAI/구독형 CLI/fallback)
* - 실패 시 fallback 전략으로 안전하게 응답
*/
import { NextResponse } from "next/server";
import { z } from "zod";
import {
AUTOTRADE_API_ERROR_CODE,
createAutotradeErrorResponse,
getAutotradeUserId,
readJsonBody,
sanitizeAutotradeError,
} from "@/app/api/autotrade/_shared";
import {
AUTOTRADE_DEFAULT_TECHNIQUES,
AUTOTRADE_TECHNIQUE_IDS,
} from "@/features/autotrade/types/autotrade.types";
import {
compileStrategyWithSubscriptionCliDetailed,
summarizeSubscriptionCliExecution,
} from "@/lib/autotrade/cli-provider";
import { compileStrategyWithOpenAi, isOpenAiConfigured } from "@/lib/autotrade/openai";
import { createFallbackCompiledStrategy } from "@/lib/autotrade/strategy";
export const runtime = "nodejs";
const compileRequestSchema = z.object({
aiMode: z
.enum(["auto", "openai_api", "subscription_cli", "rule_fallback"])
.default("auto"),
subscriptionCliVendor: z.enum(["auto", "codex", "gemini"]).optional(),
subscriptionCliModel: z.string().trim().max(80).optional(),
prompt: z.string().trim().max(1200).default(""),
selectedTechniques: z.array(z.enum(AUTOTRADE_TECHNIQUE_IDS)).default([]),
confidenceThreshold: z.number().min(0.45).max(0.95).optional(),
});
const compileResultSchema = z.object({
summary: z.string().min(1).max(320),
confidenceThreshold: z.number().min(0.45).max(0.95),
maxDailyOrders: z.number().int().min(1).max(200),
cooldownSec: z.number().int().min(10).max(600),
maxOrderAmountRatio: z.number().min(0.05).max(1),
});
export async function POST(request: Request) {
const userId = await getAutotradeUserId(request.headers);
if (!userId) {
return createAutotradeErrorResponse({
status: 401,
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const rawBody = await readJsonBody(request);
const parsed = compileRequestSchema.safeParse(rawBody);
if (!parsed.success) {
return createAutotradeErrorResponse({
status: 400,
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
message: parsed.error.issues[0]?.message ?? "전략 입력값이 올바르지 않습니다.",
});
}
try {
const selectedTechniques =
parsed.data.selectedTechniques.length > 0
? parsed.data.selectedTechniques
: AUTOTRADE_DEFAULT_TECHNIQUES;
// [Step 1] 어떤 모드든 공통 최소전략(규칙 기반)을 먼저 준비해 둡니다.
const fallback = createFallbackCompiledStrategy({
prompt: parsed.data.prompt,
selectedTechniques,
confidenceThreshold: parsed.data.confidenceThreshold ?? 0.65,
});
// [Step 2] 규칙 기반 강제 모드는 즉시 fallback 전략으로 반환합니다.
if (parsed.data.aiMode === "rule_fallback") {
return NextResponse.json({
ok: true,
compiledStrategy: {
...fallback,
summary: `규칙 기반 모드: ${fallback.summary}`,
},
});
}
// [Step 3] OpenAI 모드(auto/openai_api): API 키가 있으면 OpenAI 결과를 우선 사용합니다.
const shouldUseOpenAi = parsed.data.aiMode === "auto" || parsed.data.aiMode === "openai_api";
if (shouldUseOpenAi && isOpenAiConfigured()) {
const aiResult = await compileStrategyWithOpenAi({
prompt: parsed.data.prompt,
selectedTechniques,
confidenceThreshold: fallback.confidenceThreshold,
});
if (aiResult) {
const finalizedSummary = finalizeCompiledSummary({
summary: aiResult.summary,
prompt: parsed.data.prompt,
selectedTechniques,
});
return NextResponse.json({
ok: true,
compiledStrategy: {
...fallback,
provider: "openai",
summary: finalizedSummary,
confidenceThreshold: aiResult.confidenceThreshold,
maxDailyOrders: aiResult.maxDailyOrders,
cooldownSec: aiResult.cooldownSec,
maxOrderAmountRatio: aiResult.maxOrderAmountRatio,
createdAt: new Date().toISOString(),
},
});
}
}
// [Step 4] 구독형 CLI 모드(subscription_cli/auto-fallback): codex/gemini CLI를 호출합니다.
const shouldUseCli =
parsed.data.aiMode === "subscription_cli" ||
(parsed.data.aiMode === "auto" && !isOpenAiConfigured());
if (shouldUseCli) {
const cliResult = await compileStrategyWithSubscriptionCliDetailed({
prompt: parsed.data.prompt,
selectedTechniques,
confidenceThreshold: fallback.confidenceThreshold,
preferredVendor: parsed.data.subscriptionCliVendor,
preferredModel:
parsed.data.subscriptionCliVendor && parsed.data.subscriptionCliVendor !== "auto"
? parsed.data.subscriptionCliModel
: undefined,
});
const normalizedCliCompile = normalizeCliCompileResult(cliResult.parsed, fallback);
const cliParsed = compileResultSchema.safeParse(normalizedCliCompile);
if (cliParsed.success) {
const finalizedSummary = finalizeCompiledSummary({
summary: cliParsed.data.summary,
prompt: parsed.data.prompt,
selectedTechniques,
});
return NextResponse.json({
ok: true,
compiledStrategy: {
...fallback,
provider: "subscription_cli",
providerVendor: cliResult.vendor ?? undefined,
providerModel: cliResult.model ?? undefined,
summary: finalizedSummary,
confidenceThreshold: cliParsed.data.confidenceThreshold,
maxDailyOrders: cliParsed.data.maxDailyOrders,
cooldownSec: cliParsed.data.cooldownSec,
maxOrderAmountRatio: cliParsed.data.maxOrderAmountRatio,
createdAt: new Date().toISOString(),
},
});
}
const parseSummary = summarizeCompileParseFailure(cliResult.parsed);
return NextResponse.json({
ok: true,
compiledStrategy: {
...fallback,
provider: "subscription_cli",
providerVendor: cliResult.vendor ?? undefined,
providerModel: cliResult.model ?? undefined,
// CLI가 실패해도 자동매매가 멈추지 않도록 fallback 전략으로 안전하게 유지합니다.
summary: `구독형 CLI 응답을 해석하지 못해 규칙 기반 전략으로 동작합니다. (${summarizeSubscriptionCliExecution(cliResult)}; parse=${parseSummary})`,
},
});
}
return NextResponse.json({
ok: true,
compiledStrategy: fallback,
});
} catch (error) {
return createAutotradeErrorResponse({
status: 500,
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
message: sanitizeAutotradeError(error, "전략 컴파일 중 오류가 발생했습니다."),
});
}
}
function normalizeCliCompileResult(raw: unknown, fallback: ReturnType<typeof createFallbackCompiledStrategy>) {
const source = resolveCompilePayloadSource(raw);
if (!source) return raw;
const summary = normalizeSummaryText(
source.summary ??
source.strategySummary ??
source.description ??
source.plan ??
source.reason ??
fallback.summary,
fallback.summary,
);
const confidenceThreshold = normalizeRatioNumber(
source.confidenceThreshold ?? source.confidence ?? source.threshold,
fallback.confidenceThreshold,
0.45,
0.95,
);
const maxDailyOrders = normalizeIntegerValue(
source.maxDailyOrders ?? source.dailyOrderLimit ?? source.maxOrdersPerDay ?? source.orderLimit,
fallback.maxDailyOrders,
1,
200,
);
const cooldownSec = normalizeIntegerValue(
source.cooldownSec ?? source.cooldownSeconds ?? source.cooldown ?? source.minIntervalSec,
fallback.cooldownSec,
10,
600,
);
const maxOrderAmountRatio = normalizeRatioNumber(
source.maxOrderAmountRatio ??
source.maxPositionRatio ??
source.positionSizeRatio ??
source.orderAmountRatio,
fallback.maxOrderAmountRatio,
0.05,
1,
);
return {
summary,
confidenceThreshold,
maxDailyOrders,
cooldownSec,
maxOrderAmountRatio,
};
}
function resolveCompilePayloadSource(raw: unknown): Record<string, unknown> | null {
if (!raw || typeof raw !== "object") return null;
const source = raw as Record<string, unknown>;
if (
source.summary ||
source.strategySummary ||
source.confidenceThreshold ||
source.maxDailyOrders ||
source.cooldownSec ||
source.maxOrderAmountRatio
) {
return source;
}
const nestedCandidate =
source.strategy ??
source.compiledStrategy ??
source.result ??
source.output ??
source.data ??
source.payload;
if (!nestedCandidate || typeof nestedCandidate !== "object") {
return source;
}
return nestedCandidate as Record<string, unknown>;
}
function normalizeSummaryText(raw: unknown, fallback: string) {
const text = typeof raw === "string" ? raw.trim() : "";
if (!text) return fallback;
return text.slice(0, 320);
}
function normalizeRatioNumber(
raw: unknown,
fallback: number,
min: number,
max: number,
) {
let value = typeof raw === "number" ? raw : Number.parseFloat(String(raw ?? ""));
if (!Number.isFinite(value)) return fallback;
if (value > 1 && value <= 100) value /= 100;
return Math.min(max, Math.max(min, value));
}
function normalizeIntegerValue(
raw: unknown,
fallback: number,
min: number,
max: number,
) {
const value = Number.parseInt(String(raw ?? ""), 10);
if (!Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, value));
}
function summarizeCompileParseFailure(raw: unknown) {
if (raw === null || raw === undefined) return "empty";
if (typeof raw === "string") return `string:${raw.slice(0, 80)}`;
if (typeof raw !== "object") return typeof raw;
try {
const keys = Object.keys(raw as Record<string, unknown>).slice(0, 8);
return `keys:${keys.join("|") || "none"}`;
} catch {
return "object";
}
}
function finalizeCompiledSummary(params: {
summary: string;
prompt: string;
selectedTechniques: readonly string[];
}) {
const cleanedSummary = params.summary.trim();
const prompt = params.prompt.trim();
if (!prompt) {
return cleanedSummary.slice(0, 320);
}
const loweredSummary = cleanedSummary.toLowerCase();
const loweredPrompt = prompt.toLowerCase();
const suspiciousPhrases = [
"테스트 목적",
"테스트용",
"sample",
"example",
"for testing",
"test purpose",
];
const hasSuspiciousPhrase =
suspiciousPhrases.some((phrase) => loweredSummary.includes(phrase)) &&
!suspiciousPhrases.some((phrase) => loweredPrompt.includes(phrase));
const hasPromptCoverage = detectPromptCoverage(cleanedSummary, prompt);
if (hasSuspiciousPhrase) {
return buildPromptAnchoredSummary(prompt, params.selectedTechniques, cleanedSummary);
}
if (!hasPromptCoverage) {
return buildPromptAnchoredSummary(prompt, params.selectedTechniques, cleanedSummary);
}
return cleanedSummary.slice(0, 320);
}
function detectPromptCoverage(summary: string, prompt: string) {
const normalizedSummary = normalizeCoverageText(summary);
const keywords = extractPromptKeywords(prompt);
if (keywords.length === 0) return true;
return keywords.some((keyword) => normalizedSummary.includes(keyword));
}
function normalizeCoverageText(text: string) {
return text
.toLowerCase()
.replace(/[^a-z0-9가-힣]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function extractPromptKeywords(prompt: string) {
const stopwords = new Set([
"그리고",
"그냥",
"우선",
"위주",
"중심",
"하게",
"하면",
"현재",
"지금",
"please",
"with",
"from",
"that",
"this",
]);
return normalizeCoverageText(prompt)
.split(" ")
.map((token) => token.trim())
.filter((token) => token.length >= 2 && !stopwords.has(token))
.slice(0, 12);
}
function buildPromptAnchoredSummary(
prompt: string,
selectedTechniques: readonly string[],
aiSummary?: string,
) {
const promptExcerpt = prompt.replace(/\s+/g, " ").trim().slice(0, 120);
const techniquesText =
selectedTechniques.length > 0 ? ` (${selectedTechniques.join(", ")})` : "";
const aiSummaryText = aiSummary?.replace(/\s+/g, " ").trim().slice(0, 120);
if (!aiSummaryText) {
return `프롬프트 반영 전략${techniquesText}: ${promptExcerpt}`.slice(0, 320);
}
return `프롬프트 반영 전략${techniquesText}: ${promptExcerpt} | AI요약: ${aiSummaryText}`.slice(
0,
320,
);
}

View File

@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import {
AUTOTRADE_API_ERROR_CODE,
createAutotradeErrorResponse,
getAutotradeUserId,
readJsonBody,
} from "@/app/api/autotrade/_shared";
import { buildRiskEnvelope } from "@/lib/autotrade/risk";
const validateRequestSchema = z.object({
cashBalance: z.number().nonnegative(),
allocationPercent: z.number().nonnegative(),
allocationAmount: z.number().positive(),
dailyLossPercent: z.number().nonnegative(),
dailyLossAmount: z.number().positive(),
});
export async function POST(request: Request) {
const userId = await getAutotradeUserId(request.headers);
if (!userId) {
return createAutotradeErrorResponse({
status: 401,
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const rawBody = await readJsonBody(request);
const parsed = validateRequestSchema.safeParse(rawBody);
if (!parsed.success) {
return createAutotradeErrorResponse({
status: 400,
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
message: parsed.error.issues[0]?.message ?? "검증 입력값이 올바르지 않습니다.",
});
}
return NextResponse.json({
ok: true,
validation: buildRiskEnvelope(parsed.data),
});
}

View File

@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import {
AUTOTRADE_API_ERROR_CODE,
AUTOTRADE_WORKER_TOKEN_HEADER,
createAutotradeErrorResponse,
isAutotradeWorkerAuthorized,
listAutotradeSessions,
sanitizeAutotradeError,
sweepExpiredAutotradeSessions,
} from "@/app/api/autotrade/_shared";
export async function POST(request: Request) {
if (!isAutotradeWorkerAuthorized(request.headers)) {
return createAutotradeErrorResponse({
status: 401,
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
message: `${AUTOTRADE_WORKER_TOKEN_HEADER} 인증이 필요합니다.`,
});
}
try {
const sweep = sweepExpiredAutotradeSessions();
const sessions = listAutotradeSessions();
return NextResponse.json({
ok: true,
sweep,
runningSessions: sessions.filter((session) => session.runtimeState === "RUNNING").length,
stoppedSessions: sessions.filter((session) => session.runtimeState === "STOPPED").length,
checkedAt: new Date().toISOString(),
});
} catch (error) {
return createAutotradeErrorResponse({
status: 500,
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
message: sanitizeAutotradeError(error, "자동매매 워커 점검 중 오류가 발생했습니다."),
});
}
}

View File

@@ -15,6 +15,9 @@ import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [ const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
"1m", "1m",
"5m",
"10m",
"15m",
"30m", "30m",
"1h", "1h",
"1d", "1d",

View File

@@ -0,0 +1,72 @@
import { NextResponse } from "next/server";
import type { DashboardMarketHubResponse } from "@/features/dashboard/types/dashboard.types";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticDashboardMarketHub } from "@/lib/kis/dashboard";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
/**
* @file app/api/kis/domestic/market-hub/route.ts
* @description 국내주식 시장 허브(급등/인기/뉴스) 조회 API
*/
/**
* 대시보드 시장 허브 조회 API
* @returns 급등주식/인기종목/주요뉴스 목록
* @remarks UI 흐름: DashboardContainer -> useDashboardData -> /api/kis/domestic/market-hub -> MarketHubSection 렌더링
*/
export async function GET(request: Request) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message: "KIS API 키 설정이 필요합니다.",
});
}
try {
const result = await getDomesticDashboardMarketHub(credentials);
const response: DashboardMarketHubResponse = {
source: "kis",
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
gainers: result.gainers,
losers: result.losers,
popularByVolume: result.popularByVolume,
popularByValue: result.popularByValue,
news: result.news,
pulse: result.pulse,
warnings: result.warnings,
fetchedAt: new Date().toISOString(),
};
return NextResponse.json(response, {
headers: {
"cache-control": "no-store",
},
});
} catch (error) {
return createKisApiErrorResponse({
status: 500,
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
message: toKisApiErrorMessage(
error,
"시장 허브 조회 중 오류가 발생했습니다.",
),
});
}
}

View File

@@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { executeInquireOrderableCash } from "@/lib/kis/trade";
import type { DashboardStockOrderableCashResponse } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import {
readKisAccountParts,
readKisCredentialsFromHeaders,
} from "@/app/api/kis/domestic/_shared";
/**
* @file app/api/kis/domestic/orderable-cash/route.ts
* @description 국내주식 매수가능금액(주문가능현금) 조회 API
*/
const orderableCashBodySchema = z.object({
symbol: z
.string()
.trim()
.regex(/^\d{6}$/, "종목코드는 6자리 숫자여야 합니다."),
price: z.coerce.number().positive("기준 가격은 0보다 커야 합니다."),
orderType: z.enum(["limit", "market"]).default("market"),
});
export async function POST(request: NextRequest) {
const credentials = readKisCredentialsFromHeaders(request.headers);
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
tradingEnv,
});
}
if (!hasKisConfig(credentials)) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message: "KIS API 키 설정이 필요합니다.",
tradingEnv,
});
}
const account = readKisAccountParts(request.headers);
if (!account) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
message:
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
tradingEnv,
});
}
try {
let rawBody: unknown = {};
try {
rawBody = (await request.json()) as unknown;
} catch {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: "요청 본문(JSON)을 읽을 수 없습니다.",
tradingEnv,
});
}
const parsed = orderableCashBodySchema.safeParse(rawBody);
if (!parsed.success) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: parsed.error.issues[0]?.message ?? "요청값이 올바르지 않습니다.",
tradingEnv,
});
}
const result = await executeInquireOrderableCash(
{
symbol: parsed.data.symbol,
price: parsed.data.price,
orderType: parsed.data.orderType,
accountNo: account.accountNo,
accountProductCode: account.accountProductCode,
},
credentials,
);
const response: DashboardStockOrderableCashResponse = {
ok: true,
tradingEnv,
orderableCash: result.orderableCash,
noReceivableBuyAmount: result.noReceivableBuyAmount,
maxBuyAmount: result.maxBuyAmount,
maxBuyQuantity: result.maxBuyQuantity,
noReceivableBuyQuantity: result.noReceivableBuyQuantity,
fetchedAt: new Date().toISOString(),
};
return NextResponse.json(response, {
headers: {
"cache-control": "no-store",
},
});
} catch (error) {
return createKisApiErrorResponse({
status: 500,
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
message: toKisApiErrorMessage(error, "매수가능금액 조회 중 오류가 발생했습니다."),
tradingEnv,
});
}
}

View File

@@ -0,0 +1,64 @@
/**
* @file app/api/kis/indices/route.ts
* @description 국내 KOSPI/KOSDAQ 지수 조회 API
*
* @description [주요 책임]
* - 로그인 및 KIS API 설정 여부 확인
* - `getDomesticDashboardIndices` 함수를 호출하여 지수 데이터를 조회
* - 조회된 데이터를 클라이언트에 JSON 형식으로 반환
*/
import { NextResponse } from "next/server";
import { hasKisConfig } from "@/lib/kis/config";
import { getDomesticDashboardIndices } from "@/lib/kis/dashboard";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
export async function GET(request: Request) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message: "KIS API 키 설정이 필요합니다.",
});
}
try {
const indices = await getDomesticDashboardIndices(credentials);
return NextResponse.json(
{
indices,
fetchedAt: new Date().toISOString(),
},
{
headers: {
"cache-control": "no-store",
},
},
);
} catch (error) {
return createKisApiErrorResponse({
status: 500,
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
message: toKisApiErrorMessage(
error,
"지수 조회 중 오류가 발생했습니다.",
),
});
}
}

View File

@@ -7,9 +7,9 @@
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-noto-sans-kr);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
--font-heading: var(--font-heading); --font-heading: var(--font-gowun-heading);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -191,4 +191,10 @@
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
h1,
h2,
h3,
h4 {
font-family: var(--font-jua), var(--font-gowun-sans), sans-serif;
}
} }

View File

@@ -9,7 +9,7 @@
*/ */
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono, Outfit } from "next/font/google"; import { Geist_Mono, Gowun_Dodum, Noto_Sans_KR } from "next/font/google";
import { QueryProvider } from "@/providers/query-provider"; import { QueryProvider } from "@/providers/query-provider";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { SessionManager } from "@/features/auth/components/session-manager"; import { SessionManager } from "@/features/auth/components/session-manager";
@@ -17,9 +17,18 @@ import { GlobalAlertModal } from "@/features/layout/components/GlobalAlertModal"
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const gowunDodum = Gowun_Dodum({
variable: "--font-geist-sans", weight: "400",
subsets: ["latin"], variable: "--font-gowun-heading",
display: "swap",
preload: false,
});
const notoSansKr = Noto_Sans_KR({
weight: ["400", "500", "700"],
variable: "--font-noto-sans-kr",
display: "swap",
preload: false,
}); });
const geistMono = Geist_Mono({ const geistMono = Geist_Mono({
@@ -27,12 +36,6 @@ const geistMono = Geist_Mono({
subsets: ["latin"], subsets: ["latin"],
}); });
const outfit = Outfit({
variable: "--font-heading",
subsets: ["latin"],
display: "swap",
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "JOORIN-E (주린이) - 감이 아닌 전략으로 시작하는 자동매매", title: "JOORIN-E (주린이) - 감이 아닌 전략으로 시작하는 자동매매",
description: description:
@@ -52,9 +55,14 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en" className="scroll-smooth" suppressHydrationWarning> <html
lang="en"
className="scroll-smooth"
data-scroll-behavior="smooth"
suppressHydrationWarning
>
<body <body
className={`${geistSans.variable} ${geistMono.variable} ${outfit.variable} antialiased`} className={`${notoSansKr.variable} ${geistMono.variable} ${gowunDodum.variable} font-sans antialiased`}
> >
<ThemeProvider <ThemeProvider
attribute="class" attribute="class"

View File

@@ -0,0 +1,82 @@
# 자동매매 모델 카탈로그 운영 런북 (Codex/Gemini)
이 문서는 **새 모델이 나왔을 때** 자동매매 모델 선택 UI/서버 설정을 안전하게 갱신하기 위한 운영 절차입니다.
## 1) 목적
1. Codex/Gemini 신모델을 빠르게 목록에 반영한다.
2. 잘못된 모델 ID로 인해 자동매매가 fallback으로 떨어지는 문제를 줄인다.
3. 운영자가 "어디를 고치고 어떻게 검증하는지"를 한 번에 확인할 수 있게 한다.
## 2) 적용 범위
1. 자동매매 설정창 모델 드롭다운
2. 서버 모델 선택 우선순위(env + UI)
3. 전략/신호 응답에서 `providerVendor`, `providerModel` 추적
## 3) 빠른 절차 (입력 -> 처리 -> 결과)
1. 입력: 공식 문서에서 신규 모델 ID 확인
2. 처리: 모델 옵션 상수 + 안내 문구 + 기본 env 값 점검
3. 결과: UI 선택 가능 + 로그/응답에서 실제 모델 확인 가능
## 4) 공식 소스(항상 여기 먼저 확인)
1. OpenAI Codex CLI: <https://developers.openai.com/codex/cli>
2. OpenAI Models: <https://platform.openai.com/docs/models>
3. Gemini CLI model command: <https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/model.md>
4. Gemini CLI model routing: <https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/model-routing.md>
5. Gemini API models: <https://ai.google.dev/gemini-api/docs/models>
## 5) 코드 반영 위치
1. 모델 드롭다운 목록
- `features/autotrade/types/autotrade.types.ts`
- `AUTOTRADE_SUBSCRIPTION_CLI_MODEL_OPTIONS.codex`
- `AUTOTRADE_SUBSCRIPTION_CLI_MODEL_OPTIONS.gemini`
2. 기본값/우선순위 점검
- `lib/autotrade/strategy.ts` (`resolveDefaultSubscriptionCliModel`)
- `lib/autotrade/cli-provider.ts` (`resolveSubscriptionCliModel`)
3. 사용자 안내 문구(필요 시)
- `features/autotrade/components/AutotradeControlPanel.tsx`
4. 샘플 환경변수 문서화
- `.env.example`
## 6) 모델 추가 규칙
1. 모델 ID는 **공식 문서 표기 그대로** 입력한다.
2. preview 모델은 라벨에 `(프리뷰)`를 명시한다.
3. 종료 예정 모델은 라벨/설명에 종료 예정일을 남긴다.
4. 기존 안정형 모델 1개 이상은 항상 남겨둔다.
5. 목록에 없는 모델도 쓸 수 있도록 `직접 입력` 경로는 유지한다.
## 7) 검증 체크리스트
- [ ] 드롭다운에 신규 모델이 보인다.
- [ ] 신규 모델 선택 후 compile/signal 요청 payload에 `subscriptionCliModel`이 들어간다.
- [ ] 응답에 `providerVendor`, `providerModel`이 기대값으로 온다.
- [ ] 자동매매 로그에 `subscription_cli:vendor:model`이 표시된다.
- [ ] `npm run -s lint` 통과
## 8) 수동 검증 포인트(화면 기준)
1. 자동매매 설정 -> 구독형 CLI 엔진 선택(codex 또는 gemini)
2. 신규 모델 선택 후 자동매매 시작
3. 로그에서 아래 3개 필드 확인
- `subscriptionCliVendor`
- `subscriptionCliModel`
- `providerModel`
## 9) 장애 대응
1. 모델 호출 실패 시 우선 `직접 입력`으로 동일 ID 재시도
2. 계속 실패하면 직전 안정 모델로 즉시 롤백
3. `AUTOTRADE_SUBSCRIPTION_CLI_DEBUG=1`로 서버 로그에서 CLI stderr 확인
## 10) 변경 이력 템플릿
```md
- YYYY-MM-DD: [vendor] modelA, modelB 추가
- YYYY-MM-DD: [vendor] modelX 종료 예정 표기
- YYYY-MM-DD: 기본 추천 모델 변경 (old -> new)
```

View File

@@ -0,0 +1,144 @@
# 자동매매 프롬프트 흐름 추적 가이드 (UI -> 함수 -> AI -> 주문)
이 문서는 "전략 프롬프트를 입력하면 실제로 어디 함수로 흘러가고, 어디서 AI가 호출되는지"를 코드 라인 기준으로 설명합니다.
## 1) 한 줄 요약
사용자가 UI에 프롬프트를 입력하면, 시작/검증 시점에 `compile` API로 전달되어 전략 JSON으로 바뀌고, 실행 중에는 그 전략 JSON + 실시간 시세로 신호를 생성해 주문 여부를 결정합니다.
## 2) 구조 그림
```text
[브라우저 UI]
AutotradeControlPanel.tsx
└─ 프롬프트 입력 + 시작/검증 클릭
[브라우저 엔진 훅]
useAutotradeEngine.ts
└─ prepareStrategy()에서 compile/validate 실행
[브라우저 API 클라이언트]
autotrade.api.ts
└─ /api/autotrade/strategies/compile 호출
[Next 서버 route]
strategies/compile/route.ts
└─ OpenAI / subscription_cli / fallback 분기
[AI Provider]
openai.ts 또는 cli-provider.ts
└─ 전략 JSON 반환
[브라우저 엔진 훅]
useAutotradeEngine.ts
└─ compiledStrategy 저장 후 실행 루프 시작
[신호 루프]
/api/autotrade/signals/generate -> 리스크 게이트 -> 주문 API
```
## 3) 프롬프트 입력 -> 전략 컴파일 (상세 추적)
1. 프롬프트 입력 UI
- 컴포넌트: [`AutotradeControlPanel.tsx#L335`](../../features/autotrade/components/AutotradeControlPanel.tsx#L335)
- 입력 이벤트: [`handlePromptChange`](../../features/autotrade/components/AutotradeControlPanel.tsx#L123)
- store 반영: [`patchSetupForm({ prompt })`](../../features/autotrade/components/AutotradeControlPanel.tsx#L126)
- 같은 화면에서 구독형 CLI vendor/model도 선택 가능: `subscriptionCliVendor`, `subscriptionCliModel`
2. 시작/검증 버튼 클릭
- 시작 버튼 핸들러: [`handleStartAutotrade`](../../features/autotrade/components/AutotradeControlPanel.tsx#L102)
- 검증 버튼 핸들러: [`handlePreviewValidation`](../../features/autotrade/components/AutotradeControlPanel.tsx#L113)
3. 엔진 훅에서 전략 준비
- 함수: [`prepareStrategy()`](../../features/autotrade/hooks/useAutotradeEngine.ts#L138)
- compile 호출: [`compileAutotradeStrategy(...)`](../../features/autotrade/hooks/useAutotradeEngine.ts#L153)
4. 브라우저 API 클라이언트
- 함수: [`compileAutotradeStrategy`](../../features/autotrade/apis/autotrade.api.ts#L30)
- HTTP 호출: [`POST /api/autotrade/strategies/compile`](../../features/autotrade/apis/autotrade.api.ts#L36)
- 전달 필드: `aiMode`, `subscriptionCliVendor`, `subscriptionCliModel`, `prompt`, `selectedTechniques`, `confidenceThreshold`
5. Next API route에서 provider 분기
- 엔드포인트: [`strategies/compile/route.ts#L44`](../../app/api/autotrade/strategies/compile/route.ts#L44)
- fallback 전략 준비: [`createFallbackCompiledStrategy`](../../app/api/autotrade/strategies/compile/route.ts#L67)
- OpenAI 분기: [`compileStrategyWithOpenAi`](../../app/api/autotrade/strategies/compile/route.ts#L87)
- 구독형 CLI 분기: [`compileStrategyWithSubscriptionCliDetailed`](../../app/api/autotrade/strategies/compile/route.ts#L119)
6. OpenAI 실제 호출 지점
- OpenAI 전략 함수: [`compileStrategyWithOpenAi`](../../lib/autotrade/openai.ts#L51)
- 공통 호출기: [`callOpenAiJson`](../../lib/autotrade/openai.ts#L203)
- 외부 API: [`https://api.openai.com/v1/chat/completions`](../../lib/autotrade/openai.ts#L19)
7. 컴파일 결과 반영
- compiledStrategy 저장: [`setCompiledStrategy(...)`](../../features/autotrade/hooks/useAutotradeEngine.ts#L160)
- validate 저장: [`setValidation(...)`](../../features/autotrade/hooks/useAutotradeEngine.ts#L173)
## 4) 실행 중 "자동 프롬프트"가 도는 방식
중요: 실행 중 매 틱마다 자연어 프롬프트를 다시 보내지 않습니다.
1. 시작 시점에만 프롬프트를 전략 JSON으로 컴파일합니다.
2. 실행 루프에서는 "컴파일된 전략 JSON + 현재 시세 스냅샷"으로 신호를 만듭니다.
관련 코드:
1. 신호 요청 주기(12초): [`SIGNAL_REQUEST_INTERVAL_MS`](../../features/autotrade/hooks/useAutotradeEngine.ts#L51)
2. 신호 API 호출: [`generateAutotradeSignal(...)`](../../features/autotrade/hooks/useAutotradeEngine.ts#L495)
3. 서버 신호 route: [`signals/generate/route.ts#L74`](../../app/api/autotrade/signals/generate/route.ts#L74)
4. 신호 생성 OpenAI 함수: [`generateSignalWithOpenAi`](../../lib/autotrade/openai.ts#L116)
신호 요청 시 스냅샷 실제 필드:
1. `symbol`
2. `currentPrice`
3. `changeRate`
4. `open`
5. `high`
6. `low`
7. `tradeVolume`
8. `accumulatedVolume`
9. `recentPrices`
## 5) 신호 -> 주문 판단 (자동 실행 핵심)
1. 신호 생성 결과 수신: [`runtime.setLastSignal(signal)`](../../features/autotrade/hooks/useAutotradeEngine.ts#L504)
2. 리스크 게이트 검사: [`evaluateSignalBlockers(...)`](../../features/autotrade/hooks/useAutotradeEngine.ts#L516)
3. 통과 시 주문 API 호출: [`fetchOrderCash(...)`](../../features/autotrade/hooks/useAutotradeEngine.ts#L556)
즉, AI가 `buy/sell`을 주더라도 리스크 게이트를 통과하지 못하면 주문은 실행되지 않습니다.
## 6) AI를 못 쓰는 경우
1. 전략 폴백: [`createFallbackCompiledStrategy`](../../lib/autotrade/strategy.ts#L26)
2. 신호 폴백: [`createFallbackSignalCandidate`](../../lib/autotrade/strategy.ts#L48)
AI(OpenAI/CLI) 응답 실패 시에도 시스템이 멈추지 않고 보수적으로 동작하도록 설계되어 있습니다.
## 7) Codex CLI인지 Gemini CLI인지 확인하는 법
1. 자동매매 로그에서 확인
- `신호 수신 [subscription_cli:codex:gpt-5-codex]` 또는 `신호 수신 [subscription_cli:gemini:flash]`
- 로그 코드: [`useAutotradeEngine.ts`](../../features/autotrade/hooks/useAutotradeEngine.ts#L564)
2. Network 응답에서 확인
- 전략 컴파일 응답: `compiledStrategy.providerVendor`
- 신호 생성 응답: `signal.providerVendor`
3. 실패 시 어떤 순서로 시도했는지 확인
- 파싱 실패 문구에 `selected=vendor:model; attempts=vendor:model:status` 포함
- `status=timeout`이면 CLI 실행시간 초과입니다. `AUTOTRADE_SUBSCRIPTION_CLI_TIMEOUT_MS`를 늘리세요(권장: 60000).
- 생성 코드: [`summarizeSubscriptionCliExecution`](../../lib/autotrade/cli-provider.ts#L112)
4. 모델 선택 환경변수
- `AUTOTRADE_CODEX_MODEL` (예: `gpt-5-codex`)
- `AUTOTRADE_GEMINI_MODEL` (예: `auto`, `pro`, `flash`, `flash-lite`)
- `AUTOTRADE_SUBSCRIPTION_CLI_MODEL` (vendor 전용 값이 없을 때 공통 fallback)
5. 모델 선택 UI (환경변수보다 우선)
- 자동매매 설정창에서 `subscriptionCliVendor`, `subscriptionCliModel` 선택 시 해당 값이 API payload로 전달되어 CLI 실행 인자에 우선 적용됩니다.

View File

@@ -0,0 +1,407 @@
# 자동매매 사용/검증/보안 가이드 (3계층 구조)
이 문서는 자동매매를 아래 3개 영역으로 나눠서 설명합니다.
1. 사용자 브라우저
2. Next.js 서버(API)
3. 워커(Node)
프롬프트 입력값이 실제로 어디 함수/어디 API로 흘러가는지 추적하려면 아래 문서를 같이 보세요.
- `common-docs/features/autotrade-prompt-flow-guide.md`
---
## 1) 한눈에 구조
```text
┌───────────────────────────── 사용자 브라우저 ─────────────────────────────┐
│ /trade 자동매매 UI │
│ - 설정 입력(전략/투자금/손실한도/임계치) │
│ - start/stop/heartbeat/signals 호출 │
└───────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────── Next.js 서버 (API) ──────────────────────────┐
│ /api/autotrade/strategies/* │
│ /api/autotrade/sessions/* │
│ /api/autotrade/signals/generate │
│ /api/autotrade/worker/tick │
└───────────────────────────────────────────────────────────────────────────┘
│ x-autotrade-worker-token
┌────────────────────────────── Worker (Node) ─────────────────────────────┐
│ scripts/autotrade-worker.mjs │
│ - 주기적으로 /api/autotrade/worker/tick 호출 │
│ - heartbeat 만료 세션 정리 │
└───────────────────────────────────────────────────────────────────────────┘
```
---
## 1-1) 개발 실행 시 (내 PC 기준)
1. 브라우저: React 화면 사용
2. Next 개발 서버(`npm run dev`): 화면 + API를 함께 처리
3. 워커(`node scripts/autotrade-worker.mjs`): tick 호출 담당
즉, 개발에서는 보통 `Next 1개 + Worker 1개` 프로세스를 실행합니다.
## 1-2) 운영 배포 시
운영은 보통 아래 2가지 중 하나입니다.
1. 같은 Linux 서버에 Next + Worker 같이 운영
2. Next는 배포 플랫폼, Worker는 별도 Linux 서버에서 운영
공통 원칙:
1. 브라우저는 Next API를 호출
2. 워커도 Next API(`/api/autotrade/worker/tick`)를 호출
3. 워커 인증은 `x-autotrade-worker-token`으로 처리
---
## 2) 레이어별 역할
## 2-1) 사용자 브라우저
하는 일:
1. 자동매매 설정 입력
2. 전략 컴파일/검증 요청
3. 세션 시작 후 10초마다 heartbeat 전송
4. 신호 요청 후 주문 가능 여부 판단
5. 브라우저 종료/외부 이동 시 중지 처리
핵심 소스:
1. UI: [`AutotradeControlPanel`](../../features/autotrade/components/AutotradeControlPanel.tsx#L25)
2. 엔진: [`useAutotradeEngine`](../../features/autotrade/hooks/useAutotradeEngine.ts#L118)
3. heartbeat 루프: [`useAutotradeEngine`](../../features/autotrade/hooks/useAutotradeEngine.ts#L336)
4. 주문 직전 게이트+주문 호출: [`useAutotradeEngine`](../../features/autotrade/hooks/useAutotradeEngine.ts#L426)
## 2-2) Next.js 서버(API)
하는 일:
1. 사용자 인증 검사
2. 전략 compile/validate 처리
3. 세션 start/heartbeat/stop/active 관리
4. AI 호출 실패 시 폴백 전략/신호로 대응
5. 워커 토큰 인증 후 만료 세션 정리
핵심 소스:
1. 공통 유틸: [`_shared.ts`](../../app/api/autotrade/_shared.ts)
2. compile: [`POST /strategies/compile`](../../app/api/autotrade/strategies/compile/route.ts#L22)
3. validate: [`POST /strategies/validate`](../../app/api/autotrade/strategies/validate/route.ts#L19)
4. sessions: [`/sessions/start`](../../app/api/autotrade/sessions/start/route.ts#L21), [`/sessions/heartbeat`](../../app/api/autotrade/sessions/heartbeat/route.ts#L18), [`/sessions/stop`](../../app/api/autotrade/sessions/stop/route.ts#L27), [`/sessions/active`](../../app/api/autotrade/sessions/active/route.ts#L9)
5. 신호 생성: [`POST /signals/generate`](../../app/api/autotrade/signals/generate/route.ts#L41)
## 2-3) 워커(Node)
하는 일:
1. 주기적으로 Next API `/api/autotrade/worker/tick` 호출
2. heartbeat 끊긴 세션을 timeout 종료
3. 정리 결과 로그 출력
핵심 소스:
1. 워커 스크립트: [`autotrade-worker.mjs`](../../scripts/autotrade-worker.mjs)
2. 워커 API: [`POST /worker/tick`](../../app/api/autotrade/worker/tick/route.ts#L12)
3. 만료 정리 함수: [`sweepExpiredAutotradeSessions()`](../../app/api/autotrade/_shared.ts#L147)
---
## 3) 가장 헷갈리는 개념 3개
## 3-1) 폴백 전략(fallback)
뜻:
1. AI를 못 쓰는 상황에서 쓰는 대체 규칙
2. 자동매매를 완전 중지하지 않고 보수적으로 유지
3. 애매하면 `hold`를 더 자주 반환
관련 소스:
1. 전략 폴백: [`createFallbackCompiledStrategy()`](../../lib/autotrade/strategy.ts#L16)
2. 신호 폴백: [`createFallbackSignalCandidate()`](../../lib/autotrade/strategy.ts#L36)
3. AI 호출: [`callOpenAiJson()`](../../lib/autotrade/openai.ts#L187)
## 3-2) heartbeat
뜻:
1. 브라우저가 Next 서버로 보내는 "세션 살아있음" 신호
2. 워커가 보내는 신호가 아님
## 3-3) worker tick
뜻:
1. 워커가 Next 서버로 보내는 "만료 세션 정리 요청"
2. heartbeat가 끊긴 세션을 timeout 종료
---
## 3-4) 구독형 CLI 자동판단(신규)
뜻:
1. OpenAI API 키 대신 서버에 설치된 `gemini` 또는 `codex` CLI를 호출해 자동판단
2. 자동판단 결과(JSON)를 파싱해 전략/신호에 반영
3. CLI 호출 실패 또는 파싱 실패 시 규칙 기반으로 자동 폴백
UI에서 선택:
1. 자동매매 설정창에서 `구독형 CLI 엔진``auto/codex/gemini` 중 선택
2. `codex` 또는 `gemini` 선택 시 공식 문서 기반 추천 모델 목록을 드롭다운으로 선택
3. 목록에 없는 최신 모델은 `직접 입력`으로 설정
모델 우선순위:
1. UI에서 선택한 모델(있을 때)
2. `AUTOTRADE_CODEX_MODEL` / `AUTOTRADE_GEMINI_MODEL`
3. `AUTOTRADE_SUBSCRIPTION_CLI_MODEL`
4. 각 CLI 기본 모델
환경변수:
```env
AUTOTRADE_AI_MODE=subscription_cli
AUTOTRADE_SUBSCRIPTION_CLI=auto
AUTOTRADE_SUBSCRIPTION_CLI_MODEL=
AUTOTRADE_CODEX_MODEL=
AUTOTRADE_GEMINI_MODEL=
AUTOTRADE_SUBSCRIPTION_CLI_TIMEOUT_MS=60000
AUTOTRADE_SUBSCRIPTION_CLI_DEBUG=0
AUTOTRADE_CODEX_COMMAND=
AUTOTRADE_GEMINI_COMMAND=
```
동작 우선순위:
1. `AUTOTRADE_SUBSCRIPTION_CLI=auto`면 codex -> gemini 순서로 시도
2. 모델 선택 우선순위는 `vendor 전용 모델` -> `AUTOTRADE_SUBSCRIPTION_CLI_MODEL` -> `CLI 기본 모델`
3. 둘 다 실패하면 fallback 규칙 신호 사용
4. 로그에 `attempts=codex:default:timeout`가 나오면 CLI 타임아웃이므로 `AUTOTRADE_SUBSCRIPTION_CLI_TIMEOUT_MS`를 더 크게 설정
5. 로그에 `attempts=codex:gpt-5-codex:error(...)`처럼 괄호가 붙으면 실제 실패 원인(stderr/spawn 에러)입니다.
어떤 CLI를 썼는지 확인:
1. 자동매매 로그에서 `신호 수신 [subscription_cli:codex:gpt-5-codex]` 또는 `신호 수신 [subscription_cli:gemini:flash]` 확인
2. Network 응답에서 `providerVendor` 확인
- `/api/autotrade/strategies/compile` 응답: `compiledStrategy.providerVendor`
- `/api/autotrade/signals/generate` 응답: `signal.providerVendor`
3. Network 응답에서 `providerModel` 확인
- `/api/autotrade/strategies/compile` 응답: `compiledStrategy.providerModel`
- `/api/autotrade/signals/generate` 응답: `signal.providerModel`
4. 파싱 실패 시 reason/summary에 `selected=vendor:model; attempts=...` 형태로 시도 결과 포함
`selected=none:default; attempts=codex:gpt-5-codex:error(...)`가 보이면:
1. `AUTOTRADE_SUBSCRIPTION_CLI_DEBUG=1`로 켜고 `npm run dev`를 재시작합니다.
2. Next 서버 콘솔에서 `[autotrade-cli]` 로그를 확인합니다.
3. `spawn:ENOENT`가 보이면 `AUTOTRADE_CODEX_COMMAND` 또는 `AUTOTRADE_GEMINI_COMMAND`에 CLI 절대경로를 넣습니다.
4. 예: `AUTOTRADE_CODEX_COMMAND=C:\\Users\\<계정>\\AppData\\Roaming\\npm\\codex.cmd`
모델 지정 예시:
```env
# Codex만 쓸 때
AUTOTRADE_SUBSCRIPTION_CLI=codex
AUTOTRADE_CODEX_MODEL=gpt-5-codex
# Gemini만 쓸 때
AUTOTRADE_SUBSCRIPTION_CLI=gemini
AUTOTRADE_GEMINI_MODEL=flash
# auto 모드에서 공통 모델 fallback만 쓸 때
AUTOTRADE_SUBSCRIPTION_CLI=auto
AUTOTRADE_SUBSCRIPTION_CLI_MODEL=auto
```
공식 문서:
1. Codex CLI 옵션(`--model`): <https://developers.openai.com/codex/cli>
2. OpenAI 모델 목록(`gpt-5-codex` 포함): <https://platform.openai.com/docs/models>
3. Gemini CLI 모델 선택: <https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/model.md>
4. Gemini CLI 모델 우선순위(`--model` > `GEMINI_MODEL`): <https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/model-routing.md>
모델 갱신 운영 런북:
1. 새 모델 출시 대응 절차: [`autotrade-model-catalog-runbook.md`](./autotrade-model-catalog-runbook.md)
관련 소스:
1. CLI 공급자: [`lib/autotrade/cli-provider.ts`](../../lib/autotrade/cli-provider.ts)
2. 전략 compile 라우트: [`/strategies/compile`](../../app/api/autotrade/strategies/compile/route.ts)
3. 신호 generate 라우트: [`/signals/generate`](../../app/api/autotrade/signals/generate/route.ts)
---
## 4) 환경변수: 어디에 넣는지
## 4-1) 앱(Next.js 서버)
위치:
1. 로컬: `.env.local`
2. 운영: 배포 환경변수
필수:
```env
AUTOTRADE_WORKER_TOKEN=<랜덤시크릿>
OPENAI_API_KEY=<옵션, 없으면 폴백 동작>
```
## 4-2) 워커(Node/PM2)
위치:
1. PM2 실행 셸 환경변수
2. 서버 시스템 환경변수
필수:
```env
AUTOTRADE_WORKER_TOKEN=<앱과동일값>
AUTOTRADE_APP_URL=<Next서버URL>
AUTOTRADE_WORKER_POLL_MS=5000
```
중요:
1. `AUTOTRADE_WORKER_TOKEN`은 사용자별이 아니라 서비스별 시크릿
2. 앱과 워커가 같은 값을 써야 인증 통과
---
## 5) 실행 순서 (앱/워커 분리)
## 5-1) 로컬 개발
터미널 A:
```bash
npm run dev
```
터미널 B:
```bash
AUTOTRADE_WORKER_TOKEN="<앱과같은값>" \
AUTOTRADE_APP_URL="http://127.0.0.1:3001" \
node scripts/autotrade-worker.mjs
```
Windows PowerShell:
```powershell
npm run dev
# 새 터미널
$env:AUTOTRADE_WORKER_TOKEN="<앱과같은값>"
$env:AUTOTRADE_APP_URL="http://127.0.0.1:3001"
$env:AUTOTRADE_WORKER_POLL_MS="5000"
npm run worker:autotrade
```
또는 `.env.local` 기반:
```powershell
npm run worker:autotrade:dev
```
## 5-2) 운영(PM2)
```bash
export AUTOTRADE_WORKER_TOKEN="<앱과같은값>"
export AUTOTRADE_APP_URL="https://your-domain.com"
export AUTOTRADE_WORKER_POLL_MS="5000"
pm2 start scripts/pm2.autotrade-worker.config.cjs
pm2 logs autotrade-worker
```
---
## 6) end-to-end 흐름 (브라우저 -> 서버 -> 워커)
1. 브라우저: 설정 입력
2. 서버: `/strategies/compile` (AI 또는 폴백)
3. 서버: `/strategies/validate` (리스크 계산)
4. 서버: `/sessions/start`
5. 브라우저: 10초마다 `/sessions/heartbeat`
6. 브라우저: 주기적으로 `/signals/generate`
7. 브라우저: 리스크 게이트 통과 시 주문 API 호출
8. 브라우저: 중지 이벤트 시 `/sessions/stop`
9. 워커: `/worker/tick`로 heartbeat 만료 세션 정리
---
## 6-1) AI가 실제로 받는 판단 데이터
자동매매는 "자연어 프롬프트만" 보내는 구조가 아닙니다. 실행 중에는 아래 구조화된 데이터가 같이 전달됩니다.
1. 전략(compile 결과)
- `selectedTechniques`
- `confidenceThreshold`
- `maxDailyOrders`
- `cooldownSec`
- `maxOrderAmountRatio`
2. 시세 스냅샷(signal 요청 시)
- `symbol`
- `currentPrice`
- `changeRate`
- `open/high/low`
- `tradeVolume`
- `accumulatedVolume`
- `recentPrices`(최근 체결가 배열)
3. 서버 리스크 검증 결과
- AI 신호가 `buy/sell`이어도 리스크 게이트 미통과 시 주문 차단
즉, AI는 "현재 종목 + 현재가 + 가격 흐름 + 전략 제약"을 같이 받아 판단하고, 최종 주문은 리스크 게이트를 통과해야 실행됩니다.
관련 소스:
1. 스냅샷 구성: [`useAutotradeEngine.ts`](../../features/autotrade/hooks/useAutotradeEngine.ts)
2. 신호 route 검증: [`signals/generate/route.ts`](../../app/api/autotrade/signals/generate/route.ts)
3. 리스크 게이트: [`risk.ts`](../../lib/autotrade/risk.ts)
---
## 7) 보안: 레이어별 핵심
## 7-1) 브라우저
1. KIS 민감정보는 세션 저장소(sessionStorage) 사용
2. 브라우저 종료 시 세션 저장소 제거
## 7-2) Next 서버
1. 자동매매 API는 사용자 인증 필요
2. 워커 API는 `x-autotrade-worker-token` 인증 필요
3. 민감정보 문자열 마스킹 처리
## 7-3) 워커
1. 토큰이 틀리면 401
2. 토큰은 코드 하드코딩 금지
---
## 8) 역할별로 어디 보면 되는지
1. 기획/대표: 1, 2, 6, 7장
2. QA: 5, 6, 7장 + worker 문서 6, 7장
3. 개발: 2장 소스링크 + worker 문서 전체
---
## 9) 추가 문서
1. 워커 상세 운영: [`autotrade-worker-pm2.md`](./autotrade-worker-pm2.md)

View File

@@ -0,0 +1,269 @@
# 자동매매 워커 운영 가이드 (실행/배포 구조 이해용)
이 문서는 "앱을 실행하면 뭐가 어디서 도는지"를 먼저 설명하고, 그다음 실행 방법을 설명합니다.
## 0) 먼저 용어 정리
1. React 앱: 브라우저에서 보이는 UI (`/trade` 화면)
2. Next.js 서버: React 화면 제공 + API(`/api/*`) 처리
3. 워커(Node): 백그라운드에서 `/api/autotrade/worker/tick` 호출하는 별도 프로세스
중요:
1. React와 API는 보통 같은 Next 프로세스에서 동작합니다.
2. 워커는 Next와 별도 프로세스입니다.
---
## 1) 개발(local)에서 실제로 어디서 도는가
```text
내 PC
┌──────────────────────────────────────────────────────────────────────────┐
│ 브라우저(Chrome) │
│ - /trade 화면 렌더링 │
│ - heartbeat 전송 (/sessions/heartbeat) │
└──────────────────────────────────────────────────────────────────────────┘
│ http://127.0.0.1:3001
┌──────────────────────────────────────────────────────────────────────────┐
│ 터미널 A: Next 개발 서버 (`npm run dev`) │
│ - React 페이지 제공 │
│ - /api/autotrade/* API 처리 │
└──────────────────────────────────────────────────────────────────────────┘
│ x-autotrade-worker-token
┌──────────────────────────────────────────────────────────────────────────┐
│ 터미널 B: 워커 (`node scripts/autotrade-worker.mjs`) │
│ - /api/autotrade/worker/tick 주기 호출 │
└──────────────────────────────────────────────────────────────────────────┘
외부 클라우드 서비스
- Supabase(Auth/DB)
- KIS API
- OpenAI API(선택)
```
핵심:
1. 개발에서는 보통 프로세스 2개를 띄웁니다.
2. Next 1개 + Worker 1개
---
## 2) 운영(prod)에서 실제로 어디서 도는가
## 2-1) 패턴 A: 같은 Linux 서버에 Next + Worker
```text
사용자 브라우저
│ HTTPS
[Linux 서버]
- Next 앱 프로세스 (웹 + API)
- Worker 프로세스 (PM2)
└─ 내부에서 /api/autotrade/worker/tick 호출
```
장점:
1. 구성 단순
2. 네트워크 경로 짧음
## 2-2) 패턴 B: Next는 플랫폼(Vercel 등), Worker는 별도 Linux
```text
사용자 브라우저 ──HTTPS──> Next 배포 플랫폼(웹+API)
│ HTTPS + x-autotrade-worker-token
Linux Worker 서버(PM2)
```
장점:
1. 앱/워커 분리 운영 가능
2. 워커 자원 독립 관리 가능
주의:
1. 워커 서버에서 Next 도메인으로 접근 가능해야 함
2. 토큰/URL 설정을 양쪽에 정확히 맞춰야 함
---
## 3) 서버에서 "무엇이 돌아가는지" 체크표
| 구성요소 | 실제 실행 위치 | 프로세스 | 시작 명령 예시 | 역할 |
|---|---|---|---|---|
| React UI | 사용자 브라우저 | Browser Tab | URL 접속 | 화면 렌더링, 사용자 입력 |
| Next 서버 | Linux/플랫폼 | Node(Next) | `npm run dev` 또는 `npm run start` | 웹 + `/api/autotrade/*` 처리 |
| Worker | Linux/Worker 서버 | Node Script(PM2) | `pm2 start scripts/pm2.autotrade-worker.config.cjs` | 만료 세션 정리 |
---
## 4) heartbeat와 worker/tick 차이
1. heartbeat
브라우저 -> Next 서버
세션 살아있음 알림
2. worker/tick
워커 -> Next 서버
heartbeat 끊긴 세션 정리 요청
즉:
1. heartbeat는 "상태 보고"
2. tick은 "청소 작업"
---
## 5) 토큰/URL: 뭘 어떻게 넣어야 하나
## 5-1) `AUTOTRADE_WORKER_TOKEN`
뜻:
1. 사용자용 토큰 아님
2. 앱 서버와 워커 간 내부 인증 시크릿
3. 환경별(dev/staging/prod)로 1개 사용
생성 예시:
```bash
openssl rand -hex 32
```
## 5-2) `AUTOTRADE_APP_URL`
뜻:
1. 워커가 호출할 Next 서버 주소
예시:
1. 로컬: `http://127.0.0.1:3001`
2. 운영: `https://your-domain.com`
---
## 6) 어디 파일/어디 시스템에 넣나
## 6-1) 앱(Next 서버)
위치:
1. 로컬: `.env.local`
2. 운영: 배포 환경변수
필수:
```env
AUTOTRADE_WORKER_TOKEN=<랜덤시크릿>
```
## 6-2) 워커(Node/PM2)
위치:
1. PM2 실행 셸 환경변수
2. 서버 시스템 환경변수
필수:
```env
AUTOTRADE_WORKER_TOKEN=<앱과동일값>
AUTOTRADE_APP_URL=<Next서버URL>
AUTOTRADE_WORKER_POLL_MS=5000
```
중요:
1. 앱/워커 토큰 값은 완전히 같아야 합니다.
2. 다르면 `/worker/tick`가 401로 실패합니다.
---
## 7) 실행 방법
## 7-1) 로컬 개발
터미널 A (Next):
```bash
npm run dev
```
터미널 B (Worker):
```bash
AUTOTRADE_WORKER_TOKEN="<앱과같은값>" \
AUTOTRADE_APP_URL="http://127.0.0.1:3001" \
AUTOTRADE_WORKER_POLL_MS="5000" \
node scripts/autotrade-worker.mjs
```
## 7-1-a) 로컬 개발 (Windows PowerShell)
터미널 A (Next):
```powershell
npm run dev
```
터미널 B (Worker):
```powershell
$env:AUTOTRADE_WORKER_TOKEN="<앱과같은값>"
$env:AUTOTRADE_APP_URL="http://127.0.0.1:3001"
$env:AUTOTRADE_WORKER_POLL_MS="5000"
npm run worker:autotrade
```
`.env.local` 값을 바로 쓰고 싶으면:
```powershell
npm run worker:autotrade:dev
```
## 7-2) 운영 서버 (PM2)
```bash
npm i -g pm2
export AUTOTRADE_WORKER_TOKEN="<앱과같은값>"
export AUTOTRADE_APP_URL="https://your-domain.com"
export AUTOTRADE_WORKER_POLL_MS="5000"
pm2 start scripts/pm2.autotrade-worker.config.cjs
pm2 status
pm2 logs autotrade-worker
pm2 save
pm2 startup
```
---
## 8) 장애 시 빠른 점검
1. 워커 401
원인: 앱/워커 토큰 불일치
조치: `AUTOTRADE_WORKER_TOKEN` 동일화
2. fetch failed
원인: `AUTOTRADE_APP_URL` 오타, Next 미기동
조치: URL/앱 프로세스 확인
3. 세션이 안 정리됨
원인: heartbeat 정상 수신 중일 수 있음
조치: 브라우저 종료 후 TTL 경과 뒤 확인
---
## 9) 관련 소스
1. 워커: [`scripts/autotrade-worker.mjs`](../../scripts/autotrade-worker.mjs)
2. PM2 설정: [`scripts/pm2.autotrade-worker.config.cjs`](../../scripts/pm2.autotrade-worker.config.cjs)
3. 워커 API: [`app/api/autotrade/worker/tick/route.ts`](../../app/api/autotrade/worker/tick/route.ts)
4. heartbeat API: [`app/api/autotrade/sessions/heartbeat/route.ts`](../../app/api/autotrade/sessions/heartbeat/route.ts)
5. 세션 만료 정리: [`app/api/autotrade/_shared.ts`](../../app/api/autotrade/_shared.ts#L147)

View File

@@ -0,0 +1,134 @@
[계획 문서 경로]
- common-docs/improvement/plans/dev-plan-2026-02-26-autotrade-ai-mvp.md
[요구사항 요약]
- `features-autotrade-design.md`를 참고해 자동매매 기능을 실제 코드로 추가한다.
- 설계 항목 중 현재 코드베이스에서 바로 구현 가능한 범위와 불필요/보류 범위를 구분한다.
- "구독형 AI + 유명 기법"(OpenAI 기반 + ORB/VWAP/거래량/이평/갭)을 자동매매 시작 흐름에 반영한다.
- OpenAI API 외에도 서버에 설치된 Codex/Gemini CLI를 이용한 구독형 자동판단 경로를 추가한다.
- Windows 개발 환경에서 워커 실행 방법을 문서와 스크립트로 제공한다.
[확인 질문(필요 시 1~3개)]
- 없음(우선 MVP 범위로 구현 후 동작 가능한 형태를 제공)
[가정]
- 서버 DB(Supabase) 스키마를 이번 작업에서 새로 만들지 않고, 세션/로그는 서버 메모리 + 클라이언트 상태로 우선 구현한다.
- OpenAI 키(`OPENAI_API_KEY`)가 없으면 AI 추론은 휴리스틱 폴백(보수적 hold 중심)으로 동작한다.
- 자동매매는 트레이드 화면에서 선택된 종목 기준으로 우선 실행한다(멀티 종목 동시 엔진은 보류).
[추가/제외 판단]
- 즉시 추가:
- 자동매매 설정 팝업(UI): 프롬프트, 유명 기법 복수 선택, 투자금/손실한도(퍼센트+금액), 동의 체크
- 전략 컴파일/검증 API: `compile`, `validate`
- 런타임 세션 API: `start`, `heartbeat`, `stop`, `active`
- 브라우저 엔진 훅: 신호 평가, 리스크 게이트, 주문 실행, heartbeat, 중지 처리
- 실행 중 경고 배너/상태 카드
- 설정 도움말/추천 프리셋(초보/균형/공격) 추가
- 백엔드 워커 tick API + 리눅스 PM2 실행 스크립트/문서 추가
- 자동 세션 수명주기(start->heartbeat->stop) E2E 스크립트 추가
- 이번에 제외(보류):
- Supabase 테이블 5종 + 감사로그 영구 저장
- 온라인 전략 수집/카탈로그 검수 워크플로우 전체
- 멀티탭 리더 선출 lock + BroadcastChannel 완성형
- 4주 배포 계획/운영 대시보드/Sentry 통합
- AI 다중 제공자(OpenAI/Gemini/Claude) 동시 운영
[영향 범위]
- 수정:
- features/trade/components/TradeContainer.tsx
- .env.example
- utils/supabase/middleware.ts
- package.json
- common-docs/features/autotrade-usage-security-guide.md
- common-docs/features/autotrade-worker-pm2.md
- common-docs/improvement/plans/dev-plan-2026-02-26-autotrade-ai-mvp.md
- 추가:
- features/autotrade/types/autotrade.types.ts
- features/autotrade/stores/use-autotrade-engine-store.ts
- features/autotrade/hooks/useAutotradeEngine.ts
- features/autotrade/components/AutotradeControlPanel.tsx
- features/autotrade/components/AutotradeWarningBanner.tsx
- features/autotrade/apis/autotrade.api.ts
- app/api/autotrade/_shared.ts
- app/api/autotrade/strategies/compile/route.ts
- app/api/autotrade/strategies/validate/route.ts
- app/api/autotrade/sessions/start/route.ts
- app/api/autotrade/sessions/heartbeat/route.ts
- app/api/autotrade/sessions/stop/route.ts
- app/api/autotrade/sessions/active/route.ts
- app/api/autotrade/signals/generate/route.ts
- app/api/autotrade/worker/tick/route.ts
- lib/autotrade/risk.ts
- lib/autotrade/strategy.ts
- lib/autotrade/openai.ts
- lib/autotrade/cli-provider.ts
- scripts/autotrade-session-e2e.mjs
- scripts/autotrade-worker.mjs
- scripts/pm2.autotrade-worker.config.cjs
- common-docs/features/autotrade-worker-pm2.md
- 삭제:
- 없음
[구현 단계]
- [x] 1. 자동매매 타입/리스크 계산 유틸/AI-폴백 전략 컴파일 로직 추가
- 근거: `features/autotrade/types/autotrade.types.ts`, `lib/autotrade/risk.ts`, `lib/autotrade/strategy.ts`, `lib/autotrade/openai.ts`
- [x] 2. 자동매매 API 라우트(`compile/validate/start/heartbeat/stop/active/signal`) 구현
- 근거: `app/api/autotrade/**/route.ts`, `app/api/autotrade/_shared.ts`
- [x] 3. 클라이언트 스토어/엔진 훅 구현(상태, heartbeat, 주문 실행, 중지)
- 근거: `features/autotrade/stores/use-autotrade-engine-store.ts`, `features/autotrade/hooks/useAutotradeEngine.ts`, `features/autotrade/apis/autotrade.api.ts`
- [x] 4. 트레이드 화면에 설정 패널/실행 경고 배너 통합
- 근거: `features/autotrade/components/AutotradeControlPanel.tsx`, `features/autotrade/components/AutotradeWarningBanner.tsx`, `features/trade/components/TradeContainer.tsx`
- [x] 5. 문서/환경변수(.env.example) 반영 및 계획 체크 업데이트
- 근거: `.env.example`, 본 계획 문서 갱신
- [x] 6. 설정 팝업 입력값 설명 강화 + 추천 프리셋(초보/균형/공격) 추가
- 근거: `features/autotrade/components/AutotradeControlPanel.tsx`
- [x] 7. 백엔드 워커 tick API 및 PM2 운영 스크립트/문서 추가
- 근거: `app/api/autotrade/worker/tick/route.ts`, `scripts/autotrade-worker.mjs`, `scripts/pm2.autotrade-worker.config.cjs`, `common-docs/features/autotrade-worker-pm2.md`
- [x] 8. 자동매매 세션 수명주기 E2E 스크립트 추가 및 실행
- 근거: `scripts/autotrade-session-e2e.mjs`, `npm run test:autotrade:lifecycle` PASS
- [x] 9. 구독형 CLI 자동판단 모드 추가(codex/gemini CLI)
- 근거: `lib/autotrade/cli-provider.ts`, `app/api/autotrade/strategies/compile/route.ts`, `app/api/autotrade/signals/generate/route.ts`, `features/autotrade/components/AutotradeControlPanel.tsx`
- [x] 10. Windows 개발 워커 실행 경로 추가
- 근거: `package.json(worker:autotrade:dev)`, `common-docs/features/autotrade-worker-pm2.md`, `common-docs/features/autotrade-usage-security-guide.md`
[사용할 MCP/Skills]
- MCP: next-devtools(nextjs_index/nextjs_call), playwright(스모크), shell_command
- Skills: dev-auto-pipeline, dev-plan-writer, dev-mcp-implementation, nextjs-app-router-patterns, vercel-react-best-practices, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker
[참조 문서(common-docs)]
- common-docs/ui/GLOBAL_ALERT_SYSTEM.md
- common-docs/api-reference/kis_api_reference.md (주문 연동 시 기존 패턴 준수)
- common-docs/api-reference/kis-error-code-reference.md (에러 표현 패턴 유지)
- 사용자 지정 기획 입력: common-docs/features-autotrade-design.md
[주석/문서 반영 계획]
- 함수 주석: [목적]/[사용처]/[데이터 흐름] 중심으로 핵심 흐름만 보강
- 상태 주석: 자동매매 상태 변경이 화면에 미치는 영향 위주
- 복잡 로직/핸들러: 신호 생성 -> 리스크 검증 -> 주문 실행 단계 주석
- JSX 구역 주석: 설정 패널/경고 배너/상태 카드 구역 분리
[리스크/회귀 포인트]
- 주문 API 호출 빈도 과다 시 중복 주문 위험
- 브라우저 종료 시 stop beacon 실패 가능성
- AI 출력 포맷 불안정 시 잘못된 신호 처리 위험
- 기존 수동 주문 UX와 충돌(버튼/상태 동시 사용)
[검증 계획]
- [x] 1. `npm run lint` 통과
- 근거: ESLint 에러/경고 정리 후 재실행 통과
- [x] 2. `npm run build` 통과
- 근거: Next.js 16.1.6 프로덕션 빌드 성공, 신규 `/api/autotrade/*` 라우트 포함 확인
- [x] 3. Playwright 스모크: `/trade` 자동매매 설정 패널 오픈 + 도움말/추천 프리셋 입력 반영 확인
- 근거: `자동매매 설정` 모달 오픈, 쉬운 설명 문구 노출, `초보 추천` 클릭 시 수치 자동 반영 확인, 콘솔 error 없음
- [x] 4. start -> heartbeat -> stop 상태 전환 검증
- 근거: `npm run test:autotrade:lifecycle` PASS (`start -> heartbeat -> active -> stop -> active(null)`)
[진행 로그]
- 2026-02-26: 초안 작성. 설계서 기준 MVP 범위(즉시 구현/보류) 확정.
- 2026-02-26: 자동매매 MVP 구현 완료. 타입/유틸/API/스토어/엔진/트레이드 화면 통합 및 `.env.example` 갱신.
- 2026-02-26: 검증 완료(`npm run lint`, `npm run build`, Playwright 스모크). 로그인+KIS 인증 기반 수동 E2E는 남은 확인 항목으로 기록.
- 2026-02-26: 설정값 도움말/추천 프리셋(초보/균형/공격) 추가로 입력 이해도 개선.
- 2026-02-26: 워커 tick API + PM2 운영 스크립트/문서 추가, `worker:autotrade:once` 정상 동작 확인.
- 2026-02-26: 수명주기 자동 검증 스크립트(`test:autotrade:lifecycle`) 통과로 검증계획 4 완료.
- 2026-02-26: 구독형 CLI 자동판단 모드(`subscription_cli`) 추가. OpenAI 미사용 환경에서 gemini/codex CLI 호출 후 JSON 파싱, 실패 시 규칙 기반 폴백하도록 리팩토링.
- 2026-02-26: Windows PowerShell 기준 워커 실행 방법(환경변수 + `worker:autotrade(:dev)`) 문서화.

View File

@@ -0,0 +1,50 @@
# 자동매매 가용자산 0원 차단 보완 계획
[계획 문서 경로]
- common-docs/improvement/plans/dev-plan-2026-02-26-autotrade-cash-balance-fix.md
[요구사항 요약]
- 자동매매 검증에서 `가용 자산 0원`으로 차단되는 문제를 보완한다.
- `내 계좌 기준`으로 매수가능금액을 추가 조회해 검증 금액에 반영한다.
- 기존 리스크 검증/주문 흐름은 유지한다.
[가정]
- KIS 인증/계좌번호는 이미 설정되어 있다.
- selectedStock의 종목코드와 가격 정보는 자동매매 시작 시점에 확보 가능하다.
[영향 범위]
- 수정: features/autotrade/hooks/useAutotradeEngine.ts
- 수정: features/trade/apis/kis-stock.api.ts
- 수정: features/trade/types/trade.types.ts
- 수정: lib/kis/trade.ts
- 추가: app/api/kis/domestic/orderable-cash/route.ts
[구현 단계]
- [x] 1. KIS 매수가능금액 조회 서버 라우트 추가
- [x] 2. 프론트 API 클라이언트/타입 추가
- [x] 3. 자동매매 prepareStrategy에서 cashBalance 0원 보정 로직 추가
- [x] 4. 로그/주석 보강
[사용할 MCP/Skills]
- Skills: dev-auto-pipeline, dev-mcp-implementation, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker
[참조 문서(common-docs)]
- common-docs/api-reference/kis_api_reference.md
- common-docs/api-reference/kis-error-code-reference.md
[리스크/회귀 포인트]
- 매수가능금액 조회 실패 시 기존 cashBalance만 사용하도록 폴백 필요
- 종목가격이 0 또는 비정상일 때 조회 파라미터 보정 필요
[검증 계획]
- [x] 1. lint 통과
- [x] 2. build 통과
- [ ] 3. autotrade smoke 테스트 통과
[진행 로그]
- 2026-02-26: 계획 문서 생성
- 2026-02-26: `/api/kis/domestic/orderable-cash` 라우트 및 `executeInquireOrderableCash` 구현
- 2026-02-26: 자동매매 `prepareStrategy`에서 cashBalance 0원 시 매수가능금액 보정 로직 반영
- 2026-02-26: `npx eslint ...` 통과
- 2026-02-26: `npm run build` 통과
- 2026-02-26: smoke 테스트는 현재 3001 실행 프로세스가 dev bypass를 허용하지 않는 환경으로 로그인 필요 응답 확인(추가 환경 정리 후 재실행 필요)

View File

@@ -0,0 +1,58 @@
# 자동매매 CLI 모델 선택 + AI 입력 데이터 흐름 보강 계획
## [계획 문서 경로]
- `common-docs/improvement/plans/dev-plan-2026-02-26-autotrade-cli-model-selection.md`
## [요구사항 요약]
- 자동매매가 AI 판단 시 어떤 데이터를 전달하는지 쉽게 설명한다.
- Codex/Gemini CLI 모델을 공식 옵션 기준으로 선택 가능하게 만든다.
- 로그/응답에서 실제 사용된 vendor/model을 확인 가능하게 만든다.
## [가정]
- 구독형 CLI는 서버(개발/운영)에 설치되어 있고 로그인/인증이 완료되어 있다.
- 모델 선택은 UI 입력보다 서버 환경변수 방식이 운영상 안전하다.
## [영향 범위]
- 수정: `lib/autotrade/cli-provider.ts`
- 수정: `app/api/autotrade/strategies/compile/route.ts`
- 수정: `app/api/autotrade/signals/generate/route.ts`
- 수정: `features/autotrade/types/autotrade.types.ts`
- 수정: `features/autotrade/hooks/useAutotradeEngine.ts`
- 수정: `.env.example`
- 수정: `common-docs/features/autotrade-usage-security-guide.md`
- 수정: `common-docs/features/autotrade-prompt-flow-guide.md`
## [구현 단계]
- [x] 1. CLI 실행 인자에 vendor별 모델 선택 환경변수를 반영한다. (근거: `lib/autotrade/cli-provider.ts`)
- [x] 2. compile/signal 응답에 `providerModel`을 포함해 추적 가능하게 만든다. (근거: `app/api/autotrade/strategies/compile/route.ts`, `app/api/autotrade/signals/generate/route.ts`)
- [x] 3. 런타임 로그에 vendor/model을 함께 노출한다. (근거: `features/autotrade/hooks/useAutotradeEngine.ts`)
- [x] 4. AI 입력 데이터(시세/전략) 흐름 설명을 문서에 보강한다. (근거: `common-docs/features/autotrade-usage-security-guide.md`, `common-docs/features/autotrade-prompt-flow-guide.md`)
## [사용할 MCP/Skills]
- Skills: `dev-auto-pipeline`, `dev-mcp-implementation`, `dev-refactor-polish`, `dev-test-gate`, `dev-plan-completion-checker`
- MCP: 없음(로컬 코드 수정 + 공식 문서 웹 근거 활용)
## [참조 문서(common-docs)]
- `common-docs/features/trade-stock-sync.md` (참고만, 변경 없음)
- `common-docs/ui/GLOBAL_ALERT_SYSTEM.md` (참고만, 변경 없음)
## [주석/문서 반영 계획]
- 함수 주석: CLI 모델 선택 우선순위와 데이터 흐름 주석 보강
- 상태/로그 주석: vendor/model 로그 의미를 한 줄로 명시
- 흐름 문서: UI -> 훅 -> API -> route -> provider 단계 유지
## [리스크/회귀 포인트]
- Codex CLI 모델명이 환경과 불일치하면 CLI 실패 후 fallback으로 전환될 수 있다.
- 응답 스키마 필드 추가(`providerModel`)가 프론트 타입과 불일치하면 TS 오류가 날 수 있다.
## [검증 계획]
- [x] 1. 변경 파일 eslint 검사 통과 (결과: 코드 파일 오류 없음, md 파일은 lint 대상 아님 경고)
- [x] 2. `npm run build` 통과
- [x] 3. 문서의 환경변수/확인 절차가 실제 로그 포맷과 일치
## [진행 로그]
- 2026-02-26: 계획 문서 생성
- 2026-02-26: CLI 모델 선택 환경변수(`AUTOTRADE_CODEX_MODEL`, `AUTOTRADE_GEMINI_MODEL`, `AUTOTRADE_SUBSCRIPTION_CLI_MODEL`) 반영
- 2026-02-26: provider vendor/model 추적값 응답/로그 반영
- 2026-02-26: AI 입력 데이터(시세 스냅샷/전략 제약) 설명 문서 보강
- 2026-02-26: `npx eslint` + `npm run build` 검증 완료

View File

@@ -0,0 +1,71 @@
# 자동매매 모델 선택 + 대시보드 잔고/매도 UX 개선 계획
## [계획 문서 경로]
- `common-docs/improvement/plans/dev-plan-2026-02-26-autotrade-dashboard-ux-cli-models.md`
## [요구사항 요약]
- AI 판단 입력 데이터(시세 스냅샷)가 무엇인지 쉽게 설명한다.
- 자동매매 UI에서 구독형 CLI vendor/model을 선택할 수 있게 개선한다.
- 대시보드 잔고 표시(총자산/순자산) 혼동을 줄이고, 매도 UX에 매도가능수량 정보를 보강한다.
- 보유종목 잔존(전량 매도 후 표시) 문제를 점검하고 수정한다.
- 자동매매 리스크 요약 문구를 초보자 기준으로 이해 가능하게 바꾼다.
## [가정]
- 구독형 CLI 모델 목록은 "공식 문서 기준 추천 프리셋 + 직접 입력" 방식이 운영 안정성에 유리하다.
- KIS 주식잔고조회 output1의 `ord_psbl_qty`(매도가능수량)를 우선 사용한다.
## [영향 범위]
- 수정: `features/autotrade/types/autotrade.types.ts`
- 수정: `lib/autotrade/strategy.ts`
- 수정: `features/autotrade/apis/autotrade.api.ts`
- 수정: `features/autotrade/hooks/useAutotradeEngine.ts`
- 수정: `features/autotrade/components/AutotradeControlPanel.tsx`
- 수정: `app/api/autotrade/strategies/compile/route.ts`
- 수정: `app/api/autotrade/signals/generate/route.ts`
- 수정: `lib/autotrade/cli-provider.ts`
- 수정: `lib/kis/dashboard.ts`
- 수정: `features/dashboard/types/dashboard.types.ts`
- 수정: `features/dashboard/components/StatusHeader.tsx`
- 수정: `features/dashboard/components/HoldingsList.tsx`
- 수정: `features/trade/components/TradeContainer.tsx`
- 수정: `features/trade/components/order/OrderForm.tsx`
- 수정: `common-docs/features/autotrade-usage-security-guide.md`
- 수정: `common-docs/features/autotrade-prompt-flow-guide.md`
## [구현 단계]
- [x] 1. 자동매매 setup form에 CLI vendor/model 선택 필드를 추가한다.
- [x] 2. compile/signal API 요청에 vendor/model 오버라이드를 전달하고 라우트/CLI provider에서 반영한다.
- [x] 3. 공식 문서 기반 모델 프리셋(코덱스/제미나이) + 직접입력 UX를 패널에 추가한다.
- [x] 4. 대시보드 잔고 파싱에서 수량 0 보유종목 제거/매도가능수량 필드를 반영한다.
- [x] 5. 상단 자산 카드 라벨/표시 순서를 총자산 중심으로 개선한다.
- [x] 6. 주문 패널 매도 탭에서 매도가능수량 기반 가이드/검증을 추가한다.
- [x] 7. 자동매매 리스크 요약 문구를 쉬운 용어로 바꾸고 입력값 대비 계산 근거를 함께 노출한다.
- [x] 8. 문서(사용 가이드/흐름 가이드)에 스냅샷 필드 설명과 모델 선택 기준을 반영한다.
## [사용한 공식 문서]
- OpenAI Models: <https://platform.openai.com/docs/models>
- OpenAI Codex CLI: <https://developers.openai.com/codex/cli>
- Gemini CLI model selection: <https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/model.md>
- Gemini CLI model routing precedence: <https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/model-routing.md>
- KIS 매도가능수량조회 경로 참고: `common-docs/api-reference/kis_api_reference.md`, `.tmp/open-trading-api/examples_llm/domestic_stock/inquire_psbl_sell/inquire_psbl_sell.py`
## [리스크/회귀 포인트]
- UI 필드 증가로 기존 자동매매 설정 저장/반영 흐름이 깨질 수 있음.
- 모델명을 강제로 지정했을 때 vendor와 호환되지 않으면 CLI 실패 후 fallback으로 전환될 수 있음.
- 보유종목 필터링 조건이 과도하면 실제 보유 종목이 누락될 수 있음.
## [검증 계획]
- [x] 1. 변경 파일 eslint 통과
- [x] 2. `npm run build` 통과
- [x] 3. 대시보드에서 수량 0 종목 미노출 로직 반영 확인 (`lib/kis/dashboard.ts` 수량 0 필터)
- [x] 4. 매도 탭에서 매도가능수량 초과 입력 차단 로직 반영 확인 (`OrderForm.tsx`)
- [x] 5. 자동매매 로그에 vendor/model 노출 유지 확인 (`useAutotradeEngine.ts`)
## [진행 로그]
- 2026-02-26: 계획 문서 생성
- 2026-02-26: 자동매매 설정창에 구독형 CLI vendor/model 선택 UI 추가
- 2026-02-26: compile/signal route와 CLI provider에 vendor/model override 반영
- 2026-02-26: 대시보드 잔고 파싱에 `ord_psbl_qty` 반영, 수량 0 종목 필터링 적용
- 2026-02-26: StatusHeader 총자산 중심 표기 개편, 매도 UX(매도가능수량 표시/검증) 개선
- 2026-02-26: 리스크 요약 문구를 쉬운 용어로 교체, 스냅샷/모델선택 문서 보강
- 2026-02-26: `npx eslint``npm run build` 통과

View File

@@ -0,0 +1,55 @@
[계획 문서 경로]
- common-docs/improvement/plans/dev-plan-2026-02-26-market-indices-display.md
[요구사항 요약]
- 메인 레이아웃의 헤더에 KOSPI 및 KOSDAQ 지수를 표시한다.
- 지수에는 현재가, 전일 대비 등락, 등락률이 포함되어야 한다.
- 데이터는 30초마다 자동으로 새로고침되어야 한다.
[가정]
- 사용자는 로그인 상태이며 KIS API 키가 설정되어 있다고 가정한다.
- `lib/kis/dashboard.ts``getDomesticDashboardIndices` 함수가 정상 동작한다고 가정한다.
[영향 범위]
- 수정:
- `features/layout/components/header.tsx`: `MarketIndices` 컴포넌트를 추가하고 레이아웃을 조정.
- 추가:
- `app/api/kis/indices/route.ts`: KIS 지수 데이터를 조회하는 새로운 API 라우트.
- `features/layout/stores/market-indices-store.ts`: 지수 데이터 상태 관리를 위한 Zustand 스토어.
- `features/layout/hooks/use-market-indices.ts`: 지수 데이터를 가져오는 커스텀 훅.
- `features/layout/components/market-indices.tsx`: 지수 정보를 표시하는 UI 컴포넌트.
- 삭제:
- 없음
[구현 단계]
- [x] 1. KIS 지수 API 라우트 생성 (`app/api/kis/indices/route.ts`): `getDomesticDashboardIndices` 함수를 사용하여 KOSPI, KOSDAQ 지수 정보를 반환하는 GET 엔드포인트를 구현.
- [x] 2. 상태 관리 스토어 생성 (`features/layout/stores/market-indices-store.ts`): 지수 데이터, 로딩 상태, 에러 상태를 관리하기 위한 Zustand 스토어를 생성.
- [x] 3. 커스텀 훅 생성 (`features/layout/hooks/use-market-indices.ts`): 위에서 만든 API 라우트를 호출하고, 스토어의 상태를 업데이트하는 `useMarketIndices` 훅을 구현.
- [x] 4. UI 컴포넌트 생성 (`features/layout/components/market-indices.tsx`): `useMarketIndices` 훅을 사용하여 지수 정보를 받아와 화면에 표시하는 컴포넌트를 생성. 30초마다 데이터를 폴링하는 로직을 포함.
- [x] 5. 헤더에 컴포넌트 추가 (`features/layout/components/header.tsx`): 생성된 `MarketIndices` 컴포넌트를 헤더 중앙에 추가하고, 로그인 및 `blendWithBackground` 상태에 따라 노출 여부를 제어.
[사용할 MCP/Skills]
- MCP: 없음
- Skills: `dev-plan-writer`, `dev-mcp-implementation`, `dev-refactor-polish`, `dev-test-gate`, `dev-plan-completion-checker`
[참조 문서(common-docs)]
- `common-docs/api-reference/kis_api_reference.md`
[주석/문서 반영 계획]
- 각 파일 상단에 파일의 목적과 역할을 설명하는 JSDoc 주석을 추가.
- 주요 함수에 파라미터와 반환 값, 역할을 설명하는 주석을 추가.
[리스크/회귀 포인트]
- KIS API 호출 실패 시 에러 처리가 적절히 이루어지지 않으면 UI가 깨지거나 오류 메시지가 표시되지 않을 수 있다.
- 자동 새로고침 로직이 메모리 누수를 일으키지 않도록 `useEffect`의 cleanup 함수를 정확히 구현해야 한다.
[검증 계획]
- [ ] 1. **API 라우트 검증**: 브라우저나 API 테스트 도구로 `/api/kis/indices`를 직접 호출하여 정상적인 JSON 응답(지수 데이터, fetchedAt)이 오는지 확인.
- [ ] 2. **UI 초기 로딩 검증**: 페이지 로드 시 `MarketIndices` 컴포넌트 영역에 스켈레톤 UI가 먼저 표시되는지 확인.
- [ ] 3. **UI 데이터 표시 검증**: 데이터 로딩 완료 후 KOSPI, KOSDAQ 지수 정보(현재가, 등락, 등락률)가 헤더에 정상적으로 표시되는지 확인. 등락에 따라 색상(빨강/파랑)이 올바르게 적용되는지 확인.
- [ ] 4. **UI 자동 새로고침 검증**: 약 30초가 지난 후 `fetchedAt` 시간이 갱신되며 데이터가 새로고침되는지 네트워크 탭과 화면 표시를 통해 확인.
- [ ] 5. **로그아웃/비로그인 상태 검증**: 로그아웃하거나 비로그인 상태로 접속했을 때, 지수 컴포넌트가 헤더에 표시되지 않는지 확인.
- [ ] 6. **홈 랜딩 페이지 검증**: `blendWithBackground` prop이 `true`로 설정된 홈 랜딩 페이지에서 지수 컴포넌트가 표시되지 않는지 확인.
[진행 로그]
- 2026-02-26: 계획 문서 작성 및 기능 구현 완료.

View File

@@ -0,0 +1,81 @@
[계획 문서 경로]
- common-docs/improvement/plans/dev-plan-2026-03-04-dashboard-market-hub-and-orderbook-rate.md
[요구사항 요약]
- 호가창 각 호가 행에 기준가 대비 퍼센트(등락률)를 추가 표시한다.
- /dashboard 안에서 내 종목/내 재산/주문내역 같은 개인 자산 정보를 별도 메뉴(탭)로 분리한다.
- /dashboard 메인 화면에는 급등주식, 인기종목, 주요 뉴스와 추가 시장 정보 카드를 배치한다.
[가정]
- "메뉴를 하나 새로"는 /dashboard 내부 탭 메뉴(시장 탭/내 자산 탭) 추가로 해석한다.
- 기존 KIS 인증/세션 헤더 체계는 유지하고, 신규 데이터도 동일 헤더로 조회한다.
- 인기종목은 거래량 기준 상위(필요 시 거래대금 기준 포함)로 제공한다.
[영향 범위]
- 수정:
- features/trade/components/orderbook/orderbook-utils.ts
- features/trade/components/orderbook/orderbook-sections.tsx
- features/dashboard/types/dashboard.types.ts
- features/dashboard/apis/dashboard.api.ts
- features/dashboard/hooks/use-dashboard-data.ts
- features/dashboard/components/DashboardContainer.tsx
- lib/kis/dashboard.ts
- 추가:
- app/api/kis/domestic/market-hub/route.ts
- features/dashboard/components/MarketHubSection.tsx
- 삭제:
- features/dashboard/hooks/use-market-movers-alert.ts
[구현 단계]
- [x] 1. 호가창 퍼센트 표시 로직 추가: 기준가 대비 등락률 계산 유틸을 만들고 호가 행 UI에 퍼센트를 노출한다. (`features/trade/components/orderbook/orderbook-utils.ts`, `features/trade/components/orderbook/orderbook-sections.tsx`)
- [x] 2. 대시보드 시장 허브 API 추가: 급등주식/거래량 상위/뉴스(및 보조 지표)를 KIS에서 조회해 단일 응답으로 반환한다. (`lib/kis/dashboard.ts`, `app/api/kis/domestic/market-hub/route.ts`)
- [x] 3. 대시보드 데이터 훅 확장: 기존 balance/indices/activity에 market-hub 데이터를 병렬 조회하고 에러 상태를 분리 관리한다. (`features/dashboard/hooks/use-dashboard-data.ts`, `features/dashboard/apis/dashboard.api.ts`, `features/dashboard/types/dashboard.types.ts`)
- [x] 4. /dashboard 메뉴 분리: "시장" 탭과 "내 자산" 탭을 만들고 개인 자산 컴포넌트를 "내 자산" 탭으로 이동한다. (`features/dashboard/components/DashboardContainer.tsx`)
- [x] 5. 시장 탭 구성: 급등주식, 인기종목, 주요 뉴스, 추가 정보(시장 폭/업다운 카운트)를 카드로 구성한다. (`features/dashboard/components/MarketHubSection.tsx`)
[사용할 MCP/Skills]
- MCP: next-devtools(런타임 점검), web search(요구사항의 검색 반영)
- Skills: dev-auto-pipeline, dev-plan-writer, dev-mcp-implementation, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker, nextjs-app-router-patterns, vercel-react-best-practices
[참조 문서(common-docs)]
- common-docs/api-reference/openapi_all.xlsx
- common-docs/api-reference/kis_api_reference.md
- common-docs/api-reference/kis-error-code-reference.md
- common-docs/features/trade-stock-sync.md
- common-docs/ui/GLOBAL_ALERT_SYSTEM.md
[주석/문서 반영 계획]
- 함수 주석: [목적]/[사용처]/[데이터 흐름] 중심으로 유지한다.
- 상태 주석: market-hub 로딩/오류 상태가 화면에 미치는 영향 한 줄 주석을 추가한다.
- 복잡 로직: 시장 허브 응답 정규화는 1,2,3 단계 주석으로 분해한다.
[리스크/회귀 포인트]
- KIS 순위/뉴스 API는 파라미터 조합에 따라 빈 응답이 나올 수 있어 폴백 파라미터가 필요하다.
- 신규 시장 API 실패 시에도 기존 내 자산 탭은 정상 동작해야 한다.
- 호가 퍼센트 표시가 모바일에서 줄바꿈/폭 깨짐을 유발할 수 있어 반응형 폭 점검이 필요하다.
[검증 계획]
- [x] 1. lint: 타입/린트 오류 없이 통과하는지 확인. (`npm run lint` 통과)
- [x] 2. build: Next.js 프로덕션 빌드가 통과하는지 확인. (`npm run build` 통과)
- [x] 3. 런타임: /dashboard 진입 후 시장 탭/내 자산 탭 전환이 정상 동작하는지 확인. (Playwright MCP에서 탭 전환 및 화면 반영 확인)
- [x] 4. 런타임: 시장 탭에서 급등/인기/뉴스 카드가 실패 시에도 개별 에러 안내로 안전하게 렌더링되는지 확인. (Playwright MCP route abort로 `/api/kis/domestic/market-hub` 실패 주입 후 `Failed to fetch` + 빈 카드 안전 렌더링 확인)
- [x] 5. 런타임: /trade 호가창에서 각 가격 행에 퍼센트가 표시되는지 확인. (Playwright MCP로 `/dashboard` 종목 클릭 이동 후 일반호가 행 `±x.xx%` 표기 확인)
[진행 로그]
- 2026-03-04: 계획 문서 작성.
- 2026-03-04: 구현 1~5 완료, `npm run lint`/`npm run build` 통과.
- 2026-03-04: 브라우저 스모크 실행 시 `/dashboard`, `/trade`, `/settings`가 비로그인 상태에서 `/login`으로 리다이렉트되는 동작 확인.
- 2026-03-04: 급등주 미노출 대응(등락률 API 파라미터 폴백 + 거래량 기반 폴백) 적용.
- 2026-03-04: 급락주 데이터 및 급등/급락 주기 알림(60초 갱신 + 3분 쿨다운 모달) 추가.
- 2026-03-04: 요청 반영으로 급등/급락 전역 모달 알림 훅 제거.
- 2026-03-04: KIS 문서/코드 기준 급등·급락 웹소켓 수신 가능성 검토 완료(순위는 REST, WS는 종목 체결/호가 중심).
- 2026-03-04: Playwright MCP로 `/dashboard` 시장/내 자산 탭 전환 정상 동작 재검증 완료.
- 2026-03-04: Playwright MCP route abort 주입으로 시장 허브 API 실패 시 에러 안내/빈 상태 카드 안전 렌더링 확인.
- 2026-03-04: Playwright MCP로 급등/급락/인기/거래대금 카드 종목 클릭 시 `/trade` 이동 및 선택 종목 반영 확인.
- 2026-03-04: Playwright MCP로 `/trade` 일반호가 각 가격 행의 퍼센트(등락률) 표기 확인.
[계획 대비 완료체크]
- 완료: 구현 1~5, 검증 1~5
- 부분 완료: 없음
- 미완료: 없음
- 최종 판정: 배포 가능

View File

@@ -0,0 +1,50 @@
[계획 문서 경로]
- common-docs/improvement/plans/dev-plan-2026-03-04-dashboard-modern-brand-layout-refresh.md
[요구사항 요약]
- /dashboard를 모던하고 현대적인 느낌으로 재배치한다.
- 브랜드 컬러(brand 토큰)를 적극 활용해 UI 일관성을 높인다.
- 핵심 정보를 한눈에 확인할 수 있도록 정보 우선순위를 재정렬한다.
[가정]
- 기존 정보 구조(시장 탭/내 자산 탭, API/데이터 모델)는 유지하고 UI/레이아웃 중심으로 개선한다.
- 기존 브랜드 토큰(--brand-*)을 재사용해 전체 앱 디자인 언어와 일관성을 맞춘다.
[영향 범위]
- 수정:
- features/dashboard/components/DashboardContainer.tsx
- features/dashboard/components/StatusHeader.tsx
- features/dashboard/components/MarketSummary.tsx
- features/dashboard/components/MarketHubSection.tsx
- features/dashboard/components/HoldingsList.tsx
- features/dashboard/components/StockDetailPreview.tsx
- features/dashboard/components/ActivitySection.tsx
[구현 단계]
- [x] 1. 대시보드 컨테이너 재배치: 상단 브랜드 히어로/상태 칩 추가, 탭 인터랙션 스타일 강화, 탭별 레이아웃 재정렬.
- [x] 2. 자산 헤더 리디자인: 총자산/손익 중심 카드 + 연결 상태/액션 패널 + 핵심 지표 4분할 구성.
- [x] 3. 시장 지수 카드 리디자인: 실시간 상태 배지, 지수 카드 시각 톤 강화, 카드 대비 개선.
- [x] 4. 시장 허브 리디자인: 급등/급락/인기/거래대금 2x2 구성 및 뉴스 가독성 개선.
- [x] 5. 자산 하위 카드 톤 정렬: 보유종목/선택종목/활동내역 카드 스타일 일관화 및 탭 버튼 강조.
- [x] 6. 사후 버그 수정: 시장 지수 배지의 실시간 상태 판정을 상단 상태칩과 동일 기준으로 통일.
- [x] 7. 사후 버그 수정: Next.js `scroll-behavior` 경고 제거를 위한 루트 html 속성 보완.
[리스크/회귀 포인트]
- 카드 높이/스크롤 높이 조정으로 모바일에서 콘텐츠 길이 체감이 달라질 수 있다.
- 탭 스타일 커스터마이징이 다크 모드 대비에 영향을 줄 수 있다.
[검증 계획]
- [x] 1. lint: `npm run lint` 통과.
- [x] 2. build: `npm run build` 통과.
- [x] 3. 런타임: 로그인 상태에서 /dashboard 시각적 배치/반응형 확인. (Playwright로 데스크톱/모바일, 탭 전환, 메인 왕복 동선 확인)
- [x] 4. 런타임: 브라우저 콘솔 경고/오류 확인. (`warning`/`error` 비어있음)
- [x] 5. 런타임: API 네트워크 응답 확인. (`/api/kis/domestic/indices`, `/api/kis/domestic/market-hub`, `/api/kis/ws/approval` 모두 200)
[진행 로그]
- 2026-03-04: 대시보드 모던 UI 재배치 구현 완료.
- 2026-03-04: `npm run lint` 통과.
- 2026-03-04: `npm run build` 통과.
- 2026-03-04: Playwright로 `/dashboard` 접근 시 `/login` 리다이렉트 동작 및 모바일 뷰포트(390x844) 렌더링 확인.
- 2026-03-04: 시장 지수 배지 상태 문구 불일치(실시간 미연결 vs 수신중) 수정.
- 2026-03-04: `app/layout.tsx``data-scroll-behavior=\"smooth\"` 추가로 Next 경고 제거.
- 2026-03-04: Playwright 재검증(데스크톱/모바일, 로고->메인, 메인->대시보드, 자산 탭 전환) 완료.

View File

@@ -0,0 +1,78 @@
[계획 문서 경로]
- common-docs/improvement/plans/dev-plan-2026-03-05-autotrade-ai-context-layout-boxrange.md
[요구사항 요약]
- 자동매매 신호 생성 시 AI 판단 입력 데이터를 늘린다.
- 자동매매 설정창 높이 문제를 해결하고 레이아웃을 더 간결하게 정리한다.
- 유명기법을 선택하지 않아도 자동매매가 동작하도록 기본 동작을 완화한다.
- "당일 상승 후 박스권 횡보 단타" 기법을 새로 추가한다.
[가정]
- "유명기법 미선택 허용"은 시작 자체 허용 + 서버에서 기본 기법 자동 적용으로 해석한다.
- 박스권 단타 기법은 fallback 엔진(규칙 기반)에서 즉시 동작하도록 우선 구현한다.
- AI/CLI 모드에도 동일한 추가 스냅샷 데이터를 전달해 판단 품질을 함께 높인다.
[영향 범위]
- 수정:
- features/autotrade/types/autotrade.types.ts
- features/autotrade/components/AutotradeControlPanel.tsx
- features/autotrade/hooks/useAutotradeEngine.ts
- features/autotrade/apis/autotrade.api.ts
- lib/autotrade/strategy.ts
- lib/autotrade/openai.ts
- app/api/autotrade/strategies/compile/route.ts
- app/api/autotrade/signals/generate/route.ts
- 추가:
- 없음
- 삭제:
- 없음
[구현 단계]
- [x] 1. 타입/스키마 확장: 자동매매 스냅샷에 체결/호가/파생 지표 필드를 추가하고 클라이언트/서버 타입을 동기화했다. (`features/autotrade/types/autotrade.types.ts`, `features/autotrade/apis/autotrade.api.ts`, `app/api/autotrade/signals/generate/route.ts`)
- [x] 2. AI 입력 데이터 확장: `useAutotradeEngine`에서 추가 지표를 계산해 signal API로 전달하고, OpenAI 프롬프트 안내 문구를 업데이트했다. (`features/autotrade/hooks/useAutotradeEngine.ts`, `lib/autotrade/openai.ts`, `features/autotrade/components/AutotradeControlPanel.tsx`)
- [x] 3. 유명기법 미선택 허용: 시작 버튼 조건/사전 검증 제한을 완화하고, compile 라우트에서 기본 기법 자동 적용을 넣었다. (`features/autotrade/components/AutotradeControlPanel.tsx`, `features/autotrade/hooks/useAutotradeEngine.ts`, `app/api/autotrade/strategies/compile/route.ts`)
- [x] 4. 박스권 단타 기법 추가: 기법 목록에 항목을 추가하고 fallback 신호 로직에 박스권 왕복 단타 판단을 구현했다. (`features/autotrade/types/autotrade.types.ts`, `lib/autotrade/strategy.ts`)
- [x] 5. 자동매매창 레이아웃 개선: 모달 높이 잘림을 없애고(내부 스크롤), 섹션 구조를 간결화했다. (`features/autotrade/components/AutotradeControlPanel.tsx`)
[사용할 MCP/Skills]
- MCP: shell_command, apply_patch, playwright
- Skills: dev-auto-pipeline, dev-plan-writer, dev-mcp-implementation, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker, nextjs-app-router-patterns, vercel-react-best-practices
[참조 문서(common-docs)]
- common-docs/api-reference/openapi_all.xlsx
- common-docs/api-reference/kis_api_reference.md
- common-docs/api-reference/kis-error-code-reference.md
- common-docs/features/trade-stock-sync.md
- common-docs/ui/GLOBAL_ALERT_SYSTEM.md
[주석/문서 반영 계획]
- 데이터 흐름 주석: "입력 데이터 확장" 구간에 [Step 1]/[Step 2]를 추가한다.
- UI 주석: 모달 섹션을 상단 요약/설정 본문/하단 액션으로 분리해 가독성을 유지한다.
- 박스권 기법 주석: 조건(상승폭, 박스 범위, 상하단 근접)과 신호 방향을 한글로 명확히 남긴다.
[리스크/회귀 포인트]
- 스냅샷 필드 확장 시 signal 라우트 zod 스키마 불일치가 발생할 수 있다.
- 유명기법 미선택 허용 이후에도 과도한 신호가 나오지 않게 fallback 신호 품질을 확인해야 한다.
- 설정 모달 레이아웃 변경 시 모바일에서 버튼 접근/스크롤 충돌이 생길 수 있다.
[검증 계획]
- [x] 1. lint: 타입/린트 오류 없이 통과 (`npm run lint` 통과)
- [x] 2. build: 프로덕션 빌드 통과 (`npm run build` 통과)
- [x] 3. 동작: 기법 미선택 허용 코드 경로 확인 (`canStartAutotrade` 조건 완화, `prepareStrategy` 필수 체크 제거, compile 기본 기법 자동 적용)
- [x] 4. 동작: 박스권 단타 기법이 목록/enum/fallback 로직에 반영됨을 코드 경로 확인
- [x] 5. 동작: 설정 화면 스모크에서 신규 체크박스/설정 UI 접근 및 콘솔 치명 오류 없음 확인 (Playwright). 자동매매 설정 모달은 KIS 미연결 환경으로 직접 실행 검증은 제한
[진행 로그]
- 2026-03-05: 계획 문서 작성.
- 2026-03-05: 자동매매 스냅샷 확장(체결/호가/파생 지표) 및 signal API 스키마 동기화 완료.
- 2026-03-05: 유명기법 미선택 허용(기본 기법 자동 적용) 반영 완료.
- 2026-03-05: "상승 후 박스권 단타" 기법 추가 및 fallback 신호 로직 구현 완료.
- 2026-03-05: 자동매매 설정 모달 레이아웃 간소화/높이 잘림 개선(내부 스크롤) 적용.
- 2026-03-05: `npm run lint`, `npm run build` 통과.
- 2026-03-05: Playwright 스모크(`/trade`, `/dashboard`, `/settings`) 확인, 콘솔 치명 오류 없음.
[계획 대비 완료체크]
- 완료: 구현 1~5, 검증 1~5
- 부분 완료: 없음
- 미완료: 없음
- 최종 판정: 배포 가능

View File

@@ -0,0 +1,87 @@
[계획 문서 경로]
- common-docs/improvement/plans/dev-plan-2026-03-05-autotrade-observability-momentum-scalp.md
[요구사항 요약]
- 자동매매 로그에 AI 진행상태/응답 근거/주요 수치가 보이도록 개선한다.
- 자동매매에서 AI로 보내는 데이터 항목을 코드 기준으로 명확히 보여준다(로그/설명 근거 강화).
- 1분봉 상승구간 단타(눌림-재돌파) 기법을 새로 추가한다.
[가정]
- "좋은 데이터 로그"는 사용자 화면에서 즉시 확인 가능한 런타임 로그 품질 개선(단계, 공급자, 핵심 수치, AI 근거)으로 해석한다.
- AI 전송 데이터 "상세" 요구는 코드 반영(진단 로그) + 최종 보고에서 필드 목록/흐름 설명으로 충족한다.
- 신규 전략은 규칙 기반 fallback에서 즉시 동작하고, OpenAI/CLI 프롬프트 가이드에도 동일 기법명을 반영한다.
[영향 범위]
- 수정:
- features/autotrade/types/autotrade.types.ts
- features/autotrade/stores/use-autotrade-engine-store.ts
- features/autotrade/hooks/useAutotradeEngine.ts
- features/autotrade/components/AutotradeControlPanel.tsx
- lib/autotrade/strategy.ts
- lib/autotrade/openai.ts
- app/api/autotrade/signals/generate/route.ts (필요 시)
- common-docs/improvement/plans/dev-plan-2026-03-05-autotrade-observability-momentum-scalp.md
- 추가:
- 없음
- 삭제:
- 없음
[구현 단계]
- [x] 1. 로그 타입 확장: 런타임 로그에 단계(stage)와 상세 데이터(detail)를 담을 수 있게 타입/스토어를 확장한다. (`features/autotrade/types/autotrade.types.ts`, `features/autotrade/stores/use-autotrade-engine-store.ts`)
- [x] 2. 엔진 로그 강화: compile/signal/risk/order 흐름에서 "요청 전송", "AI 응답", "주문 차단/실행"을 구조화 로그로 남긴다. (`features/autotrade/hooks/useAutotradeEngine.ts`)
- [x] 3. 로그 UI 개선: 상단 최근 로그 영역에서 단계/레벨/상세 데이터를 읽기 쉽게 표시한다. (`features/autotrade/components/AutotradeControlPanel.tsx`)
- [x] 4. 상승구간 단타 기법 추가: 기법 enum/옵션 추가 + fallback 로직(추세 필터, 눌림 구간, 재돌파, 거래량 확인) 구현. (`features/autotrade/types/autotrade.types.ts`, `lib/autotrade/strategy.ts`)
- [x] 5. OpenAI 가이드 반영: 신규 기법 설명과 판단 제약을 프롬프트에 반영한다. (`lib/autotrade/openai.ts`)
[사용할 MCP/Skills]
- MCP: shell_command, apply_patch, tavily-remote, playwright
- Skills: dev-auto-pipeline, dev-plan-writer, dev-mcp-implementation, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker, vercel-react-best-practices
[참조 문서(common-docs)]
- common-docs/api-reference/openapi_all.xlsx
- common-docs/api-reference/kis_api_reference.md
- common-docs/api-reference/kis-error-code-reference.md
- common-docs/features/trade-stock-sync.md
- common-docs/ui/GLOBAL_ALERT_SYSTEM.md
[외부 근거(전략 설계)]
- Investopedia Flag Pattern: 상승 추세 + 조정 중 거래량 축소 + 돌파 시 거래량 확인
- Investopedia Low Volume Pullback: 저거래량 눌림 후 추세 재개 확률
- Fidelity Technical Analysis(학습 PDF): 이동평균 기반 추세/눌림 해석, 거래량/모멘텀 보조 확인
[주석/문서 반영 계획]
- 함수 주석: [목적]/[데이터 흐름] 유지
- 상태 주석: 로그 stage/detail 도입 영향 표시
- 복잡 로직: 신규 상승구간 단타 판단 함수를 [Step 1~3] 주석으로 분리
- JSX 구역 주석: 로그 카드 영역을 단계/상세 구분 렌더링으로 분리
[리스크/회귀 포인트]
- 로그 데이터가 과도하면 UI 가독성이 저하될 수 있어 길이 제한/요약이 필요하다.
- 신규 전략이 기존 박스권 단타와 동시에 점수를 높여 과매수 신호가 늘 수 있어 임계값을 보수적으로 둔다.
- 타입 확장 시 기존 appendLog 호출과의 호환성을 유지해야 한다.
[검증 계획]
- [x] 1. lint: 타입/린트 오류 없는지 확인 (`npm run lint` 통과)
- [x] 2. build: Next 빌드 통과 확인 (`npm run build` 통과)
- [x] 3. 동작: 자동매매 로그에 stage/detail이 표시되는지 코드 경로/UI 확인 (`useAutotradeEngine` 로그 작성 + `AutotradeControlPanel` 렌더 반영)
- [x] 4. 동작: 신규 기법이 목록과 fallback 로직에 반영됐는지 확인 (`intraday_breakout_scalp` enum/옵션/룰 추가)
- [x] 5. 동작: /trade 화면 스모크에서 콘솔 오류 없이 렌더링 확인 (Playwright). 비로그인 환경으로 `/login` 리다이렉트 확인, 콘솔 error 없음
[진행 로그]
- 2026-03-05: 계획 문서 작성.
- 2026-03-05: 런타임 로그 구조(stage/detail) 확장 및 로그 UI 상세표시 반영.
- 2026-03-05: 로그 UI를 기본 접힘(차트 가림 최소화) + 쉬운 문장 요약 + 개발자 상세 토글 + 라이브 커서 표시로 개선.
- 2026-03-05: AI 신호 요청/응답/리스크게이트/주문실행 흐름 구조화 로그 반영.
- 2026-03-05: 상승구간 눌림-재돌파 단타(`intraday_breakout_scalp`) 기법 추가.
- 2026-03-05: AI 신호 사유 한글 강제(프롬프트 + 서버 후처리) 반영.
- 2026-03-05: 상단 예산 카드에 검증 전 입력 기준 예산 표시 추가.
- 2026-03-05: 신호 API 호출을 in-flight 순차 처리로 변경(이전 응답 완료 전 재호출 차단).
- 2026-03-05: 상단 로그를 `입력 -> 답변` 1쌍 고정 표시로 개선(응답 대기 상태 포함).
- 2026-03-05: `npm run lint`, `npm run build` 통과.
- 2026-03-05: Playwright 스모크(`/trade`, `/dashboard`, `/settings`) 실행, 비로그인 리다이렉트 경로에서 콘솔 error 없음.
[계획 대비 완료체크]
- 완료: 구현 1~5, 검증 1~5
- 부분 완료: 없음
- 미완료: 없음
- 최종 판정: 배포 가능

View File

@@ -0,0 +1,63 @@
[계획 문서 경로]
- common-docs/improvement/plans/dev-plan-2026-03-05-autotrade-risk-input-simplify.md
[요구사항 요약]
- 자동매매 투자금/손실 설정의 기존 복잡한 계산 로직(작은 값 선택)을 제거한다.
- 사용자가 입력한 투자금 금액/손실 금액이 실제 거래 기준으로 직접 반영되게 한다.
- 퍼센트 입력은 유지하되, 이해하기 쉬운 기준(경고/참고)으로 단순화한다.
[가정]
- "로직 없애고"는 `min(퍼센트 계산값, 금액)` 기반 자동 축소 로직 제거로 해석한다.
- 실제 주문 예산은 `투자금 금액(allocationAmount)` 그대로 사용한다.
- 자동중지 손실선은 `손실 금액(dailyLossAmount)` 그대로 사용한다.
- 퍼센트 입력값은 유지하고, 금액과 충돌 시 차단 대신 경고로 안내한다.
[영향 범위]
- 수정:
- lib/autotrade/risk.ts
- features/autotrade/components/AutotradeControlPanel.tsx
- common-docs/improvement/plans/dev-plan-2026-03-05-autotrade-risk-input-simplify.md
- 추가:
- 없음
- 삭제:
- 없음
[구현 단계]
- [x] 1. 리스크 계산식 단순화: 실적용 투자금/손실한도를 입력 금액 그대로 쓰도록 변경했다. (`lib/autotrade/risk.ts`)
- [x] 2. 퍼센트 해석 단순화: 퍼센트는 참고 기준 경고로만 반영했다. (`lib/autotrade/risk.ts`, `app/api/autotrade/strategies/validate/route.ts`)
- [x] 3. UI 문구 정리: "중 작은 값" 설명을 제거하고 "입력값 직접 적용"으로 변경했다. (`features/autotrade/components/AutotradeControlPanel.tsx`)
- [x] 4. 리스크 요약 카드 문구를 새 계산식에 맞게 정리했다. (`features/autotrade/components/AutotradeControlPanel.tsx`)
[사용할 MCP/Skills]
- MCP: shell_command, apply_patch
- Skills: dev-auto-pipeline, dev-plan-writer, dev-mcp-implementation, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker
[참조 문서(common-docs)]
- common-docs/api-reference/openapi_all.xlsx
- common-docs/api-reference/kis_api_reference.md
- common-docs/api-reference/kis-error-code-reference.md
- common-docs/features/trade-stock-sync.md
- common-docs/ui/GLOBAL_ALERT_SYSTEM.md
[리스크/회귀 포인트]
- 기존보다 공격적으로 주문이 나갈 수 있어(자동 축소 제거) 금액 입력 검증이 중요하다.
- 퍼센트 필드가 무의미하게 보이지 않도록 경고 기준 문구를 명확히 해야 한다.
[검증 계획]
- [x] 1. lint 통과 (`npm run lint` 통과)
- [x] 2. build 통과 (`npm run build` 통과)
- [x] 3. 코드 경로 확인: 주문 수량 계산에 쓰이는 `effectiveAllocationAmount`가 입력 금액 기준으로 세팅됨 확인 (`lib/autotrade/risk.ts` -> `features/autotrade/hooks/useAutotradeEngine.ts` -> `resolveOrderQuantity`)
- [x] 4. UI 문구 확인: "작은 값" 문구 제거 확인 (`features/autotrade/components/AutotradeControlPanel.tsx`)
[진행 로그]
- 2026-03-05: 계획 문서 작성.
- 2026-03-05: 투자금/손실 계산 로직을 입력 금액 직접 적용 방식으로 단순화.
- 2026-03-05: 퍼센트 필드를 참고 경고용으로 전환하고 검증 스키마를 nonnegative로 완화.
- 2026-03-05: 자동매매 설정/리스크 요약 문구를 새 계산식 기준으로 업데이트.
- 2026-03-05: `npm run lint`, `npm run build` 통과.
[계획 대비 완료체크]
- 완료: 구현 1~4, 검증 1~4
- 부분 완료: 없음
- 미완료: 없음
- 최종 판정: 배포 가능

View File

@@ -0,0 +1,69 @@
[계획 문서 경로]
- common-docs/improvement/plans/dev-plan-2026-03-05-kis-remember-credentials-checkbox.md
[요구사항 요약]
- 설정 화면에서 앱토큰(앱키), 앱시크릿키, 계좌번호에 대해 "기억하기" 체크박스를 제공한다.
- 체크한 항목만 브라우저 재시작 후에도 복원되도록 로컬 저장을 추가한다.
- 기존 KIS 검증/계좌인증 동작은 그대로 유지한다.
[가정]
- 사용자 요청의 "앱토큰"은 현재 화면 필드명 기준 "앱키(appKey)"로 해석한다.
- "기억하기"는 장기 저장(localStorage), 미체크는 저장하지 않음으로 해석한다.
- 기존 세션값이 있으면(이미 입력/검증된 상태) 기억값 자동 복원으로 덮어쓰지 않는다.
[영향 범위]
- 수정:
- features/settings/components/KisAuthForm.tsx
- features/settings/components/KisProfileForm.tsx
- features/layout/components/user-menu.tsx
- features/auth/components/session-manager.tsx
- 추가:
- features/settings/lib/kis-remember-storage.ts
- 삭제:
- 없음
[구현 단계]
- [x] 1. 기억하기 저장 유틸 추가: 앱키/앱시크릿/계좌별 체크 상태/값을 localStorage로 읽기/쓰기/삭제하는 공통 함수를 만들었다. (`features/settings/lib/kis-remember-storage.ts`)
- [x] 2. 앱키/앱시크릿 체크박스 UI 추가: 인증 폼에 2개 체크박스를 추가하고, 체크 여부에 따라 자동 저장/삭제를 연결했다. (`features/settings/components/KisAuthForm.tsx`)
- [x] 3. 계좌번호 체크박스 UI 추가: 계좌 인증 폼에 체크박스를 추가하고 동일한 저장/복원 흐름을 연결했다. (`features/settings/components/KisProfileForm.tsx`)
- [x] 4. 로그아웃/세션만료 시 정리 연동: 기존 세션 정리 루틴에 기억값 키를 포함해 민감 정보가 남지 않게 했다. (`features/layout/components/user-menu.tsx`, `features/auth/components/session-manager.tsx`)
[사용할 MCP/Skills]
- MCP: shell_command(코드 탐색/수정), apply_patch(파일 수정)
- Skills: dev-auto-pipeline, dev-plan-writer, dev-mcp-implementation, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker, nextjs-app-router-patterns, vercel-react-best-practices
[참조 문서(common-docs)]
- common-docs/api-reference/openapi_all.xlsx
- common-docs/api-reference/kis_api_reference.md
- common-docs/api-reference/kis-error-code-reference.md
- common-docs/features/trade-stock-sync.md
- common-docs/ui/GLOBAL_ALERT_SYSTEM.md
[주석/문서 반영 계획]
- 상태 주석: 기억하기 체크 상태가 입력 필드 자동복원/저장에 미치는 영향을 한 줄 주석으로 추가한다.
- 복잡 로직: "초기 복원"과 "변경 저장"을 [Step 1], [Step 2] 주석으로 분리한다.
- JSX 구역 주석: 입력/체크박스 구역을 나눠 화면 구조를 더 쉽게 읽게 유지한다.
[리스크/회귀 포인트]
- 체크박스 초기화 시 하이드레이션 타이밍 차이로 깜빡임이 생길 수 있다.
- store 입력 setter 호출은 인증 상태를 리셋하므로, 복원 시 기존 세션값을 덮어쓰지 않도록 조건이 필요하다.
- 민감값 장기 저장 정책 변경이므로 로그아웃 시 정리 누락이 없어야 한다.
[검증 계획]
- [x] 1. lint: 타입/린트 오류 없이 통과했다. (`npm run lint` 통과)
- [x] 2. build: Next.js 프로덕션 빌드가 통과했다. (`npm run build` 통과)
- [x] 3. 동작: 체크박스/저장 로직을 코드 경로로 검증했다. (기억하기 on/off -> `setKisRememberEnabled` -> `setRememberedKisValue`)
- [x] 4. 동작: 복원 로직을 코드 경로로 검증했다. (`hasHydrated` 이후 입력값 비어 있을 때만 `getRememberedKisValue` 복원)
- [x] 5. 동작: 로그아웃/세션만료 시 기억값 정리 키 포함을 반영했다. (`SESSION_RELATED_STORAGE_KEYS``KIS_REMEMBER_LOCAL_STORAGE_KEYS` 추가)
[진행 로그]
- 2026-03-05: 계획 문서 작성.
- 2026-03-05: 구현 1~4 완료 (기억하기 체크박스 + localStorage 유틸 + 세션 정리 키 반영).
- 2026-03-05: `npm run lint`, `npm run build` 통과.
- 2026-03-05: Playwright 스모크에서 `/settings` 접근 시 `/login` 리다이렉트 및 콘솔 치명 오류 없음 확인(인증 미보유로 설정 폼 직접 상호작용은 환경상 제한).
[계획 대비 완료체크]
- 완료: 구현 1~4, 검증 1~5
- 부분 완료: 없음
- 미완료: 없음
- 최종 판정: 배포 가능

View File

@@ -0,0 +1,71 @@
# [계획 문서]
- 경로: `common-docs/improvement/plans/dev-plan-2026-03-05-trade-chart-timeframes-and-history.md`
## [요구사항 요약]
- 차트 표시/상호작용을 개선한다. (공식 문서 기준 반영)
- 분봉 옵션에 5분/10분/15분을 추가한다.
- 1시간봉 과거 데이터가 짧게 보이는 원인을 수정한다.
## [가정]
- 기존 차트 라이브러리는 `lightweight-charts@5.1.0`을 유지한다.
- KIS 분봉 API는 당일/일별 분봉 API를 조합해 과거 데이터를 이어 붙인다.
- UI 레이아웃 전체 재설계보다 차트 영역 중심 개선을 우선한다.
## [영향 범위]
- 수정: `features/trade/types/trade.types.ts`
- 수정: `features/trade/components/chart/stock-line-chart-meta.ts`
- 수정: `features/trade/components/chart/chart-utils.ts`
- 수정: `features/trade/components/chart/StockLineChart.tsx`
- 수정: `lib/kis/domestic-helpers.ts`
- 수정: `app/api/kis/domestic/chart/route.ts`
## [구현 단계]
- [x] 1. 차트/타임프레임 타입 확장 (`1m/5m/10m/15m/30m/1h/1d/1w`)
- 근거: `features/trade/types/trade.types.ts`
- [x] 2. 분봉 버킷 계산 로직 확장 (5/10/15분 지원)
- 근거: `lib/kis/domestic-helpers.ts`, `features/trade/components/chart/chart-utils.ts`, `app/api/kis/domestic/chart/route.ts`
- [x] 3. 차트 초기 과거 로드량을 시간프레임별로 확장해 1시간봉 과거 구간 부족 개선
- 근거: `features/trade/components/chart/stock-line-chart-meta.ts`, `features/trade/components/chart/StockLineChart.tsx`
- [x] 4. infinite history 로딩 트리거를 공식 문서 권장 패턴(`barsInLogicalRange`)으로 보강
- 근거: `features/trade/components/chart/StockLineChart.tsx`
- [x] 5. 차트 가시성 옵션(축 여백/우측 여백/가격선) 미세 개선
- 근거: `features/trade/components/chart/StockLineChart.tsx` (`timeScale.rightOffset/barSpacing/minBarSpacing/rightBarStaysOnScroll`)
## [사용할 MCP/Skills]
- MCP: `tavily-remote` (lightweight-charts 공식 문서 확인)
- MCP: `mcp:kis-code-assistant-mcp` (KIS 분봉 API 파라미터/제약 확인)
- Skills: `dev-auto-pipeline`, `vercel-react-best-practices`
## [참조 문서(common-docs)]
- `common-docs/api-reference/openapi_all.xlsx`
- `common-docs/api-reference/kis_api_reference.md`
- `common-docs/api-reference/kis-error-code-reference.md`
- `common-docs/features/trade-stock-sync.md`
- `common-docs/ui/GLOBAL_ALERT_SYSTEM.md`
## [리스크/회귀 포인트]
- 분봉 추가 후 기존 30분/1시간 정렬 경계가 깨질 수 있음
- 과거 로드량 증가 시 초기 로딩 시간이 늘 수 있음
- 무한 스크롤 조건 변경 시 중복 API 호출이 발생할 수 있음
## [검증 계획]
- [x] 1. 타입/빌드 검증: `npm run lint`
- 근거: 통과
- [x] 2. 프로덕션 빌드 검증: `npm run build`
- 근거: 통과
- [x] 3. 수동 점검: 분봉 드롭다운(1/5/10/15/30/60분) 노출 확인
- 근거: Playwriter 스냅샷에서 `1분/5분/10분/15분/30분/1시간` 버튼 노출 확인
- [x] 4. 수동 점검: 1시간봉 진입 직후 과거 구간 확장 여부 확인
- 근거: `/api/kis/domestic/chart?timeframe=1h` 초기 요청 19건 확인, 최소 시각 `2026-02-26 09:00:00(KST)`까지 로드
- [x] 5. 수동 점검: 좌측 스크롤 시 과거 데이터 추가 로딩 유지 확인
- 근거: 차트 드래그 후 `timeframe=1h` 추가 요청 5건 발생, 최소 시각 `2026-02-25 09:00:00(KST)`로 확장
## [진행 로그]
- 2026-03-05: 계획 문서 생성.
- 2026-03-05: `lightweight-charts` 공식 문서 확인 (`subscribeVisibleLogicalRangeChange`, `barsInLogicalRange`, infinite history 데모).
- 2026-03-05: `kis-code-assistant-mcp``inquire_time_itemchartprice`, `inquire_time_dailychartprice` 예제 확인 (당일/과거 분봉 API 호출 제약 확인).
- 2026-03-05: 차트 타임프레임 확장(5/10/15분) + 과거 로드 로직 개선 + KIS 분봉 cursor 파싱 보강 적용.
- 2026-03-05: `npm run lint`, `npm run build` 통과.
- 2026-03-05: Playwriter 실브라우저 검증 수행(`/trade`), 분봉 메뉴/1시간봉 과거 로드/좌측 스크롤 추가 로드 확인.
- 2026-03-05: 1시간봉 초기 과거 로드 상한 추가 상향(페이지 수 + 목표 봉 수 + 12초 예산), 재검증 시 최소 시각 `2026-02-05 09:00:00(KST)`까지 자동 로드 확인.
- 2026-03-05: 창 확장 시 좌측 공백 보완 로직 추가(초기 fitContent 보강 + left whitespace 자동 추가 로드), 1920px 기준 재검증 시 최소 시각 `2026-01-30 13:00:00(KST)`까지 자동 로드 확인.

View File

@@ -0,0 +1,71 @@
[계획 문서 경로]
- common-docs/improvement/plans/dev-plan-2026-03-06-autotrade-ai-signal-context.md
[요구사항 요약]
- 자동매매에서 AI에 넘기는 신호 생성 입력값이 신규 프롬프트 요구사항을 만족하는지 점검한다.
- 부족한 데이터가 있으면 실제 신호 요청 payload에 추가한다.
- 변경 후 검증 결과까지 남긴다.
[가정]
- 신규 프롬프트의 핵심 요구는 `직전 강한 움직임 + 최근 1분봉 압축 구간` 판단이다.
- 현재 전달 중인 최근 체결/호가 파생값만으로는 캔들 구조 판단이 부족하다.
- 실시간 주문 루프는 유지하되, 추가 데이터는 기존 KIS 차트 API를 재사용해 보강한다.
[영향 범위]
- 수정: features/autotrade/hooks/useAutotradeEngine.ts
- 수정: features/autotrade/apis/autotrade.api.ts
- 수정: features/autotrade/types/autotrade.types.ts
- 수정: app/api/autotrade/signals/generate/route.ts
- 수정: lib/autotrade/openai.ts
- 수정: lib/autotrade/cli-provider.ts
- 수정: common-docs/improvement/plans/dev-plan-2026-03-06-autotrade-ai-signal-context.md
- 추가: 없음
- 삭제: 없음
[구현 단계]
- [x] 1. 현재 신호 생성 입력값과 신규 프롬프트 요구사항 차이를 정리한다.
- 근거: 기존 signal payload에는 틱/호가/체결 파생값만 있고, 최근 1분봉 OHLCV와 원본 사용자 prompt가 빠져 있었음.
- [x] 2. 최근 1분봉 OHLCV와 관련 파생값을 담을 타입/요청 스키마를 추가한다.
- 근거: `features/autotrade/types/autotrade.types.ts`, `app/api/autotrade/signals/generate/route.ts`
- [x] 3. 자동매매 훅에서 최근 1분봉 데이터를 조회/캐시하고 신호 요청 snapshot에 포함한다.
- 근거: `features/autotrade/hooks/useAutotradeEngine.ts`
- [x] 4. OpenAI/구독형 CLI 프롬프트가 새 입력값을 활용하도록 지시문을 보강한다.
- 근거: `lib/autotrade/openai.ts`, `lib/autotrade/cli-provider.ts`
- [x] 5. 로그 요약에 새 입력 데이터가 보이도록 정리한다.
- 근거: `features/autotrade/hooks/useAutotradeEngine.ts`
[사용할 MCP/Skills]
- MCP: next-devtools(init), update_plan
- Skills: dev-auto-pipeline, dev-plan-writer, dev-mcp-implementation, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker, vercel-react-best-practices
[참조 문서(common-docs)]
- common-docs/api-reference/kis_api_reference.md
- common-docs/api-reference/kis-error-code-reference.md
- common-docs/features/trade-stock-sync.md
- common-docs/ui/GLOBAL_ALERT_SYSTEM.md
[주석/문서 반영 계획]
- 함수 주석: [목적]/[사용처]/[데이터 흐름] 유지
- 상태 주석: 값 변경 시 화면/주문 루프 영향 한 줄 설명
- 복잡 로직/핸들러: [Step 1], [Step 2], [Step 3] 구조 유지
- JSX 구역 주석: 기존 구조 유지, 필요 시 최소 보강
[리스크/회귀 포인트]
- 1분봉 조회를 신호 루프마다 과도하게 호출하면 응답 지연이 늘 수 있다.
- 차트 조회 실패 시 신호 생성 자체가 막히지 않도록 기존 snapshot fallback을 유지해야 한다.
- 타입 확장 후 route/request schema가 불일치하면 신호 요청이 400으로 실패할 수 있다.
[검증 계획]
- [x] 1. 타입/요청 스키마가 일치하는지 `npm run lint`로 확인한다.
- 결과: 통과
- [x] 2. OpenAI/CLI 프롬프트에 1분봉 데이터와 압축 구간 판단 지시가 반영됐는지 코드로 확인한다.
- 결과: `operatorPrompt`, `recentMinuteCandles`, `minutePatternContext` 활용 지시 반영 완료
- [x] 3. 신호 요청 snapshot 로그에 새 필드가 노출되는지 코드 기준으로 확인한다.
- 결과: `snapshotSummary`, `snapshot` 로그에 minutePattern/recentMinuteCandlesTail 반영 완료
[진행 로그]
- 2026-03-06: 기존 snapshot은 틱/호가/체결 파생값은 충분하지만, 1분봉 캔들 구조 데이터가 없어 신규 패턴 프롬프트 기준으로는 입력이 부족하다고 판단함.
- 2026-03-06: 신호 요청에 원본 사용자 prompt를 추가해, 전략 요약으로 축약되던 세부 규칙이 신호 생성 단계에도 직접 전달되도록 수정함.
- 2026-03-06: 최근 1분봉 OHLCV 24개와 minutePatternContext(직전 추세/압축 범위/압축 거래량비/박스 상하단)를 snapshot에 추가함.
- 2026-03-06: `npm run lint`, `npm run build` 통과. `nextjs_call(get_errors)` 기준 3001 개발 서버에서 브라우저 세션 오류 없음 확인. 브라우저 자동화 스모크는 로컬 Chrome 프로필 충돌로 미실행.
- 2026-03-06: BUY 신호인데 주문이 나가지 않는 원인을 추가 점검한 결과, `maxOrderAmountRatio`가 낮으면 전체 예산으로 1주를 살 수 있어도 주문 수량이 0주가 되는 문제가 확인됨. `lib/autotrade/risk.ts`에서 최소 1주 보정 로직을 추가하고 `npm run lint`, `npm run build` 재통과 확인.

View File

@@ -0,0 +1,126 @@
[계획 문서 경로]
- common-docs/improvement/plans/dev-plan-2026-03-06-autotrade-real-execution-budget-tax.md
[요구사항 요약]
- 내 예산 기준으로 실제 몇 주를 살 수 있는지 계산하고, 자동매매 설정창에서 정한 비율대로 매수 수량이 정해지게 만든다.
- 매도는 현재 보유/매도가능 수량과 비교해서 가능한 수량만 나가게 한다.
- 수수료/세금/실현손익까지 고려해 진짜 자동매매처럼 동작하게 만든다.
[가정]
- 자동매매 설정창의 `allocationPercent`는 "이번 종목/이번 주문에 실제로 쓸 비율"로 사용한다.
- `allocationAmount`는 절대 상한(최대 투자금)으로 사용한다.
- 수수료/세금은 계좌/환경/정책에 따라 달라질 수 있으므로, 구현 시 하드코딩보다 `설정값 + KIS 실제 체결/매매일지 값`을 함께 쓴다.
- 국내주식 단주가 아닌 1주 단위 주문 기준으로 계획한다.
[영향 범위]
- 수정: features/autotrade/hooks/useAutotradeEngine.ts
- 수정: lib/autotrade/risk.ts
- 수정: features/autotrade/types/autotrade.types.ts
- 수정: features/autotrade/components/AutotradeControlPanel.tsx
- 수정: app/api/autotrade/signals/generate/route.ts
- 수정: lib/autotrade/openai.ts
- 수정: lib/autotrade/cli-provider.ts
- 수정: package.json
- 추가: lib/autotrade/execution-cost.ts
- 추가: lib/autotrade/executable-order-quantity.ts
- 추가: tests/autotrade/risk-budget.test.ts
- 추가: tests/autotrade/order-guard-cost.test.ts
- 추가: common-docs/improvement/plans/dev-plan-2026-03-06-autotrade-real-execution-budget-tax.md
- 삭제: 없음
[현재 코드 기준 핵심 문제]
- `allocationPercent`가 실주문 계산 기준이 아니라 참고 경고 수준으로만 쓰이고 있다.
- 쉬운 말: 설정창에서 10%, 25%를 바꿔도 실제 자동매매 수량 계산에는 약하게만 반영된다.
- 매수 수량은 `effectiveAllocationAmount``maxOrderAmountRatio` 중심이라, 내 예산/비율/호가/예상 비용을 함께 계산하는 구조가 아니다.
- 매도는 `보유수량/매도가능수량` 차단은 있지만, 포지션 기준 목표 청산 비율, 부분 청산, 순손익 기준 청산 조건이 없다.
- 세금/수수료는 대시보드 조회/표시에는 일부 있지만, 자동매매의 진입/청산/손실 한도 계산에는 거의 반영되지 않는다.
- 일일 손실 한도는 입력 금액 기준이고, 실제 체결 후 순손익(수수료/세금 포함)과 연결되지 않는다.
[구현 단계]
- [x] 1. 주문 가능 예산 모델 재정의
- 입력: 가용 예수금, 매수가능금액, allocationPercent, allocationAmount, 전략별 maxOrderAmountRatio
- 처리: `실주문가능예산 = min(매수가능금액, allocationAmount 상한, 예수금 * allocationPercent)` 구조로 통일
- 결과: "현재 이 종목에 실제로 쓸 수 있는 예산" 1개 값으로 고정
- [x] 2. 매수 수량 계산 로직 교체
- 입력: 실주문가능예산, 현재가/주문가, 예상 수수료, 최소 안전여유금
- 처리: 비용 포함 기준으로 최대 주문 가능 수량 계산
- 결과: "내 예산 기준으로 지금 몇 주 살 수 있는지"를 로그와 UI에 함께 표시
- [x] 3. 매도 수량 계산 로직을 포지션 기준으로 확장
- 입력: 보유수량, 매도가능수량, 평균단가, 평가손익, AI 제안 수량/비율
- 처리: 없는 주식은 절대 매도 금지, 보유보다 큰 수량 금지, 부분 매도 허용
- 결과: "실제 보유 중인 수량 안에서만 매도" 보장
- [x] 4. 수수료/세금 추정 모듈 추가
- 입력: 주문금액, 매수/매도 구분, 계좌/환경 정책
- 처리: 주문 전 예상 비용 계산, 주문 후 실제 체결/매매일지로 정산값 보정
- 결과: 순손익 기준 판단 가능
- [x] 5. 자동매매 위험 관리 기준을 순손익 기준으로 보강
- 입력: 실현손익, 평가손익, 누적 수수료, 누적 세금
- 처리: 일일 손실선/청산 조건을 총손익이 아니라 순손익 기준으로 갱신
- 결과: 세금/수수료 때문에 실제 손실이 커지는 상황 반영
- [x] 6. AI 입력값도 포지션/비용 기준으로 보강
- 입력: holdingQuantity, sellableQuantity, averagePrice, estimatedFee, estimatedTax, netProfitEstimate
- 처리: AI가 매도 시 "팔 수 있는지/팔면 순손익이 어떤지"를 함께 보게 함
- 결과: 보유 없는 SELL, 손익 무시 SELL/BUY 감소
- [x] 7. UI/로그 보강
- 자동매매 설정창/로그에 아래 항목 노출
- 현재 주문 가능 예산
- 현재 매수 가능 수량
- 현재 보유 수량 / 매도 가능 수량
- 예상 수수료 / 예상 세금 / 예상 순손익
- [x] 8. 체결 후 실제값 동기화
- 주문 후 잔고/활동 API 재조회
- 체결 후 보유수량, sellableQuantity, realized fee/tax, realized profit을 스토어에 반영
- 다음 주문은 이 최신값을 기준으로 계산
[사용할 MCP/Skills]
- MCP: next-devtools, sequential-thinking, mcp:kis-code-assistant-mcp
- Skills: dev-plan-writer, dev-mcp-implementation, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker, vercel-react-best-practices
[참조 문서(common-docs)]
- common-docs/api-reference/kis_api_reference.md
- common-docs/api-reference/kis-error-code-reference.md
- common-docs/features/trade-stock-sync.md
- common-docs/ui/GLOBAL_ALERT_SYSTEM.md
[주석/문서 반영 계획]
- 함수 주석: [목적]/[사용처]/[데이터 흐름] 유지
- 수량 계산/비용 계산 함수에는 입력 -> 처리 -> 결과 주석 추가
- 자동매매 로그에는 "왜 주문됐는지/왜 차단됐는지" 숫자 기준 노출
[리스크/회귀 포인트]
- 계좌별 수수료 정책이 다르면 세금/수수료 추정이 실제와 다를 수 있다.
- 매수가능금액/잔고/매매일지 API 응답 타이밍이 어긋나면 체결 직후 수량이 잠깐 다르게 보일 수 있다.
- 모의투자는 실전과 세금/수수료/매매일지 지원 방식이 다를 수 있다.
- 주문 전 추정 비용과 주문 후 실제 비용이 다를 수 있으므로, 최종 손익 기준은 실제 체결/매매일지 값으로 재정산해야 한다.
[검증 계획]
- [x] 1. `allocationPercent`, `allocationAmount`, `매수가능금액` 조합별로 매수 수량이 기대값대로 계산되는지 단위 테스트 추가
- [x] 2. 보유 없음 / 보유 1주 / 매도가능수량 부족 상황에서 SELL이 차단되는지 테스트
- [x] 3. 수수료/세금 추정 로직과 실제 activity API 정산값 연결 테스트
- [x] 4. `npm run lint`
- [x] 5. `npm run build`
- [x] 6. 자동매매 스모크 시나리오
- 예산 30만원, 비율 10%, 주가 16,000원일 때 매수 가능 수량 계산 확인
- 보유 5주, 매도가능 3주일 때 SELL 수량 제한 확인
- 체결 후 잔고/활동 재조회로 보유/손익이 갱신되는지 확인
- Playwright 인증 필요 구간에서는 사용자(본인)가 로그인/앱키/계좌 인증을 완료할 때까지 테스트를 대기하고, 완료 신호를 받은 뒤 다음 단계를 진행
[진행 로그]
- 2026-03-06: 현재 자동매매 코드를 점검한 결과, 매도가능수량 비교는 일부 구현되어 있으나 `allocationPercent` 실주문 반영, 세금/수수료 반영, 순손익 기준 손실 관리, 체결 후 정산 반영은 미흡한 상태로 판단함.
- 2026-03-06: 구현 방향을 `예산 계산 -> 주문 수량 계산 -> 보유/매도가능 수량 검증 -> 비용 추정 -> 체결 후 실제 정산` 순서로 재설계하기로 함.
- 2026-03-06: `lib/autotrade/risk.ts`에서 `allocationPercent`를 실주문 예산 계산에 강제 반영하도록 변경하고, BUY/SELL 수량 계산 경로를 분리함.
- 2026-03-06: `useAutotradeEngine.ts`에 비용 추정(수수료/세금), 체결 전후 활동/잔고 재조회, 누적 손실 한도 자동중지 로직을 반영함.
- 2026-03-06: AI 신호 스냅샷에 `budgetContext`, `portfolioContext`, `executionCostProfile`을 추가하고 OpenAI/CLI 프롬프트 규칙에 예산/보유/비용 제약을 반영함.
- 2026-03-06: 검증 결과 `npm run lint`, `npm run build` 통과. `npm run test:autotrade:smoke`는 로그인 필요(개발 우회 토큰 미적용 환경)로 실패함.
- 2026-03-06: Playwright 스모크로 `/`, `/trade`(로그인 리다이렉트 확인), `/settings`(로그인 리다이렉트 확인) 화면 로드 및 콘솔 error 없음 확인.
- 2026-03-06: Playwright 테스트 협업 규칙 추가 - 로그인/앱키/계좌 인증은 사용자가 직접 완료하고, 완료 전에는 테스트를 대기하도록 문서에 명시함.
- 2026-03-06: `lib/autotrade/executable-order-quantity.ts` 순수 clamp 유틸을 추가하고, `useAutotradeEngine.ts`의 실제 주문수량 검증에 연결함.
- 2026-03-06: 단위 테스트 추가(`tests/autotrade/risk-budget.test.ts`, `tests/autotrade/order-guard-cost.test.ts`) 후 `npm run test:autotrade:unit` 통과.
- 2026-03-06: `.env.local`의 실제 `AUTOTRADE_DEV_BYPASS_TOKEN`, `AUTOTRADE_WORKER_TOKEN`으로 스모크 재실행하여 `npm run test:autotrade:smoke` 통과.
- 2026-03-06: Playwriter 실브라우저 디버깅으로 `/trade` 화면에서 `내 설정 점검 -> 자동매매 시작 -> 수동 중지` 흐름 확인(세션 시작/중지 로그 정상, 브라우저 콘솔 error 없음). 장중 실시간 틱 부재로 신호요청/주문실행 로그는 미발생.
- 2026-03-06: AI 스냅샷의 `estimatedBuyableQuantity` 계산을 실제 주문 함수(`resolveOrderQuantity`)와 동일하게 통일해, 비율 예산으로 0주가 나와도 전체 예산 1주 가능 시 `1주`가 전달되도록 핫픽스함.
- 2026-03-06: Playwriter 네트워크 검증으로 `/api/autotrade/signals/generate` 요청 본문에 `estimatedBuyableQuantity=1`, `effectiveAllocationAmount=21631`, `effectiveOrderBudgetAmount=7570`, `currentPrice=16790`이 전달되는 것을 확인함(수량 0 전달 이슈 해소).
- 2026-03-06: 검증 자금 산정 로직을 `예수금 + 매수가능금액` 동시 조회 기반으로 변경하고, 두 값이 모두 있을 때는 더 보수적인 값(min)을 사용하도록 반영함.
- 2026-03-06: 자동매매 설정창의 투자비율 입력 UX를 퍼센트 프리셋 버튼 + 슬라이더 + 금액 자동입력 버튼으로 개선하고, 안전 점검 라벨을 `가용 예수금`에서 `주문 기준 자금`으로 변경함.
- 2026-03-06: `setNumberField`를 필드별 범위(clamp) 보정 방식으로 바꿔 퍼센트/신뢰도 입력이 비정상 값(음수, 100% 초과, 임계값 범위 이탈)으로 저장되지 않도록 정리함.
- 2026-03-06: 회귀 검증으로 `npm run test:autotrade:unit`, `npm run lint`, `npm run build` 재실행 모두 통과함.

View File

@@ -34,6 +34,12 @@ export function AnimatedBrandTone() {
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, []);
const answerText = TONE_PHRASES[index].a;
const answerChars = answerText.split("");
const answerLength = answerChars.length;
const answerFontSize = resolveAnswerFontSize(answerLength);
const answerTracking = resolveAnswerTracking(answerLength);
return ( return (
<div className="flex min-h-[300px] flex-col items-center justify-center py-10 text-center md:min-h-[400px]"> <div className="flex min-h-[300px] flex-col items-center justify-center py-10 text-center md:min-h-[400px]">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
@@ -43,7 +49,7 @@ export function AnimatedBrandTone() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }} exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.5, ease: "easeOut" }} transition={{ duration: 0.5, ease: "easeOut" }}
className="flex flex-col items-center w-full" className="flex w-full flex-col items-center"
> >
{/* 질문 (Q) */} {/* 질문 (Q) */}
<motion.p <motion.p
@@ -56,21 +62,26 @@ export function AnimatedBrandTone() {
</motion.p> </motion.p>
{/* 답변 (A) - 타이핑 효과 */} {/* 답변 (A) - 타이핑 효과 */}
<div className="mt-8 flex flex-col items-center gap-2"> <div className="mt-8 flex w-full flex-col items-center gap-2 px-2 sm:px-4">
<h2 className="text-4xl font-extrabold tracking-wide text-white drop-shadow-lg md:text-6xl lg:text-7xl"> <h2
<div className="inline-block break-keep whitespace-pre-wrap leading-tight"> className="w-full font-bold text-white drop-shadow-[0_12px_30px_rgba(0,0,0,0.38)]"
{TONE_PHRASES[index].a.split("").map((char, i) => ( style={{ fontSize: answerFontSize }}
>
<div
className="inline-flex max-w-full items-center whitespace-nowrap leading-[1.12]"
style={{ letterSpacing: answerTracking }}
>
{answerChars.map((char, i) => (
<motion.span <motion.span
key={i} key={i}
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ transition={{
duration: 0, duration: 0,
delay: 0.5 + i * 0.1, // 글자당 0.1초 딜레이로 타이핑 효과 delay: 0.45 + i * 0.055,
}} }}
className={cn( className={cn(
"inline-block", "inline-block align-baseline",
// 앞부분 강조 색상 로직은 단순화하거나 유지 (여기서는 전체 텍스트 톤 유지)
i < 5 ? "text-brand-300" : "text-white", i < 5 ? "text-brand-300" : "text-white",
)} )}
> >
@@ -86,7 +97,7 @@ export function AnimatedBrandTone() {
repeat: Infinity, repeat: Infinity,
ease: "linear", ease: "linear",
}} }}
className="ml-1 inline-block h-[0.8em] w-1.5 bg-brand-400 align-middle shadow-[0_0_10px_rgba(45,212,191,0.5)]" className="ml-2 inline-block h-[0.78em] w-1.5 rounded-xs bg-brand-300 align-middle shadow-[0_0_14px_rgba(167,139,250,0.55)]"
/> />
</div> </div>
</h2> </h2>
@@ -113,3 +124,29 @@ export function AnimatedBrandTone() {
</div> </div>
); );
} }
function resolveAnswerFontSize(answerLength: number) {
if (answerLength >= 30) {
return "clamp(1rem,2.4vw,2.2rem)";
}
if (answerLength >= 25) {
return "clamp(1.15rem,2.9vw,2.9rem)";
}
if (answerLength >= 20) {
return "clamp(1.3rem,3.4vw,3.8rem)";
}
return "clamp(1.45rem,4vw,4.8rem)";
}
function resolveAnswerTracking(answerLength: number) {
if (answerLength >= 30) {
return "-0.008em";
}
if (answerLength >= 25) {
return "-0.012em";
}
if (answerLength >= 20) {
return "-0.016em";
}
return "-0.018em";
}

View File

@@ -24,6 +24,7 @@ import {
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { SESSION_TIMEOUT_MS } from "@/features/auth/constants"; import { SESSION_TIMEOUT_MS } from "@/features/auth/constants";
import { KIS_REMEMBER_LOCAL_STORAGE_KEYS } from "@/features/settings/lib/kis-remember-storage";
// import { toast } from "sonner"; // Unused for now // import { toast } from "sonner"; // Unused for now
// 설정: 경고 표시 시간 (타임아웃 1분 전) - 현재 미사용 (즉시 로그아웃) // 설정: 경고 표시 시간 (타임아웃 1분 전) - 현재 미사용 (즉시 로그아웃)
@@ -33,6 +34,7 @@ const SESSION_RELATED_STORAGE_KEYS = [
"session-storage", "session-storage",
"auth-storage", "auth-storage",
"autotrade-kis-runtime-store", "autotrade-kis-runtime-store",
...KIS_REMEMBER_LOCAL_STORAGE_KEYS,
] as const; ] as const;
/** /**

View File

@@ -0,0 +1,217 @@
/**
* [파일 역할]
* 자동매매 프론트엔드가 호출하는 API 클라이언트 모음입니다.
*
* [주요 책임]
* - compile/validate/session/signal 관련 Next API 호출을 캡슐화합니다.
* - 공통 응답 파싱/오류 메시지 처리를 제공합니다.
*/
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import { buildKisRequestHeaders } from "@/features/settings/apis/kis-api-utils";
import type {
AutotradeAiMode,
AutotradeCompileResponse,
AutotradeCompiledStrategy,
AutotradeMarketSnapshot,
AutotradeSessionInfo,
AutotradeSessionResponse,
AutotradeSignalResponse,
AutotradeStopReason,
AutotradeValidateResponse,
} from "@/features/autotrade/types/autotrade.types";
interface AutotradeErrorPayload {
ok?: boolean;
message?: string;
errorCode?: string;
}
// [목적] UI 설정값을 서버 compile 라우트로 전달해 실행 전략(JSON)을 받습니다.
export async function compileAutotradeStrategy(payload: {
aiMode: AutotradeAiMode;
subscriptionCliVendor?: "auto" | "codex" | "gemini";
subscriptionCliModel?: string;
prompt: string;
selectedTechniques: AutotradeCompiledStrategy["selectedTechniques"];
confidenceThreshold: number;
}) {
const response = await fetch("/api/autotrade/strategies/compile", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(payload),
cache: "no-store",
});
return parseAutotradeResponse<AutotradeCompileResponse>(
response,
"자동매매 전략 컴파일 중 오류가 발생했습니다.",
);
}
// [목적] 가용자산/손실한도를 서버에서 동일 규칙으로 계산해 검증 결과를 받습니다.
export async function validateAutotradeStrategy(payload: {
cashBalance: number;
allocationPercent: number;
allocationAmount: number;
dailyLossPercent: number;
dailyLossAmount: number;
}) {
const response = await fetch("/api/autotrade/strategies/validate", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(payload),
cache: "no-store",
});
return parseAutotradeResponse<AutotradeValidateResponse>(
response,
"자동매매 리스크 검증 중 오류가 발생했습니다.",
);
}
// [목적] 자동매매 실행 세션을 서버에 등록합니다.
export async function startAutotradeSession(
payload: {
symbol: string;
leaderTabId: string;
effectiveAllocationAmount: number;
effectiveDailyLossLimit: number;
strategySummary: string;
},
credentials: KisRuntimeCredentials,
) {
const response = await fetch("/api/autotrade/sessions/start", {
method: "POST",
headers: {
...buildKisRequestHeaders(credentials, {
jsonContentType: true,
includeAccountNo: true,
}),
},
body: JSON.stringify(payload),
cache: "no-store",
});
return parseAutotradeResponse<AutotradeSessionResponse>(
response,
"자동매매 세션 시작 중 오류가 발생했습니다.",
);
}
// [목적] 실행 중 세션 생존 신호를 주기적으로 갱신합니다.
export async function heartbeatAutotradeSession(payload: {
sessionId: string;
leaderTabId: string;
}) {
const response = await fetch("/api/autotrade/sessions/heartbeat", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(payload),
cache: "no-store",
});
return parseAutotradeResponse<AutotradeSessionResponse>(
response,
"자동매매 heartbeat 전송 중 오류가 발생했습니다.",
);
}
// [목적] 수동/비상/종료 등 중지 사유를 서버 세션에 반영합니다.
export async function stopAutotradeSession(payload: {
sessionId?: string;
reason?: AutotradeStopReason;
}) {
const response = await fetch("/api/autotrade/sessions/stop", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(payload),
cache: "no-store",
});
return parseAutotradeResponse<{
ok: boolean;
session: AutotradeSessionInfo | null;
}>(response, "자동매매 세션 종료 중 오류가 발생했습니다.");
}
// [목적] 현재 사용자의 실행 중 세션 존재 여부를 조회합니다.
export async function fetchActiveAutotradeSession() {
const response = await fetch("/api/autotrade/sessions/active", {
method: "GET",
cache: "no-store",
});
return parseAutotradeResponse<{
ok: boolean;
session: AutotradeSessionInfo | null;
}>(response, "자동매매 세션 조회 중 오류가 발생했습니다.");
}
// [목적] 시세 스냅샷 + 전략을 서버에 보내 매수/매도/대기 신호를 생성합니다.
export async function generateAutotradeSignal(payload: {
aiMode: AutotradeAiMode;
subscriptionCliVendor?: "auto" | "codex" | "gemini";
subscriptionCliModel?: string;
prompt: string;
strategy: AutotradeCompiledStrategy;
snapshot: AutotradeMarketSnapshot;
}) {
const response = await fetch("/api/autotrade/signals/generate", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(payload),
cache: "no-store",
});
return parseAutotradeResponse<AutotradeSignalResponse>(
response,
"자동매매 신호 생성 중 오류가 발생했습니다.",
);
}
// [목적] 브라우저 종료 직전 stop 요청을 보내기 위한 비동기 beacon 경로입니다.
export function sendAutotradeStopBeacon(payload: {
sessionId?: string;
reason: AutotradeStopReason;
}) {
if (typeof navigator === "undefined") return false;
try {
const body = JSON.stringify(payload);
const blob = new Blob([body], { type: "application/json" });
return navigator.sendBeacon("/api/autotrade/sessions/stop", blob);
} catch {
return false;
}
}
async function parseAutotradeResponse<T>(
response: Response,
fallbackMessage: string,
): Promise<T> {
let payload: unknown = null;
try {
payload = (await response.json()) as unknown;
} catch {
throw new Error(fallbackMessage);
}
if (!response.ok) {
const errorPayload = payload as AutotradeErrorPayload;
throw new Error(errorPayload.message || fallbackMessage);
}
return payload as T;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
interface AutotradeWarningBannerProps {
visible: boolean;
isStopping?: boolean;
onStop: () => void;
}
export function AutotradeWarningBanner({
visible,
isStopping = false,
onStop,
}: AutotradeWarningBannerProps) {
if (!visible) return null;
return (
<div className="border-b border-red-300/60 bg-red-600/90 px-3 py-2 text-white shadow-[0_2px_10px_rgba(220,38,38,0.35)] sm:px-4">
<div className="mx-auto flex w-full max-w-[1800px] items-center gap-3">
<AlertTriangle className="h-4 w-4 shrink-0" />
<p className="text-xs font-semibold sm:text-sm">
: 브라우저/ .
</p>
<Button
type="button"
variant="secondary"
size="sm"
className="ml-auto h-7 bg-white text-red-700 hover:bg-red-50"
disabled={isStopping}
onClick={onStop}
>
{isStopping ? "중지 중..." : "비상 중지"}
</Button>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,212 @@
"use client";
import { create } from "zustand";
import type {
AutotradeCompiledStrategy,
AutotradeEngineState,
AutotradeRuntimeLog,
AutotradeSessionInfo,
AutotradeSetupFormValues,
AutotradeSignalCandidate,
AutotradeValidationResult,
} from "@/features/autotrade/types/autotrade.types";
import { resolveSetupDefaults } from "@/lib/autotrade/strategy";
interface AutotradeEngineStoreState {
panelOpen: boolean;
setupForm: AutotradeSetupFormValues;
engineState: AutotradeEngineState;
isWorking: boolean;
activeSession: AutotradeSessionInfo | null;
compiledStrategy: AutotradeCompiledStrategy | null;
validation: AutotradeValidationResult | null;
lastSignal: AutotradeSignalCandidate | null;
orderCountToday: number;
cumulativeLossAmount: number;
consecutiveFailures: number;
lastOrderAtBySymbol: Record<string, number>;
logs: AutotradeRuntimeLog[];
}
interface AutotradeEngineStoreActions {
setPanelOpen: (open: boolean) => void;
patchSetupForm: (patch: Partial<AutotradeSetupFormValues>) => void;
setEngineState: (state: AutotradeEngineState) => void;
setWorking: (working: boolean) => void;
setActiveSession: (session: AutotradeSessionInfo | null) => void;
setCompiledStrategy: (strategy: AutotradeCompiledStrategy | null) => void;
setValidation: (validation: AutotradeValidationResult | null) => void;
setLastSignal: (signal: AutotradeSignalCandidate | null) => void;
increaseOrderCount: (count?: number) => void;
addLossAmount: (lossAmount: number) => void;
setLastOrderAt: (symbol: string, timestampMs: number) => void;
increaseFailure: () => void;
resetFailure: () => void;
appendLog: (
level: AutotradeRuntimeLog["level"],
message: string,
options?: {
stage?: AutotradeRuntimeLog["stage"];
detail?: string | Record<string, unknown>;
},
) => void;
clearRuntime: () => void;
}
const INITIAL_FORM = resolveSetupDefaults();
const INITIAL_STATE: AutotradeEngineStoreState = {
panelOpen: false,
setupForm: INITIAL_FORM,
engineState: "IDLE",
isWorking: false,
activeSession: null,
compiledStrategy: null,
validation: null,
lastSignal: null,
orderCountToday: 0,
cumulativeLossAmount: 0,
consecutiveFailures: 0,
lastOrderAtBySymbol: {},
logs: [],
};
export const useAutotradeEngineStore = create<
AutotradeEngineStoreState & AutotradeEngineStoreActions
>((set) => ({
...INITIAL_STATE,
setPanelOpen: (open) => {
set({ panelOpen: open });
},
patchSetupForm: (patch) => {
set((state) => ({
setupForm: {
...state.setupForm,
...patch,
},
}));
},
setEngineState: (engineState) => {
set({ engineState });
},
setWorking: (isWorking) => {
set({ isWorking });
},
setActiveSession: (activeSession) => {
set({ activeSession });
},
setCompiledStrategy: (compiledStrategy) => {
set({ compiledStrategy });
},
setValidation: (validation) => {
set({ validation });
},
setLastSignal: (lastSignal) => {
set({ lastSignal });
},
increaseOrderCount: (count = 1) => {
set((state) => ({
orderCountToday: state.orderCountToday + Math.max(1, count),
}));
},
addLossAmount: (lossAmount) => {
set((state) => ({
cumulativeLossAmount:
state.cumulativeLossAmount + Math.max(0, Math.floor(lossAmount)),
}));
},
setLastOrderAt: (symbol, timestampMs) => {
set((state) => ({
lastOrderAtBySymbol: {
...state.lastOrderAtBySymbol,
[symbol]: timestampMs,
},
}));
},
increaseFailure: () => {
set((state) => ({
consecutiveFailures: state.consecutiveFailures + 1,
}));
},
resetFailure: () => {
set({ consecutiveFailures: 0 });
},
appendLog: (level, message, options) => {
const entry: AutotradeRuntimeLog = {
id: safeLogId(),
level,
stage: options?.stage,
message,
detail: normalizeLogDetail(options?.detail),
createdAt: new Date().toISOString(),
};
set((state) => ({
logs: [entry, ...state.logs].slice(0, 80),
}));
},
clearRuntime: () => {
set((state) => ({
...state,
engineState: "IDLE",
isWorking: false,
activeSession: null,
compiledStrategy: null,
validation: null,
lastSignal: null,
orderCountToday: 0,
cumulativeLossAmount: 0,
consecutiveFailures: 0,
lastOrderAtBySymbol: {},
}));
},
}));
function safeLogId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `autotrade-log-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function normalizeLogDetail(detail: string | Record<string, unknown> | undefined) {
if (!detail) {
return undefined;
}
if (typeof detail === "string") {
const cleaned = detail.trim();
return cleaned.length > 0 ? cleaned : undefined;
}
try {
return JSON.stringify(detail, null, 2);
} catch {
return undefined;
}
}

View File

@@ -0,0 +1,482 @@
/**
* @file features/autotrade/types/autotrade.types.ts
* @description 자동매매 기능에서 공통으로 사용하는 타입 정의입니다.
*/
export const AUTOTRADE_TECHNIQUE_IDS = [
"orb",
"vwap_reversion",
"volume_breakout",
"ma_crossover",
"gap_breakout",
"intraday_box_reversion",
"intraday_breakout_scalp",
] as const;
export type AutotradeTechniqueId = (typeof AUTOTRADE_TECHNIQUE_IDS)[number];
export interface AutotradeTechniqueOption {
id: AutotradeTechniqueId;
label: string;
description: string;
}
export const AUTOTRADE_TECHNIQUE_OPTIONS: AutotradeTechniqueOption[] = [
{
id: "orb",
label: "ORB(시가 범위 돌파)",
description: "시가 근처 범위를 돌파할 때 추세 진입 신호를 확인합니다.",
},
{
id: "vwap_reversion",
label: "VWAP 되돌림",
description: "VWAP에서 과하게 이탈한 가격이 평균으로 복귀하는 구간을 봅니다.",
},
{
id: "volume_breakout",
label: "거래량 돌파",
description: "거래량 급증과 함께 방향성이 생기는 순간을 포착합니다.",
},
{
id: "ma_crossover",
label: "이동평균 교차",
description: "단기/중기 평균선 교차로 추세 전환 여부를 확인합니다.",
},
{
id: "gap_breakout",
label: "갭 돌파",
description: "갭 상승/하락 이후 추가 돌파 또는 되돌림을 판단합니다.",
},
{
id: "intraday_box_reversion",
label: "상승 후 박스권 단타",
description:
"당일 상승 이후 박스권 횡보 구간에서 상단/하단 왕복(오르락내리락) 단타를 노립니다.",
},
{
id: "intraday_breakout_scalp",
label: "상승구간 눌림-재돌파 단타",
description:
"1분봉 상승 추세에서 저거래량 눌림 후 고점 재돌파(거래량 재유입) 구간을 노립니다.",
},
];
export const AUTOTRADE_DEFAULT_TECHNIQUES: AutotradeTechniqueId[] = [
"ma_crossover",
"vwap_reversion",
"intraday_box_reversion",
"intraday_breakout_scalp",
];
export type AutotradeEngineState =
| "IDLE"
| "RUNNING"
| "STOPPING"
| "STOPPED"
| "ERROR";
export const AUTOTRADE_AI_MODE_IDS = [
"auto",
"openai_api",
"subscription_cli",
"rule_fallback",
] as const;
export type AutotradeAiMode = (typeof AUTOTRADE_AI_MODE_IDS)[number];
export const AUTOTRADE_SUBSCRIPTION_CLI_VENDOR_IDS = [
"auto",
"codex",
"gemini",
] as const;
export type AutotradeSubscriptionCliVendor =
(typeof AUTOTRADE_SUBSCRIPTION_CLI_VENDOR_IDS)[number];
export interface AutotradeSubscriptionCliVendorOption {
id: AutotradeSubscriptionCliVendor;
label: string;
description: string;
}
export const AUTOTRADE_SUBSCRIPTION_CLI_VENDOR_OPTIONS: AutotradeSubscriptionCliVendorOption[] =
[
{
id: "auto",
label: "자동 선택",
description: "Codex -> Gemini 순서로 시도합니다.",
},
{
id: "codex",
label: "Codex CLI",
description: "OpenAI Codex CLI만 사용합니다.",
},
{
id: "gemini",
label: "Gemini CLI",
description: "Google Gemini CLI만 사용합니다.",
},
];
export interface AutotradeSubscriptionCliModelOption {
value: string;
label: string;
description: string;
}
// [출처] 공식 문서 기준 추천 프리셋
// - Codex Models: https://developers.openai.com/codex/models
// - Gemini CLI model command: https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/model.md
export const AUTOTRADE_SUBSCRIPTION_CLI_MODEL_OPTIONS = {
codex: [
{
value: "gpt-5.4",
label: "gpt-5.4",
description: "Codex 추천 기본형",
},
{
value: "gpt-5.3-codex",
label: "gpt-5.3-codex",
description: "Codex 5.3 고성능 라인",
},
{
value: "gpt-5.3-codex-spark",
label: "gpt-5.3-codex-spark",
description: "Codex 5.3 경량형",
},
{
value: "gpt-5.2-codex",
label: "gpt-5.2-codex",
description: "Codex 5.2 균형형",
},
{
value: "gpt-5.2",
label: "gpt-5.2",
description: "Codex 5.2 범용형",
},
{
value: "gpt-5.1-codex-max",
label: "gpt-5.1-codex-max",
description: "문맥 확장형 Codex 5.1",
},
{
value: "gpt-5.1",
label: "gpt-5.1",
description: "Codex 5.1 범용형",
},
{
value: "gpt-5.1-codex",
label: "gpt-5.1-codex",
description: "Codex 5.1 기본형",
},
{
value: "gpt-5-codex",
label: "gpt-5-codex (안정형)",
description: "Codex 안정형",
},
{
value: "gpt-5-codex-mini",
label: "gpt-5-codex-mini",
description: "Codex 경량형",
},
{
value: "gpt-5",
label: "gpt-5",
description: "Codex 범용 경량 라인",
},
] satisfies AutotradeSubscriptionCliModelOption[],
gemini: [
{
value: "auto",
label: "auto (권장)",
description: "상황에 따라 Pro/Flash 계열을 자동 선택",
},
{
value: "gemini-3.1-pro-preview",
label: "gemini-3.1-pro-preview (신규)",
description: "Gemini 3.1 고성능 추론/코딩 프리뷰",
},
{
value: "gemini-3.1-flash-lite-preview",
label: "gemini-3.1-flash-lite-preview",
description: "Gemini 3.1 경량 고속 프리뷰",
},
{
value: "gemini-3-flash-preview",
label: "gemini-3-flash-preview",
description: "Gemini 3 고속 프리뷰",
},
{
value: "gemini-2.5-pro",
label: "gemini-2.5-pro",
description: "고난도 추론 중심",
},
{
value: "gemini-2.5-flash",
label: "gemini-2.5-flash",
description: "속도/품질 균형형",
},
{
value: "gemini-2.5-flash-lite",
label: "gemini-2.5-flash-lite",
description: "가벼운 작업용 고속 모델",
},
{
value: "gemini-3-pro-preview",
label: "gemini-3-pro-preview (종료예정)",
description: "공식 문서 기준 2026-03-09 종료 예정 프리뷰",
},
] satisfies AutotradeSubscriptionCliModelOption[],
} as const;
export interface AutotradeAiModeOption {
id: AutotradeAiMode;
label: string;
description: string;
}
export const AUTOTRADE_AI_MODE_OPTIONS: AutotradeAiModeOption[] = [
{
id: "auto",
label: "자동(권장)",
description:
"OpenAI API 키가 있으면 OpenAI를 사용하고, 없으면 구독형 CLI를 시도합니다. 둘 다 실패하면 규칙 기반으로 전환합니다.",
},
{
id: "openai_api",
label: "OpenAI API",
description: "서버에서 OpenAI API를 직접 호출해 판단합니다.",
},
{
id: "subscription_cli",
label: "구독형 CLI 자동판단",
description: "서버에 설치된 Codex/Gemini CLI로 자동 판단을 생성합니다.",
},
{
id: "rule_fallback",
label: "규칙 기반",
description: "AI 호출 없이 내부 규칙 엔진으로만 판단합니다.",
},
];
export type AutotradeStopReason =
| "browser_exit"
| "external_leave"
| "manual"
| "emergency"
| "heartbeat_timeout";
export interface AutotradeSetupFormValues {
aiMode: AutotradeAiMode;
subscriptionCliVendor: AutotradeSubscriptionCliVendor;
subscriptionCliModel: string;
prompt: string;
selectedTechniques: AutotradeTechniqueId[];
allocationPercent: number;
allocationAmount: number;
dailyLossPercent: number;
dailyLossAmount: number;
confidenceThreshold: number;
agreeStopOnExit: boolean;
}
export interface AutotradeCompiledStrategy {
provider: "openai" | "fallback" | "subscription_cli";
providerVendor?: "codex" | "gemini";
providerModel?: string;
summary: string;
selectedTechniques: AutotradeTechniqueId[];
confidenceThreshold: number;
maxDailyOrders: number;
cooldownSec: number;
maxOrderAmountRatio: number;
createdAt: string;
}
export interface AutotradeValidationResult {
isValid: boolean;
blockedReasons: string[];
warnings: string[];
cashBalance: number;
effectiveAllocationAmount: number;
effectiveDailyLossLimit: number;
}
export interface AutotradeMinuteCandle {
time: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
timestamp?: number;
}
export interface AutotradeMinutePatternContext {
timeframe: "1m";
candleCount: number;
impulseDirection: "up" | "down" | "flat";
impulseBarCount: number;
consolidationBarCount: number;
impulseChangeRate?: number;
impulseRangePercent?: number;
consolidationRangePercent?: number;
consolidationCloseClusterPercent?: number;
consolidationVolumeRatio?: number;
breakoutUpper?: number;
breakoutLower?: number;
}
export interface AutotradeBudgetContext {
setupAllocationPercent: number;
setupAllocationAmount: number;
effectiveAllocationAmount: number;
strategyMaxOrderAmountRatio: number;
effectiveOrderBudgetAmount: number;
estimatedBuyUnitCost: number;
estimatedBuyableQuantity: number;
}
export interface AutotradePortfolioContext {
holdingQuantity: number;
sellableQuantity: number;
averagePrice: number;
estimatedSellableNetAmount?: number;
}
export interface AutotradeExecutionCostProfileSnapshot {
buyFeeRate: number;
sellFeeRate: number;
sellTaxRate: number;
}
export interface AutotradeSessionInfo {
sessionId: string;
symbol: string;
runtimeState: "RUNNING" | "STOPPED";
leaderTabId: string;
startedAt: string;
lastHeartbeatAt: string;
endedAt: string | null;
stopReason: AutotradeStopReason | null;
effectiveAllocationAmount: number;
effectiveDailyLossLimit: number;
}
export interface AutotradeMarketSnapshot {
symbol: string;
stockName?: string;
market?: "KOSPI" | "KOSDAQ";
requestAtIso?: string;
requestAtKst?: string;
tickTime?: string;
executionClassCode?: string;
isExpected?: boolean;
trId?: string;
currentPrice: number;
prevClose?: number;
changeRate: number;
open: number;
high: number;
low: number;
tradeVolume: number;
accumulatedVolume: number;
tradeStrength?: number;
askPrice1?: number;
bidPrice1?: number;
askSize1?: number;
bidSize1?: number;
totalAskSize?: number;
totalBidSize?: number;
buyExecutionCount?: number;
sellExecutionCount?: number;
netBuyExecutionCount?: number;
spread?: number;
spreadRate?: number;
dayRangePercent?: number;
dayRangePosition?: number;
volumeRatio?: number;
recentTradeCount?: number;
recentTradeVolumeSum?: number;
recentAverageTradeVolume?: number;
accumulatedVolumeDelta?: number;
netBuyExecutionDelta?: number;
orderBookImbalance?: number;
liquidityDepth?: number;
topLevelOrderBookImbalance?: number;
buySellExecutionRatio?: number;
recentPriceHigh?: number;
recentPriceLow?: number;
recentPriceRangePercent?: number;
recentTradeVolumes?: number[];
recentNetBuyTrail?: number[];
recentTickAgesSec?: number[];
intradayMomentum?: number;
recentReturns?: number[];
recentPrices: number[];
marketDataLatencySec?: number;
recentMinuteCandles?: AutotradeMinuteCandle[];
minutePatternContext?: AutotradeMinutePatternContext;
budgetContext?: AutotradeBudgetContext;
portfolioContext?: AutotradePortfolioContext;
executionCostProfile?: AutotradeExecutionCostProfileSnapshot;
}
export interface AutotradeProposedOrder {
symbol: string;
side: "buy" | "sell";
orderType: "limit" | "market";
price?: number;
quantity?: number;
}
export interface AutotradeSignalCandidate {
signal: "buy" | "sell" | "hold";
confidence: number;
reason: string;
ttlSec: number;
riskFlags: string[];
proposedOrder?: AutotradeProposedOrder;
source: "openai" | "fallback" | "subscription_cli";
providerVendor?: "codex" | "gemini";
providerModel?: string;
}
export interface AutotradeRuntimeLog {
id: string;
level: "info" | "warning" | "error";
stage?:
| "session"
| "strategy_compile"
| "strategy_validate"
| "signal_request"
| "signal_response"
| "risk_gate"
| "order_execution"
| "order_blocked"
| "provider_fallback"
| "engine_error";
message: string;
detail?: string;
createdAt: string;
}
export interface AutotradeCompileResponse {
ok: boolean;
compiledStrategy: AutotradeCompiledStrategy;
}
export interface AutotradeValidateResponse {
ok: boolean;
validation: AutotradeValidationResult;
}
export interface AutotradeSessionResponse {
ok: boolean;
session: AutotradeSessionInfo;
}
export interface AutotradeSignalResponse {
ok: boolean;
signal: AutotradeSignalCandidate;
}

View File

@@ -8,6 +8,7 @@ import type {
DashboardActivityResponse, DashboardActivityResponse,
DashboardBalanceResponse, DashboardBalanceResponse,
DashboardIndicesResponse, DashboardIndicesResponse,
DashboardMarketHubResponse,
} from "@/features/dashboard/types/dashboard.types"; } from "@/features/dashboard/types/dashboard.types";
/** /**
@@ -92,3 +93,31 @@ export async function fetchDashboardActivity(
return payload as DashboardActivityResponse; return payload as DashboardActivityResponse;
} }
/**
* 급등/인기/뉴스 시장 허브 데이터를 조회합니다.
* @param credentials KIS 인증 정보
* @returns 시장 허브 응답
* @see app/api/kis/domestic/market-hub/route.ts 서버 라우트
*/
export async function fetchDashboardMarketHub(
credentials: KisRuntimeCredentials,
): Promise<DashboardMarketHubResponse> {
const response = await fetch("/api/kis/domestic/market-hub", {
method: "GET",
headers: buildKisRequestHeaders(credentials),
cache: "no-store",
});
const payload = (await response.json()) as
| DashboardMarketHubResponse
| KisApiErrorPayload;
if (!response.ok) {
throw new Error(
resolveKisApiErrorMessage(payload, "시장 허브 조회 중 오류가 발생했습니다."),
);
}
return payload as DashboardMarketHubResponse;
}

View File

@@ -46,15 +46,15 @@ export function ActivitySection({
const warnings = activity?.warnings ?? []; const warnings = activity?.warnings ?? [];
return ( return (
<Card> <Card className="border-brand-200/70 bg-background/90 shadow-sm dark:border-brand-800/45">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
{/* ========== TITLE ========== */} {/* ========== TITLE ========== */}
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<ClipboardList className="h-4 w-4 text-brand-600 dark:text-brand-400" /> <ClipboardList className="h-4 w-4 text-brand-600 dark:text-brand-400" />
· · (/)
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
. / .
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@@ -106,9 +106,19 @@ export function ActivitySection({
{/* ========== TABS ========== */} {/* ========== TABS ========== */}
<Tabs defaultValue="orders" className="gap-3"> <Tabs defaultValue="orders" className="gap-3">
<TabsList className="w-full justify-start"> <TabsList className="h-auto w-full justify-start rounded-xl border border-brand-200/70 bg-background/80 p-1 dark:border-brand-800/50 dark:bg-background/60">
<TabsTrigger value="orders"> {orders.length}</TabsTrigger> <TabsTrigger
<TabsTrigger value="journal"> {journalRows.length}</TabsTrigger> value="orders"
className="h-9 rounded-lg px-3 data-[state=active]:bg-brand-600 data-[state=active]:text-white"
>
{orders.length}
</TabsTrigger>
<TabsTrigger
value="journal"
className="h-9 rounded-lg px-3 data-[state=active]:bg-brand-600 data-[state=active]:text-white"
>
{journalRows.length}
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="orders"> <TabsContent value="orders">

View File

@@ -1,12 +1,15 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo } from "react";
import { BriefcaseBusiness, Gauge, Sparkles } from "lucide-react";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ActivitySection } from "@/features/dashboard/components/ActivitySection"; import { ActivitySection } from "@/features/dashboard/components/ActivitySection";
import { DashboardAccessGate } from "@/features/dashboard/components/DashboardAccessGate"; import { DashboardAccessGate } from "@/features/dashboard/components/DashboardAccessGate";
import { DashboardSkeleton } from "@/features/dashboard/components/DashboardSkeleton"; import { DashboardSkeleton } from "@/features/dashboard/components/DashboardSkeleton";
import { HoldingsList } from "@/features/dashboard/components/HoldingsList"; import { HoldingsList } from "@/features/dashboard/components/HoldingsList";
import { MarketHubSection } from "@/features/dashboard/components/MarketHubSection";
import { MarketSummary } from "@/features/dashboard/components/MarketSummary"; import { MarketSummary } from "@/features/dashboard/components/MarketSummary";
import { StatusHeader } from "@/features/dashboard/components/StatusHeader"; import { StatusHeader } from "@/features/dashboard/components/StatusHeader";
import { StockDetailPreview } from "@/features/dashboard/components/StockDetailPreview"; import { StockDetailPreview } from "@/features/dashboard/components/StockDetailPreview";
@@ -70,6 +73,8 @@ export function DashboardContainer() {
activityError, activityError,
balanceError, balanceError,
indicesError, indicesError,
marketHub,
marketHubError,
lastUpdatedAt, lastUpdatedAt,
refresh, refresh,
} = useDashboardData(canAccess ? verifiedCredentials : null); } = useDashboardData(canAccess ? verifiedCredentials : null);
@@ -125,6 +130,15 @@ export function DashboardContainer() {
wsApprovalKey && wsUrl && isWsConnected && !hasRealtimeStreaming, wsApprovalKey && wsUrl && isWsConnected && !hasRealtimeStreaming,
); );
const effectiveIndicesError = indices.length === 0 ? indicesError : null; const effectiveIndicesError = indices.length === 0 ? indicesError : null;
const restStatusLabel = isKisRestConnected ? "REST 정상" : "REST 점검 필요";
const realtimeStatusLabel = isWsConnected
? isRealtimePending
? "실시간 대기중"
: "실시간 수신중"
: "실시간 미연결";
const profileStatusLabel = isKisProfileVerified
? "계좌 인증 완료"
: "계좌 인증 필요";
const indicesWarning = const indicesWarning =
indices.length > 0 && indicesError indices.length > 0 && indicesError
? "지수 API 재요청 중입니다. 화면 값은 실시간/최근 성공 데이터입니다." ? "지수 API 재요청 중입니다. 화면 값은 실시간/최근 성공 데이터입니다."
@@ -181,71 +195,161 @@ export function DashboardContainer() {
} }
return ( return (
<section className="mx-auto flex w-full max-w-7xl flex-col gap-4 p-4 md:p-6"> <section className="relative mx-auto flex w-full max-w-7xl flex-col gap-4 overflow-hidden p-4 md:p-6">
{/* ========== 상단 상태 영역: 계좌 연결 정보 및 새로고침 ========== */} <div className="pointer-events-none absolute -left-24 top-10 h-52 w-52 rounded-full bg-brand-400/15 blur-3xl dark:bg-brand-600/15" />
<StatusHeader <div className="pointer-events-none absolute -right-28 top-36 h-64 w-64 rounded-full bg-brand-300/20 blur-3xl dark:bg-brand-700/20" />
summary={mergedSummary}
isKisRestConnected={isKisRestConnected}
isWebSocketReady={Boolean(wsApprovalKey && wsUrl && isWsConnected)}
isRealtimePending={isRealtimePending}
isProfileVerified={isKisProfileVerified}
verifiedAccountNo={verifiedAccountNo}
isRefreshing={isRefreshing}
lastUpdatedAt={lastUpdatedAt}
onRefresh={() => {
void handleRefreshAll();
}}
/>
{/* ========== 메인 그리드 구성 ========== */} <div className="relative rounded-3xl border border-brand-200/70 bg-linear-to-br from-brand-100/70 via-brand-50/30 to-background p-4 shadow-sm dark:border-brand-800/50 dark:from-brand-900/35 dark:via-brand-950/15">
<div className="grid gap-4 lg:grid-cols-[1.15fr_1fr]"> <div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
{/* 왼쪽 섹션: 보유 종목 목록 리스트 */} <div className="space-y-2">
<HoldingsList <p className="inline-flex items-center gap-1.5 rounded-full border border-brand-300/70 bg-background/80 px-3 py-1 text-[11px] font-semibold tracking-wide text-brand-700 dark:border-brand-700 dark:bg-brand-950/50 dark:text-brand-300">
holdings={mergedHoldings} <Sparkles className="h-3.5 w-3.5" />
selectedSymbol={selectedSymbol} TRADING OVERVIEW
isLoading={isLoading} </p>
error={balanceError} <h1 className="text-xl font-bold tracking-tight text-foreground md:text-2xl">
onRetry={() => {
void handleRefreshAll(); </h1>
}} <p className="text-sm text-muted-foreground">
onSelect={setSelectedSymbol} , .
/> </p>
</div>
{/* 오른쪽 섹션: 시장 지수 요약 및 선택 종목 상세 정보 */} <div className="grid gap-2 sm:grid-cols-3">
<div className="grid gap-4"> <TopStatusPill label="서버" value={restStatusLabel} ok={isKisRestConnected} />
{/* 시장 지수 현황 (코스피/코스닥) */} <TopStatusPill
<MarketSummary label="실시간"
items={indices} value={realtimeStatusLabel}
isLoading={isLoading} ok={isWsConnected}
error={effectiveIndicesError} warn={isRealtimePending}
warning={indicesWarning} />
<TopStatusPill
label="계좌"
value={profileStatusLabel}
ok={isKisProfileVerified}
/>
</div>
</div>
</div>
<Tabs defaultValue="assets" className="relative gap-4">
<TabsList className="h-auto w-full justify-start rounded-2xl border border-brand-200/80 bg-background/90 p-1 backdrop-blur-sm dark:border-brand-800/50 dark:bg-background/60">
<TabsTrigger
value="market"
className="h-10 rounded-xl px-4 data-[state=active]:bg-brand-600 data-[state=active]:text-white data-[state=active]:shadow-md dark:data-[state=active]:bg-brand-600"
>
<Gauge className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger
value="assets"
className="h-10 rounded-xl px-4 data-[state=active]:bg-brand-600 data-[state=active]:text-white data-[state=active]:shadow-md dark:data-[state=active]:bg-brand-600"
>
<BriefcaseBusiness className="h-4 w-4" />
</TabsTrigger>
</TabsList>
<TabsContent value="market" className="space-y-4 animate-in fade-in-0 slide-in-from-bottom-2 duration-300">
<div className="grid gap-4 xl:grid-cols-[1.05fr_1.45fr]">
<MarketSummary
items={indices}
isLoading={isLoading}
error={effectiveIndicesError}
warning={indicesWarning}
isWebSocketReady={isWsConnected}
isRealtimePending={isRealtimePending}
onRetry={() => {
void handleRefreshAll();
}}
/>
<MarketHubSection
marketHub={marketHub}
isLoading={isLoading}
error={marketHubError}
onRetry={() => {
void handleRefreshAll();
}}
/>
</div>
</TabsContent>
<TabsContent value="assets" className="space-y-4 animate-in fade-in-0 slide-in-from-bottom-2 duration-300">
<StatusHeader
summary={mergedSummary}
isKisRestConnected={isKisRestConnected}
isWebSocketReady={Boolean(wsApprovalKey && wsUrl && isWsConnected)}
isRealtimePending={isRealtimePending} isRealtimePending={isRealtimePending}
onRetry={() => { isProfileVerified={isKisProfileVerified}
verifiedAccountNo={verifiedAccountNo}
isRefreshing={isRefreshing}
lastUpdatedAt={lastUpdatedAt}
onRefresh={() => {
void handleRefreshAll(); void handleRefreshAll();
}} }}
/> />
{/* 선택된 종목의 실시간 상세 요약 정보 */} <div className="grid gap-4 xl:grid-cols-[1.25fr_0.75fr]">
<StockDetailPreview <div className="space-y-4">
holding={realtimeSelectedHolding} <ActivitySection
totalAmount={mergedSummary?.totalAmount ?? 0} activity={activity}
/> isLoading={isLoading}
</div> error={activityError}
</div> onRetry={() => {
void handleRefreshAll();
}}
/>
{/* ========== 하단 섹션: 최근 매매/충전 활동 내역 ========== */} <HoldingsList
<ActivitySection holdings={mergedHoldings}
activity={activity} selectedSymbol={selectedSymbol}
isLoading={isLoading} isLoading={isLoading}
error={activityError} error={balanceError}
onRetry={() => { onRetry={() => {
void handleRefreshAll(); void handleRefreshAll();
}} }}
/> onSelect={setSelectedSymbol}
/>
</div>
<div className="xl:sticky xl:top-5 xl:self-start">
<StockDetailPreview
holding={realtimeSelectedHolding}
totalAmount={mergedSummary?.totalAmount ?? 0}
/>
</div>
</div>
</TabsContent>
</Tabs>
</section> </section>
); );
} }
function TopStatusPill({
label,
value,
ok,
warn = false,
}: {
label: string;
value: string;
ok: boolean;
warn?: boolean;
}) {
const toneClass = ok
? warn
? "border-amber-300/70 bg-amber-50/70 text-amber-700 dark:border-amber-700/50 dark:bg-amber-950/30 dark:text-amber-300"
: "border-emerald-300/70 bg-emerald-50/70 text-emerald-700 dark:border-emerald-700/50 dark:bg-emerald-950/30 dark:text-emerald-300"
: "border-red-300/70 bg-red-50/70 text-red-700 dark:border-red-700/50 dark:bg-red-950/30 dark:text-red-300";
return (
<div className={`rounded-xl border px-3 py-2 ${toneClass}`}>
<p className="text-[11px] font-medium opacity-80">{label}</p>
<p className="text-xs font-semibold">{value}</p>
</div>
);
}
/** /**
* @description REST 지수 데이터가 없을 때 실시간 수신값만으로 코스피/코스닥 표시 데이터를 구성합니다. * @description REST 지수 데이터가 없을 때 실시간 수신값만으로 코스피/코스닥 표시 데이터를 구성합니다.
* @param realtimeIndices 실시간 지수 맵 * @param realtimeIndices 실시간 지수 맵

View File

@@ -10,6 +10,7 @@
*/ */
import { AlertCircle, Wallet2 } from "lucide-react"; import { AlertCircle, Wallet2 } from "lucide-react";
import { RefreshCcw } from "lucide-react"; import { RefreshCcw } from "lucide-react";
import { useRouter } from "next/navigation";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -25,6 +26,7 @@ import {
formatPercent, formatPercent,
getChangeToneClass, getChangeToneClass,
} from "@/features/dashboard/utils/dashboard-format"; } from "@/features/dashboard/utils/dashboard-format";
import { useTradeNavigationStore } from "@/features/trade/store/use-trade-navigation-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash"; import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash";
@@ -59,8 +61,22 @@ export function HoldingsList({
onRetry, onRetry,
onSelect, onSelect,
}: HoldingsListProps) { }: HoldingsListProps) {
const router = useRouter();
const setPendingTarget = useTradeNavigationStore(
(state) => state.setPendingTarget,
);
const handleNavigateToTrade = (holding: DashboardHoldingItem) => {
setPendingTarget({
symbol: holding.symbol,
name: holding.name,
market: holding.market,
});
router.push("/trade");
};
return ( return (
<Card className="h-full"> <Card className="h-full border-brand-200/70 bg-background/90 shadow-sm dark:border-brand-800/45">
{/* ========== 카드 헤더: 타이틀 및 설명 ========== */} {/* ========== 카드 헤더: 타이틀 및 설명 ========== */}
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
@@ -113,7 +129,7 @@ export function HoldingsList({
{/* 종목 리스트 렌더링 영역 */} {/* 종목 리스트 렌더링 영역 */}
{holdings.length > 0 && ( {holdings.length > 0 && (
<ScrollArea className="h-[420px] pr-3"> <ScrollArea className="h-[360px] pr-3">
<div className="space-y-2"> <div className="space-y-2">
{holdings.map((holding) => ( {holdings.map((holding) => (
<HoldingItemRow <HoldingItemRow
@@ -121,6 +137,7 @@ export function HoldingsList({
holding={holding} holding={holding}
isSelected={selectedSymbol === holding.symbol} isSelected={selectedSymbol === holding.symbol}
onSelect={onSelect} onSelect={onSelect}
onNavigateToTrade={handleNavigateToTrade}
/> />
))} ))}
</div> </div>
@@ -138,6 +155,8 @@ interface HoldingItemRowProps {
isSelected: boolean; isSelected: boolean;
/** 클릭 핸들러 */ /** 클릭 핸들러 */
onSelect: (symbol: string) => void; onSelect: (symbol: string) => void;
/** 거래 페이지 이동 핸들러 */
onNavigateToTrade: (holding: DashboardHoldingItem) => void;
} }
/** /**
@@ -152,6 +171,7 @@ function HoldingItemRow({
holding, holding,
isSelected, isSelected,
onSelect, onSelect,
onNavigateToTrade,
}: HoldingItemRowProps) { }: HoldingItemRowProps) {
// [State/Hook] 현재가 기반 가격 반짝임 효과 상태 관리 // [State/Hook] 현재가 기반 가격 반짝임 효과 상태 관리
// @see use-price-flash.ts - 현재가 변경 감지 시 symbol을 기준으로 이펙트 발생 여부 결정 // @see use-price-flash.ts - 현재가 변경 감지 시 symbol을 기준으로 이펙트 발생 여부 결정
@@ -163,13 +183,16 @@ function HoldingItemRow({
return ( return (
<button <button
type="button" type="button"
// [Step 1] 종목 클릭 시 부모의 선택 핸들러 호출 // [Step 1] 종목 클릭 시 선택 상태 갱신 후 거래 화면으로 이동
onClick={() => onSelect(holding.symbol)} onClick={() => {
onSelect(holding.symbol);
onNavigateToTrade(holding);
}}
className={cn( className={cn(
"w-full rounded-xl border px-3 py-3 text-left transition-all relative overflow-hidden", "relative w-full overflow-hidden rounded-xl border px-3 py-3 text-left shadow-sm transition-all",
isSelected isSelected
? "border-brand-400 bg-brand-50/60 ring-1 ring-brand-300 dark:border-brand-600 dark:bg-brand-900/20 dark:ring-brand-700" ? "border-brand-400 bg-brand-50/60 ring-1 ring-brand-300 dark:border-brand-600 dark:bg-brand-900/20 dark:ring-brand-700"
: "border-border/70 bg-background hover:border-brand-200 hover:bg-brand-50/30 dark:hover:border-brand-700 dark:hover:bg-brand-900/15", : "border-border/70 bg-background hover:-translate-y-0.5 hover:border-brand-200 hover:bg-brand-50/30 hover:shadow-md dark:hover:border-brand-700 dark:hover:bg-brand-900/15",
)} )}
> >
{/* ========== 행 상단: 종목명, 심볼 및 현재가 정보 ========== */} {/* ========== 행 상단: 종목명, 심볼 및 현재가 정보 ========== */}
@@ -180,7 +203,8 @@ function HoldingItemRow({
{holding.name} {holding.name}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{holding.symbol} · {holding.market} · {holding.quantity} {holding.symbol} · {holding.market} · {holding.quantity} · {" "}
{holding.sellableQuantity}
</p> </p>
</div> </div>

View File

@@ -0,0 +1,348 @@
import {
AlertCircle,
BarChart3,
Flame,
Newspaper,
RefreshCcw,
TrendingDown,
TrendingUp,
} from "lucide-react";
import type { ComponentType } from "react";
import { useRouter } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import type {
DashboardMarketHubResponse,
DashboardMarketRankItem,
} from "@/features/dashboard/types/dashboard.types";
import {
formatCurrency,
formatSignedCurrency,
formatSignedPercent,
getChangeToneClass,
} from "@/features/dashboard/utils/dashboard-format";
import { useTradeNavigationStore } from "@/features/trade/store/use-trade-navigation-store";
import { cn } from "@/lib/utils";
interface MarketHubSectionProps {
marketHub: DashboardMarketHubResponse | null;
isLoading: boolean;
error: string | null;
onRetry?: () => void;
}
/**
* @description 시장 탭의 급등/인기/뉴스 요약 섹션입니다.
* @remarks UI 흐름: DashboardContainer -> MarketHubSection -> 급등/인기/뉴스 카드 렌더링
*/
export function MarketHubSection({
marketHub,
isLoading,
error,
onRetry,
}: MarketHubSectionProps) {
const router = useRouter();
const setPendingTarget = useTradeNavigationStore(
(state) => state.setPendingTarget,
);
const gainers = marketHub?.gainers ?? [];
const losers = marketHub?.losers ?? [];
const popularByVolume = marketHub?.popularByVolume ?? [];
const popularByValue = marketHub?.popularByValue ?? [];
const news = marketHub?.news ?? [];
const warnings = marketHub?.warnings ?? [];
const pulse = marketHub?.pulse;
const navigateToTrade = (item: DashboardMarketRankItem) => {
setPendingTarget({
symbol: item.symbol,
name: item.name,
market: item.market,
});
router.push("/trade");
};
return (
<div className="grid gap-3">
<Card className="border-brand-200/80 bg-linear-to-br from-brand-100/60 via-brand-50/20 to-background shadow-sm dark:border-brand-800/45 dark:from-brand-900/30 dark:via-brand-950/20">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base text-brand-700 dark:text-brand-300">
<TrendingUp className="h-4 w-4" />
</CardTitle>
<CardDescription>
/, , .
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
<PulseMetric label="급등주" value={`${pulse?.gainersCount ?? 0}`} tone="up" />
<PulseMetric label="급락주" value={`${pulse?.losersCount ?? 0}`} tone="down" />
<PulseMetric
label="인기종목(거래량)"
value={`${pulse?.popularByVolumeCount ?? 0}`}
tone="neutral"
/>
<PulseMetric
label="거래대금 상위"
value={`${pulse?.popularByValueCount ?? 0}`}
tone="neutral"
/>
<PulseMetric label="주요 뉴스" value={`${pulse?.newsCount ?? 0}`} tone="brand" />
</div>
{warnings.length > 0 && (
<div className="flex flex-wrap gap-2">
{warnings.map((warning) => (
<Badge
key={warning}
variant="outline"
className="border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-300"
>
<AlertCircle className="h-3 w-3" />
{warning}
</Badge>
))}
</div>
)}
{isLoading && !marketHub && (
<p className="text-sm text-muted-foreground"> .</p>
)}
{error && (
<div className="rounded-md border border-red-200 bg-red-50/60 p-2.5 dark:border-red-900/40 dark:bg-red-950/20">
<p className="flex items-start gap-1.5 text-sm text-red-600 dark:text-red-400">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</p>
{onRetry ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={onRetry}
className="mt-2 border-red-300 text-red-700 hover:bg-red-100 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/40"
>
<RefreshCcw className="mr-1.5 h-3.5 w-3.5" />
</Button>
) : null}
</div>
)}
</CardContent>
</Card>
<div className="grid gap-3 md:grid-cols-2">
<RankListCard
title="급등주식"
description="전일 대비 상승률 상위 종목"
icon={Flame}
items={gainers}
tone="up"
onSelectItem={navigateToTrade}
secondaryLabel="거래량"
secondaryValue={(item) => `${formatCurrency(item.volume)}`}
/>
<RankListCard
title="급락주식"
description="전일 대비 하락률 상위 종목"
icon={TrendingDown}
items={losers}
tone="down"
onSelectItem={navigateToTrade}
secondaryLabel="거래량"
secondaryValue={(item) => `${formatCurrency(item.volume)}`}
/>
<RankListCard
title="인기종목"
description="거래량 상위 종목"
icon={BarChart3}
items={popularByVolume}
tone="neutral"
onSelectItem={navigateToTrade}
secondaryLabel="거래량"
secondaryValue={(item) => `${formatCurrency(item.volume)}`}
/>
<RankListCard
title="거래대금 상위"
description="거래대금 상위 종목"
icon={TrendingUp}
items={popularByValue}
tone="brand"
onSelectItem={navigateToTrade}
secondaryLabel="거래대금"
secondaryValue={(item) => `${formatCurrency(item.tradingValue)}`}
/>
</div>
<Card className="border-brand-200/70 bg-background/90 dark:border-brand-800/45">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Newspaper className="h-4 w-4 text-brand-600 dark:text-brand-400" />
</CardTitle>
<CardDescription>
/ .
</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[220px] pr-3">
{news.length === 0 ? (
<p className="text-sm text-muted-foreground"> .</p>
) : (
<div className="space-y-2">
{news.map((item) => (
<article
key={item.id}
className="rounded-xl border border-border/70 bg-linear-to-br from-background to-brand-50/30 px-3 py-2 dark:from-background dark:to-brand-950/20"
>
<p className="text-sm font-medium text-foreground">{item.title}</p>
<p className="mt-1 text-xs text-muted-foreground">
{item.source} · {item.publishedAt}
</p>
{item.symbols.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{item.symbols.slice(0, 3).map((symbol) => (
<Badge
key={`${item.id}-${symbol}`}
variant="outline"
className="text-[10px]"
>
{symbol}
</Badge>
))}
</div>
)}
</article>
))}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
</div>
);
}
function PulseMetric({
label,
value,
tone,
}: {
label: string;
value: string;
tone: "up" | "down" | "neutral" | "brand";
}) {
const toneClass =
tone === "up"
? "border-red-200/70 bg-red-50/70 dark:border-red-900/40 dark:bg-red-950/20"
: tone === "down"
? "border-blue-200/70 bg-blue-50/70 dark:border-blue-900/40 dark:bg-blue-950/20"
: tone === "brand"
? "border-brand-200/70 bg-brand-50/70 dark:border-brand-700/60 dark:bg-brand-900/30"
: "border-border/70 bg-background/80";
return (
<div className={cn("rounded-xl border p-3", toneClass)}>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-sm font-semibold text-foreground">{value}</p>
</div>
);
}
function RankListCard({
title,
description,
icon: Icon,
items,
tone,
onSelectItem,
secondaryLabel,
secondaryValue,
}: {
title: string;
description: string;
icon: ComponentType<{ className?: string }>;
items: DashboardMarketRankItem[];
tone: "up" | "down" | "neutral" | "brand";
onSelectItem: (item: DashboardMarketRankItem) => void;
secondaryLabel: string;
secondaryValue: (item: DashboardMarketRankItem) => string;
}) {
const toneClass =
tone === "up"
? "border-red-200/70 bg-red-50/35 dark:border-red-900/35 dark:bg-red-950/15"
: tone === "down"
? "border-blue-200/70 bg-blue-50/35 dark:border-blue-900/35 dark:bg-blue-950/15"
: tone === "brand"
? "border-brand-200/70 bg-brand-50/35 dark:border-brand-800/50 dark:bg-brand-900/20"
: "border-border/70 bg-background/90";
return (
<Card className={cn("overflow-hidden shadow-sm", toneClass)}>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Icon className="h-4 w-4 text-brand-600 dark:text-brand-400" />
{title}
</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[220px] pr-3">
{items.length === 0 ? (
<p className="text-sm text-muted-foreground"> .</p>
) : (
<div className="space-y-2">
{items.map((item) => {
const toneClass = getChangeToneClass(item.change);
return (
<div
key={`${title}-${item.symbol}-${item.rank}`}
className="rounded-xl border border-border/70 bg-background/80 px-3 py-2 shadow-sm"
>
<button
type="button"
onClick={() => onSelectItem(item)}
className="w-full text-left hover:opacity-90"
title={`${item.name} 거래 화면으로 이동`}
>
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-semibold text-foreground">{item.name}</p>
<p className={cn("text-xs font-medium", toneClass)}>
{formatSignedPercent(item.changeRate)}
</p>
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
#{item.rank} · {item.symbol} · {item.market}
</p>
<div className="mt-1 flex items-center justify-between gap-2 text-xs">
<span className="text-muted-foreground">
{formatCurrency(item.price)}
</span>
<span className={cn("font-medium", toneClass)}>
{formatSignedCurrency(item.change)}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{secondaryLabel} {secondaryValue(item)}
</p>
</button>
</div>
);
})}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
);
}

View File

@@ -1,5 +1,6 @@
import { BarChart3, TrendingDown, TrendingUp } from "lucide-react"; import { BarChart3, TrendingDown, TrendingUp } from "lucide-react";
import { RefreshCcw } from "lucide-react"; import { RefreshCcw } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { import {
Card, Card,
CardContent, CardContent,
@@ -22,6 +23,7 @@ interface MarketSummaryProps {
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
warning?: string | null; warning?: string | null;
isWebSocketReady?: boolean;
isRealtimePending?: boolean; isRealtimePending?: boolean;
onRetry?: () => void; onRetry?: () => void;
} }
@@ -35,22 +37,46 @@ export function MarketSummary({
isLoading, isLoading,
error, error,
warning = null, warning = null,
isWebSocketReady = false,
isRealtimePending = false, isRealtimePending = false,
onRetry, onRetry,
}: MarketSummaryProps) { }: MarketSummaryProps) {
const realtimeBadgeText = isRealtimePending
? "실시간 대기중"
: isWebSocketReady
? "실시간 수신중"
: items.length > 0
? "REST 데이터"
: "데이터 준비중";
return ( return (
<Card className="overflow-hidden border-brand-200/80 bg-linear-to-br from-brand-50/50 to-background dark:border-brand-800/45 dark:from-brand-950/20 dark:to-background"> <Card className="overflow-hidden border-brand-200/80 bg-linear-to-br from-brand-100/65 via-brand-50/30 to-background shadow-sm dark:border-brand-800/45 dark:from-brand-900/35 dark:via-brand-950/20 dark:to-background">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base text-brand-700 dark:text-brand-300"> <CardTitle className="flex items-center gap-2 text-base text-brand-700 dark:text-brand-300">
<BarChart3 className="h-4 w-4" /> <BarChart3 className="h-4 w-4" />
</CardTitle> </CardTitle>
<Badge
variant="outline"
className={cn(
"border-brand-300/70 bg-background/80 text-[11px] font-medium dark:border-brand-700/60 dark:bg-brand-950/30",
isRealtimePending
? "text-amber-700 dark:text-amber-300"
: isWebSocketReady
? "text-emerald-700 dark:text-emerald-300"
: "text-muted-foreground",
)}
>
{realtimeBadgeText}
</Badge>
</div> </div>
<CardDescription> / .</CardDescription> <CardDescription>
/ .
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"> <CardContent className="grid gap-3 sm:grid-cols-2">
{/* ========== LOADING STATE ========== */} {/* ========== LOADING STATE ========== */}
{isLoading && items.length === 0 && ( {isLoading && items.length === 0 && (
<div className="col-span-full py-4 text-center text-sm text-muted-foreground animate-pulse"> <div className="col-span-full py-4 text-center text-sm text-muted-foreground animate-pulse">
@@ -133,23 +159,23 @@ function IndexItem({ item }: { item: DashboardMarketIndexItem }) {
: "text-muted-foreground"; : "text-muted-foreground";
const bgClass = isUp const bgClass = isUp
? "bg-red-50/50 dark:bg-red-950/10 border-red-100 dark:border-red-900/30" ? "bg-linear-to-br from-red-50/90 to-background dark:from-red-950/20 dark:to-background border-red-100/80 dark:border-red-900/40"
: isDown : isDown
? "bg-blue-50/50 dark:bg-blue-950/10 border-blue-100 dark:border-blue-900/30" ? "bg-linear-to-br from-blue-50/90 to-background dark:from-blue-950/20 dark:to-background border-blue-100/80 dark:border-blue-900/40"
: "bg-muted/50 border-border/50"; : "bg-linear-to-br from-muted/60 to-background border-border/50";
const flash = usePriceFlash(item.price, item.code); const flash = usePriceFlash(item.price, item.code);
return ( return (
<div <div
className={cn( className={cn(
"relative flex flex-col justify-between rounded-xl border p-4 transition-all hover:bg-background/80", "relative flex flex-col justify-between rounded-2xl border p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md",
bgClass, bgClass,
)} )}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground"> <span className="text-sm font-medium text-muted-foreground">
{item.market} {item.name} ({item.market})
</span> </span>
{isUp ? ( {isUp ? (
<TrendingUp className="h-4 w-4 text-red-500/70" /> <TrendingUp className="h-4 w-4 text-red-500/70" />
@@ -158,7 +184,7 @@ function IndexItem({ item }: { item: DashboardMarketIndexItem }) {
) : null} ) : null}
</div> </div>
<div className="mt-2 text-2xl font-bold tracking-tight relative w-fit"> <div className="relative mt-2 w-fit text-2xl font-bold tracking-tight">
{formatCurrency(item.price)} {formatCurrency(item.price)}
{/* Flash Indicator */} {/* Flash Indicator */}
@@ -176,14 +202,9 @@ function IndexItem({ item }: { item: DashboardMarketIndexItem }) {
)} )}
</div> </div>
<div <div className={cn("mt-2 flex items-center gap-2 text-sm font-medium", toneClass)}>
className={cn(
"mt-1 flex items-center gap-2 text-sm font-medium",
toneClass,
)}
>
<span>{formatSignedCurrency(item.change)}</span> <span>{formatSignedCurrency(item.change)}</span>
<span className="rounded-md bg-background/50 px-1.5 py-0.5 text-xs shadow-sm"> <span className="rounded-md bg-background/70 px-1.5 py-0.5 text-xs shadow-sm">
{formatSignedPercent(item.changeRate)} {formatSignedPercent(item.changeRate)}
</span> </span>
</div> </div>

View File

@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import type { ReactNode } from "react";
import { Activity, RefreshCcw, Settings2, Wifi } from "lucide-react"; import { Activity, RefreshCcw, Settings2, Wifi } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
@@ -58,153 +59,155 @@ export function StatusHeader({
? "수신 대기중" ? "수신 대기중"
: "연결됨" : "연결됨"
: "미연결"; : "미연결";
const displayGrossTotalAmount = hasApiTotalAmount
? summary?.apiReportedTotalAmount ?? 0
: summary?.totalAmount ?? 0;
return ( return (
<Card className="relative overflow-hidden border-brand-200 shadow-sm dark:border-brand-800/50"> <Card className="relative overflow-hidden border-brand-200/80 bg-linear-to-br from-brand-50/80 via-background to-brand-50/20 shadow-sm dark:border-brand-800/45 dark:from-brand-900/25 dark:via-background dark:to-background">
{/* ========== BACKGROUND DECORATION ========== */} <div className="pointer-events-none absolute -right-14 -top-14 h-52 w-52 rounded-full bg-brand-300/30 blur-3xl dark:bg-brand-700/20" />
<div className="pointer-events-none absolute inset-x-0 top-0 h-20 bg-linear-to-r from-brand-100/70 via-brand-50/50 to-transparent dark:from-brand-900/35 dark:via-brand-900/20" /> <div className="pointer-events-none absolute -left-16 bottom-0 h-44 w-44 rounded-full bg-brand-200/25 blur-3xl dark:bg-brand-800/20" />
<CardContent className="relative grid gap-3 p-4 md:grid-cols-[1fr_1fr_1fr_auto]"> <CardContent className="relative space-y-3 p-4 md:p-5">
{/* ========== TOTAL ASSET ========== */} <div className="grid gap-3 xl:grid-cols-[1fr_1fr_auto]">
<div className="rounded-xl border border-border/70 bg-background/90 px-4 py-3"> <div className="rounded-2xl border border-brand-200/70 bg-background/85 p-4 shadow-sm dark:border-brand-800/60 dark:bg-brand-950/20">
<p className="text-xs font-medium text-muted-foreground"> ( )</p> <p className="text-xs font-semibold tracking-wide text-muted-foreground">
<p className="mt-1 text-xl font-semibold tracking-tight"> TOTAL ASSET
{summary ? `${formatCurrency(summary.totalAmount)}` : "-"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
() {summary ? `${formatCurrency(summary.cashBalance)}` : "-"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{" "}
{summary ? `${formatCurrency(summary.evaluationAmount)}` : "-"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
(KIS){" "}
{summary ? `${formatCurrency(summary.totalDepositAmount)}` : "-"}
</p>
<p className="mt-1 text-[11px] text-muted-foreground/80">
.
</p>
<p className="mt-1 text-xs text-muted-foreground">
( ){" "}
{summary ? `${formatCurrency(summary.netAssetAmount)}` : "-"}
</p>
{hasApiTotalAmount && isApiTotalAmountDifferent ? (
<p className="mt-1 text-xs text-muted-foreground">
KIS {formatCurrency(summary?.apiReportedTotalAmount ?? 0)}
</p> </p>
) : null} <p className="mt-2 text-2xl font-bold tracking-tight text-foreground md:text-3xl">
{hasApiNetAssetAmount ? ( {summary ? `${formatCurrency(displayGrossTotalAmount)}` : "-"}
<p className="mt-1 text-xs text-muted-foreground"> </p>
KIS {formatCurrency(summary?.apiReportedNetAssetAmount ?? 0)} <p className="mt-1 text-xs text-muted-foreground">
{summary ? `${formatCurrency(summary.netAssetAmount)}` : "-"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{summary ? `${formatCurrency(summary.cashBalance)}` : "-"} · {" "}
{summary ? `${formatCurrency(summary.evaluationAmount)}` : "-"}
</p> </p>
) : null}
</div>
{/* ========== PROFIT/LOSS ========== */}
<div className="rounded-xl border border-border/70 bg-background/90 px-4 py-3">
<p className="text-xs font-medium text-muted-foreground"> </p>
<p
className={cn(
"mt-1 text-xl font-semibold tracking-tight",
toneClass,
)}
>
{summary ? `${formatSignedCurrency(summary.totalProfitLoss)}` : "-"}
</p>
<p className={cn("mt-1 text-xs font-medium", toneClass)}>
{summary ? formatSignedPercent(summary.totalProfitRate) : "-"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{" "}
{summary ? `${formatCurrency(summary.evaluationAmount)}` : "-"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{" "}
{summary ? `${formatCurrency(summary.purchaseAmount)}` : "-"}
</p>
</div>
{/* ========== CONNECTION STATUS ========== */}
<div className="rounded-xl border border-border/70 bg-background/90 px-4 py-3">
<p className="text-xs font-medium text-muted-foreground"> </p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs font-medium">
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-1",
isKisRestConnected
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-red-500/10 text-red-600 dark:text-red-400",
)}
>
<Wifi className="h-3.5 w-3.5" />
{isKisRestConnected ? "연결됨" : "연결 끊김"}
</span>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-1",
isWebSocketReady
? isRealtimePending
? "bg-amber-500/10 text-amber-700 dark:text-amber-400"
: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-muted text-muted-foreground",
)}
>
<Activity className="h-3.5 w-3.5" />
{realtimeStatusLabel}
</span>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-1",
isProfileVerified
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
)}
>
<Activity className="h-3.5 w-3.5" />
{isProfileVerified ? "완료" : "미완료"}
</span>
</div> </div>
<p className="mt-2 text-xs text-muted-foreground">
{updatedLabel} <div className="rounded-2xl border border-border/70 bg-background/85 p-4 shadow-sm">
</p> <p className="text-xs font-semibold tracking-wide text-muted-foreground">
<p className="mt-1 text-xs text-muted-foreground"> TODAY P/L
{maskAccountNo(verifiedAccountNo)} </p>
</p> <p className={cn("mt-2 text-2xl font-bold tracking-tight md:text-3xl", toneClass)}>
<p className="mt-1 text-xs text-muted-foreground"> {summary ? `${formatSignedCurrency(summary.totalProfitLoss)}` : "-"}
{summary ? `${formatCurrency(summary.loanAmount)}` : "-"} </p>
</p> <p className={cn("mt-1 text-sm font-semibold", toneClass)}>
{summary ? formatSignedPercent(summary.totalProfitRate) : "-"}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{summary ? `${formatCurrency(summary.purchaseAmount)}` : "-"} · {" "}
{summary ? `${formatCurrency(summary.loanAmount)}` : "-"}
</p>
</div>
<div className="flex flex-col gap-2 rounded-2xl border border-border/70 bg-background/85 p-3">
<Button
type="button"
variant="outline"
onClick={onRefresh}
disabled={isRefreshing}
className="w-full border-brand-200 text-brand-700 hover:bg-brand-50 dark:border-brand-700 dark:text-brand-300 dark:hover:bg-brand-900/30"
>
<RefreshCcw className={cn("mr-2 h-4 w-4", isRefreshing ? "animate-spin" : "")} />
</Button>
<Button
asChild
className="w-full bg-brand-600 text-white hover:bg-brand-700 dark:bg-brand-600 dark:text-white dark:hover:bg-brand-500"
>
<Link href="/settings">
<Settings2 className="mr-2 h-4 w-4" />
</Link>
</Button>
<div className="mt-1 rounded-xl border border-border/70 bg-muted/30 px-2.5 py-2 text-[11px] text-muted-foreground">
<p> {updatedLabel}</p>
<p> {maskAccountNo(verifiedAccountNo)}</p>
</div>
</div>
</div> </div>
{/* ========== QUICK ACTIONS ========== */} <div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
<div className="flex flex-col gap-2 sm:flex-row md:flex-col md:items-stretch md:justify-center"> <OverviewMetric
<Button icon={<Wifi className="h-3.5 w-3.5" />}
type="button" label="REST 연결"
variant="outline" value={isKisRestConnected ? "정상" : "끊김"}
onClick={onRefresh} toneClass={
disabled={isRefreshing} isKisRestConnected
className="w-full border-brand-200 text-brand-700 hover:bg-brand-50 dark:border-brand-700 dark:text-brand-300 dark:hover:bg-brand-900/30" ? "border-emerald-300/70 bg-emerald-50/60 text-emerald-700 dark:border-emerald-700/50 dark:bg-emerald-950/30 dark:text-emerald-300"
> : "border-red-300/70 bg-red-50/60 text-red-700 dark:border-red-700/50 dark:bg-red-950/30 dark:text-red-300"
<RefreshCcw }
className={cn("h-4 w-4 mr-2", isRefreshing ? "animate-spin" : "")} />
/> <OverviewMetric
icon={<Activity className="h-3.5 w-3.5" />}
</Button> label="실시간 시세"
<Button value={realtimeStatusLabel}
asChild toneClass={
className="w-full bg-brand-600 text-white hover:bg-brand-700 dark:bg-brand-600 dark:text-white dark:hover:bg-brand-500" isWebSocketReady
> ? isRealtimePending
<Link href="/settings"> ? "border-amber-300/70 bg-amber-50/60 text-amber-700 dark:border-amber-700/50 dark:bg-amber-950/30 dark:text-amber-300"
<Settings2 className="h-4 w-4 mr-2" /> : "border-emerald-300/70 bg-emerald-50/60 text-emerald-700 dark:border-emerald-700/50 dark:bg-emerald-950/30 dark:text-emerald-300"
: "border-slate-300/70 bg-slate-50/60 text-slate-700 dark:border-slate-700/50 dark:bg-slate-900/30 dark:text-slate-300"
</Link> }
</Button> />
<OverviewMetric
icon={<Activity className="h-3.5 w-3.5" />}
label="계좌 인증"
value={isProfileVerified ? "완료" : "미완료"}
toneClass={
isProfileVerified
? "border-emerald-300/70 bg-emerald-50/60 text-emerald-700 dark:border-emerald-700/50 dark:bg-emerald-950/30 dark:text-emerald-300"
: "border-amber-300/70 bg-amber-50/60 text-amber-700 dark:border-amber-700/50 dark:bg-amber-950/30 dark:text-amber-300"
}
/>
<OverviewMetric
icon={<Activity className="h-3.5 w-3.5" />}
label="총예수금(KIS)"
value={summary ? `${formatCurrency(summary.totalDepositAmount)}` : "-"}
toneClass="border-brand-200/80 bg-brand-50/70 text-brand-700 dark:border-brand-700/60 dark:bg-brand-900/35 dark:text-brand-300"
/>
</div> </div>
{hasApiTotalAmount && isApiTotalAmountDifferent ? (
<p className="text-xs text-muted-foreground">
{formatCurrency(summary?.totalAmount ?? 0)} · KIS {" "}
{formatCurrency(summary?.apiReportedTotalAmount ?? 0)}
</p>
) : null}
{hasApiNetAssetAmount ? (
<p className="text-xs text-muted-foreground">
KIS {formatCurrency(summary?.apiReportedNetAssetAmount ?? 0)}
</p>
) : null}
</CardContent> </CardContent>
</Card> </Card>
); );
} }
function OverviewMetric({
icon,
label,
value,
toneClass,
}: {
icon: ReactNode;
label: string;
value: string;
toneClass: string;
}) {
return (
<div className={cn("rounded-xl border px-3 py-2", toneClass)}>
<p className="flex items-center gap-1 text-[11px] font-medium opacity-85">
{icon}
{label}
</p>
<p className="mt-0.5 text-xs font-semibold">{value}</p>
</div>
);
}
/** /**
* @description 계좌번호를 마스킹해 표시합니다. * @description 계좌번호를 마스킹해 표시합니다.
* @param value 계좌번호(8-2) * @param value 계좌번호(8-2)

View File

@@ -57,7 +57,7 @@ export function StockDetailPreview({
// [Step 1] 종목이 선택되지 않은 경우 초기 안내 화면 렌더링 // [Step 1] 종목이 선택되지 않은 경우 초기 안내 화면 렌더링
if (!holding) { if (!holding) {
return ( return (
<Card> <Card className="border-brand-200/70 bg-background/90 shadow-sm dark:border-brand-800/45">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<BarChartBig className="h-4 w-4 text-brand-600 dark:text-brand-400" /> <BarChartBig className="h-4 w-4 text-brand-600 dark:text-brand-400" />
@@ -86,7 +86,7 @@ export function StockDetailPreview({
: 0; : 0;
return ( return (
<Card> <Card className="border-brand-200/70 bg-background/90 shadow-sm dark:border-brand-800/45">
{/* ========== 카드 헤더: 종목명 및 기본 정보 ========== */} {/* ========== 카드 헤더: 종목명 및 기본 정보 ========== */}
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
@@ -131,6 +131,10 @@ export function StockDetailPreview({
label="보유 수량" label="보유 수량"
value={`${holding.quantity.toLocaleString("ko-KR")}`} value={`${holding.quantity.toLocaleString("ko-KR")}`}
/> />
<Metric
label="매도가능 수량"
value={`${holding.sellableQuantity.toLocaleString("ko-KR")}`}
/>
<Metric <Metric
label="매입 평균가" label="매입 평균가"
value={`${formatCurrency(holding.averagePrice)}`} value={`${formatCurrency(holding.averagePrice)}`}
@@ -171,7 +175,7 @@ export function StockDetailPreview({
</div> </div>
{/* ========== 추가 기능 예고 영역 (Placeholder) ========== */} {/* ========== 추가 기능 예고 영역 (Placeholder) ========== */}
<div className="rounded-xl border border-dashed border-border/80 bg-muted/30 p-3"> <div className="rounded-xl border border-dashed border-brand-300/60 bg-brand-50/40 p-3 dark:border-brand-700/50 dark:bg-brand-900/20">
<p className="flex items-center gap-1.5 text-sm font-medium text-foreground/80"> <p className="flex items-center gap-1.5 text-sm font-medium text-foreground/80">
<MousePointerClick className="h-4 w-4 text-brand-500" /> <MousePointerClick className="h-4 w-4 text-brand-500" />
( ) ( )

View File

@@ -6,11 +6,13 @@ import {
fetchDashboardActivity, fetchDashboardActivity,
fetchDashboardBalance, fetchDashboardBalance,
fetchDashboardIndices, fetchDashboardIndices,
fetchDashboardMarketHub,
} from "@/features/dashboard/apis/dashboard.api"; } from "@/features/dashboard/apis/dashboard.api";
import type { import type {
DashboardActivityResponse, DashboardActivityResponse,
DashboardBalanceResponse, DashboardBalanceResponse,
DashboardIndicesResponse, DashboardIndicesResponse,
DashboardMarketHubResponse,
} from "@/features/dashboard/types/dashboard.types"; } from "@/features/dashboard/types/dashboard.types";
interface UseDashboardDataResult { interface UseDashboardDataResult {
@@ -24,6 +26,8 @@ interface UseDashboardDataResult {
activityError: string | null; activityError: string | null;
balanceError: string | null; balanceError: string | null;
indicesError: string | null; indicesError: string | null;
marketHub: DashboardMarketHubResponse | null;
marketHubError: string | null;
lastUpdatedAt: string | null; lastUpdatedAt: string | null;
refresh: () => Promise<void>; refresh: () => Promise<void>;
} }
@@ -50,6 +54,8 @@ export function useDashboardData(
const [activityError, setActivityError] = useState<string | null>(null); const [activityError, setActivityError] = useState<string | null>(null);
const [balanceError, setBalanceError] = useState<string | null>(null); const [balanceError, setBalanceError] = useState<string | null>(null);
const [indicesError, setIndicesError] = useState<string | null>(null); const [indicesError, setIndicesError] = useState<string | null>(null);
const [marketHub, setMarketHub] = useState<DashboardMarketHubResponse | null>(null);
const [marketHubError, setMarketHubError] = useState<string | null>(null);
const [lastUpdatedAt, setLastUpdatedAt] = useState<string | null>(null); const [lastUpdatedAt, setLastUpdatedAt] = useState<string | null>(null);
const requestSeqRef = useRef(0); const requestSeqRef = useRef(0);
@@ -78,6 +84,7 @@ export function useDashboardData(
Promise<DashboardBalanceResponse | null>, Promise<DashboardBalanceResponse | null>,
Promise<DashboardIndicesResponse>, Promise<DashboardIndicesResponse>,
Promise<DashboardActivityResponse | null>, Promise<DashboardActivityResponse | null>,
Promise<DashboardMarketHubResponse>,
] = [ ] = [
hasAccountNo hasAccountNo
? fetchDashboardBalance(credentials) ? fetchDashboardBalance(credentials)
@@ -86,9 +93,15 @@ export function useDashboardData(
hasAccountNo hasAccountNo
? fetchDashboardActivity(credentials) ? fetchDashboardActivity(credentials)
: Promise.resolve(null), : Promise.resolve(null),
fetchDashboardMarketHub(credentials),
]; ];
const [balanceResult, indicesResult, activityResult] = await Promise.allSettled(tasks); const [
balanceResult,
indicesResult,
activityResult,
marketHubResult,
] = await Promise.allSettled(tasks);
if (requestSeq !== requestSeqRef.current) return; if (requestSeq !== requestSeqRef.current) return;
let hasAnySuccess = false; let hasAnySuccess = false;
@@ -136,6 +149,18 @@ export function useDashboardData(
setIndicesError(indicesResult.reason instanceof Error ? indicesResult.reason.message : "시장 지수 조회에 실패했습니다."); setIndicesError(indicesResult.reason instanceof Error ? indicesResult.reason.message : "시장 지수 조회에 실패했습니다.");
} }
if (marketHubResult.status === "fulfilled") {
hasAnySuccess = true;
setMarketHub(marketHubResult.value);
setMarketHubError(null);
} else {
setMarketHubError(
marketHubResult.reason instanceof Error
? marketHubResult.reason.message
: "시장 허브 조회에 실패했습니다.",
);
}
if (hasAnySuccess) { if (hasAnySuccess) {
setLastUpdatedAt(new Date().toISOString()); setLastUpdatedAt(new Date().toISOString());
} }
@@ -192,6 +217,8 @@ export function useDashboardData(
activityError, activityError,
balanceError, balanceError,
indicesError, indicesError,
marketHub,
marketHubError,
lastUpdatedAt, lastUpdatedAt,
refresh, refresh,
}; };

View File

@@ -32,6 +32,7 @@ export interface DashboardHoldingItem {
name: string; name: string;
market: DashboardMarket; market: DashboardMarket;
quantity: number; quantity: number;
sellableQuantity: number;
averagePrice: number; averagePrice: number;
currentPrice: number; currentPrice: number;
evaluationAmount: number; evaluationAmount: number;
@@ -139,3 +140,56 @@ export interface DashboardActivityResponse {
warnings: string[]; warnings: string[];
fetchedAt: string; fetchedAt: string;
} }
/**
* 대시보드 시장 허브(급등/인기/뉴스) 공통 종목 항목
*/
export interface DashboardMarketRankItem {
rank: number;
symbol: string;
name: string;
market: DashboardMarket;
price: number;
change: number;
changeRate: number;
volume: number;
tradingValue: number;
}
/**
* 대시보드 주요 뉴스 항목
*/
export interface DashboardNewsHeadlineItem {
id: string;
title: string;
source: string;
publishedAt: string;
symbols: string[];
}
/**
* 대시보드 시장 허브 요약 지표
*/
export interface DashboardMarketPulse {
gainersCount: number;
losersCount: number;
popularByVolumeCount: number;
popularByValueCount: number;
newsCount: number;
}
/**
* 대시보드 시장 허브 API 응답 모델
*/
export interface DashboardMarketHubResponse {
source: "kis";
tradingEnv: KisTradingEnv;
gainers: DashboardMarketRankItem[];
losers: DashboardMarketRankItem[];
popularByVolume: DashboardMarketRankItem[];
popularByValue: DashboardMarketRankItem[];
news: DashboardNewsHeadlineItem[];
pulse: DashboardMarketPulse;
warnings: string[];
fetchedAt: string;
}

View File

@@ -93,12 +93,12 @@ export function Logo({
{variant === "full" && ( {variant === "full" && (
<span <span
className={cn( className={cn(
"font-bold tracking-tight", "font-heading font-semibold tracking-tight",
blendWithBackground blendWithBackground
? "text-white opacity-95" ? "text-white opacity-95"
: "text-brand-900 dark:text-brand-50", : "text-brand-900 dark:text-brand-50",
)} )}
style={{ fontSize: "1.35rem", fontFamily: "'Inter', sans-serif" }} style={{ fontSize: "1.35rem" }}
> >
JOORIN-E JOORIN-E
</span> </span>

View File

@@ -13,6 +13,9 @@ import { SessionTimer } from "@/features/auth/components/session-timer";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Logo } from "@/features/layout/components/Logo"; import { Logo } from "@/features/layout/components/Logo";
import { MarketIndices } from "@/features/layout/components/market-indices";
interface HeaderProps { interface HeaderProps {
/** 현재 로그인 사용자 정보(null 가능) */ /** 현재 로그인 사용자 정보(null 가능) */
user: User | null; user: User | null;
@@ -59,7 +62,6 @@ export function Header({
: "", : "",
)} )}
> >
{/* ========== LEFT: LOGO SECTION ========== */}
{/* ========== LEFT: LOGO SECTION ========== */} {/* ========== LEFT: LOGO SECTION ========== */}
<Link href={AUTH_ROUTES.HOME} className="group flex items-center gap-2"> <Link href={AUTH_ROUTES.HOME} className="group flex items-center gap-2">
<Logo <Logo
@@ -69,6 +71,13 @@ export function Header({
/> />
</Link> </Link>
{/* ========== CENTER: MARKET INDICES ========== */}
{!blendWithBackground && user && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<MarketIndices />
</div>
)}
{/* ========== RIGHT: ACTION SECTION ========== */} {/* ========== RIGHT: ACTION SECTION ========== */}
<div <div
className={cn( className={cn(
@@ -141,3 +150,4 @@ export function Header({
</header> </header>
); );
} }

View File

@@ -0,0 +1,74 @@
"use client";
/**
* @file features/layout/components/market-indices.tsx
* @description KOSPI/KOSDAQ 지수를 표시하는 UI 컴포넌트
*
* @description [주요 책임]
* - `useMarketIndices` 훅을 사용하여 지수 데이터를 가져옴
* - 30초마다 데이터를 자동으로 새로고침
* - 로딩 상태일 때 스켈레톤 UI를 표시
* - 각 지수 정보를 `MarketIndexItem` 컴포넌트로 렌더링
*/
import { useEffect } from "react";
import { useMarketIndices } from "@/features/layout/hooks/use-market-indices";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import type { DomesticMarketIndexResult } from "@/lib/kis/dashboard";
const MarketIndexItem = ({ index }: { index: DomesticMarketIndexResult }) => (
<div className="flex items-center space-x-2">
<span className="text-sm font-medium">{index.name}</span>
<span
className={cn("text-sm", {
"text-red-500": index.change > 0,
"text-blue-500": index.change < 0,
})}
>
{index.price.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
<span
className={cn("text-xs", {
"text-red-500": index.change > 0,
"text-blue-500": index.change < 0,
})}
>
{index.change.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{" "}
({index.changeRate.toFixed(2)}%)
</span>
</div>
);
export function MarketIndices() {
const { indices, isLoading, fetchIndices, fetchedAt } = useMarketIndices();
useEffect(() => {
fetchIndices();
const interval = setInterval(fetchIndices, 30000); // 30초마다 새로고침
return () => clearInterval(interval);
}, [fetchIndices]);
if (isLoading && !fetchedAt) {
return (
<div className="flex items-center space-x-4">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-24" />
</div>
);
}
return (
<div className="hidden items-center space-x-6 md:flex">
{indices.map((index) => (
<MarketIndexItem key={index.code} index={index} />
))}
</div>
);
}

View File

@@ -18,12 +18,14 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { KIS_REMEMBER_LOCAL_STORAGE_KEYS } from "@/features/settings/lib/kis-remember-storage";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const SESSION_RELATED_STORAGE_KEYS = [ const SESSION_RELATED_STORAGE_KEYS = [
"session-storage", "session-storage",
"auth-storage", "auth-storage",
"autotrade-kis-runtime-store", "autotrade-kis-runtime-store",
...KIS_REMEMBER_LOCAL_STORAGE_KEYS,
] as const; ] as const;
interface UserMenuProps { interface UserMenuProps {

View File

@@ -0,0 +1,98 @@
/**
* @file features/layout/hooks/use-market-indices.ts
* @description 시장 지수 데이터를 가져오고 상태를 관리하는 커스텀 훅
*
* @description [주요 책임]
* - `useMarketIndicesStore`와 연동하여 상태(지수, 로딩, 에러)를 제공
* - KIS 검증 세션이 있을 때 `/api/kis/domestic/indices` API를 인증 헤더와 함께 호출
* - API 호출 로직을 `useCallback`으로 메모이제이션하여 성능을 최적화
*/
import { useCallback } from "react";
import {
buildKisRequestHeaders,
resolveKisApiErrorMessage,
type KisApiErrorPayload,
} from "@/features/settings/apis/kis-api-utils";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
import { useMarketIndicesStore } from "@/features/layout/stores/market-indices-store";
import type { DomesticMarketIndexResult } from "@/lib/kis/dashboard";
import type { DashboardIndicesResponse } from "@/features/dashboard/types/dashboard.types";
interface LegacyMarketIndicesResponse {
indices: DomesticMarketIndexResult[];
fetchedAt: string;
}
export function useMarketIndices() {
const verifiedCredentials = useKisRuntimeStore((state) => state.verifiedCredentials);
const isKisVerified = useKisRuntimeStore((state) => state.isKisVerified);
const {
indices,
isLoading,
error,
fetchedAt,
setIndices,
setLoading,
setError,
} = useMarketIndicesStore();
const fetchIndices = useCallback(async () => {
// [Step 1] KIS 검증이 안 된 상태에서는 지수 API를 호출하지 않습니다.
if (!isKisVerified || !verifiedCredentials) {
setLoading(false);
setError(null);
return;
}
setLoading(true);
try {
// [Step 2] 인증 헤더를 포함한 신규 지수 API를 호출합니다.
const response = await fetch("/api/kis/domestic/indices", {
method: "GET",
headers: buildKisRequestHeaders(verifiedCredentials, {
includeSessionOverride: true,
}),
cache: "no-store",
});
const payload = (await response.json()) as
| DashboardIndicesResponse
| LegacyMarketIndicesResponse
| KisApiErrorPayload;
if (!response.ok) {
throw new Error(resolveKisApiErrorMessage(payload, "지수 조회 중 오류가 발생했습니다."));
}
// [Step 3] 신규/레거시 응답 형식을 모두 수용해 스토어에 반영합니다.
if ("items" in payload) {
setIndices({
indices: payload.items,
fetchedAt: payload.fetchedAt,
});
return;
}
if ("indices" in payload && "fetchedAt" in payload) {
setIndices({
indices: payload.indices,
fetchedAt: payload.fetchedAt,
});
return;
}
throw new Error("지수 응답 형식이 올바르지 않습니다.");
} catch (e) {
setError(e instanceof Error ? e : new Error("An unknown error occurred"));
}
}, [isKisVerified, setError, setIndices, setLoading, verifiedCredentials]);
return {
indices,
isLoading,
error,
fetchedAt,
fetchIndices,
};
}

View File

@@ -0,0 +1,39 @@
/**
* @file features/layout/stores/market-indices-store.ts
* @description 시장 지수(KOSPI, KOSDAQ) 데이터 상태 관리를 위한 Zustand 스토어
*
* @description [주요 책임]
* - 지수 데이터, 로딩 상태, 에러 정보, 마지막 fetch 시각을 저장
* - 상태를 업데이트하는 액션(setIndices, setLoading, setError)을 제공
*/
import type { DomesticMarketIndexResult } from "@/lib/kis/dashboard";
import { create } from "zustand";
interface MarketIndicesState {
indices: DomesticMarketIndexResult[];
isLoading: boolean;
error: Error | null;
fetchedAt: string | null;
setIndices: (data: {
indices: DomesticMarketIndexResult[];
fetchedAt: string;
}) => void;
setLoading: (isLoading: boolean) => void;
setError: (error: Error | null) => void;
}
export const useMarketIndicesStore = create<MarketIndicesState>((set) => ({
indices: [],
isLoading: false,
error: null,
fetchedAt: null,
setIndices: (data) =>
set({
indices: data.indices,
fetchedAt: data.fetchedAt,
isLoading: false,
error: null,
}),
setLoading: (isLoading) => set({ isLoading }),
setError: (error) => set({ error, isLoading: false }),
}));

View File

@@ -1,14 +1,21 @@
import { useState, useTransition } from "react"; import { useEffect, useState, useTransition } from "react";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store"; import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
import { import {
revokeKisCredentials, revokeKisCredentials,
validateKisCredentials, validateKisCredentials,
} from "@/features/settings/apis/kis-auth.api"; } from "@/features/settings/apis/kis-auth.api";
import {
getRememberedKisValue,
isKisRememberEnabled,
setKisRememberEnabled,
setRememberedKisValue,
} from "@/features/settings/lib/kis-remember-storage";
import { import {
KeyRound, KeyRound,
ShieldCheck, ShieldCheck,
@@ -37,6 +44,7 @@ export function KisAuthForm() {
kisAppKeyInput, kisAppKeyInput,
kisAppSecretInput, kisAppSecretInput,
verifiedAccountNo, verifiedAccountNo,
hasHydrated,
verifiedCredentials, verifiedCredentials,
isKisVerified, isKisVerified,
setKisTradingEnvInput, setKisTradingEnvInput,
@@ -51,6 +59,7 @@ export function KisAuthForm() {
kisAppKeyInput: state.kisAppKeyInput, kisAppKeyInput: state.kisAppKeyInput,
kisAppSecretInput: state.kisAppSecretInput, kisAppSecretInput: state.kisAppSecretInput,
verifiedAccountNo: state.verifiedAccountNo, verifiedAccountNo: state.verifiedAccountNo,
hasHydrated: state._hasHydrated,
verifiedCredentials: state.verifiedCredentials, verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified, isKisVerified: state.isKisVerified,
setKisTradingEnvInput: state.setKisTradingEnvInput, setKisTradingEnvInput: state.setKisTradingEnvInput,
@@ -66,6 +75,74 @@ export function KisAuthForm() {
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isValidating, startValidateTransition] = useTransition(); const [isValidating, startValidateTransition] = useTransition();
const [isRevoking, startRevokeTransition] = useTransition(); const [isRevoking, startRevokeTransition] = useTransition();
// [State] 앱키 입력값을 브라우저 재접속 후 자동 복원할지 여부
const [rememberAppKey, setRememberAppKey] = useState(() =>
isKisRememberEnabled("appKey"),
);
// [State] 앱시크릿키 입력값을 브라우저 재접속 후 자동 복원할지 여부
const [rememberAppSecret, setRememberAppSecret] = useState(() =>
isKisRememberEnabled("appSecret"),
);
useEffect(() => {
if (!hasHydrated || kisAppKeyInput.trim()) {
return;
}
// [Step 1] 세션 입력값이 비어 있을 때만 저장된 앱키를 복원합니다.
const rememberedAppKey = getRememberedKisValue("appKey");
if (rememberedAppKey) {
setKisAppKeyInput(rememberedAppKey);
}
}, [hasHydrated, kisAppKeyInput, setKisAppKeyInput]);
useEffect(() => {
if (!hasHydrated || kisAppSecretInput.trim()) {
return;
}
// [Step 1] 세션 입력값이 비어 있을 때만 저장된 앱시크릿키를 복원합니다.
const rememberedAppSecret = getRememberedKisValue("appSecret");
if (rememberedAppSecret) {
setKisAppSecretInput(rememberedAppSecret);
}
}, [hasHydrated, kisAppSecretInput, setKisAppSecretInput]);
useEffect(() => {
if (!hasHydrated) {
return;
}
// [Step 2] 앱키 기억하기 체크 상태를 저장/해제합니다.
setKisRememberEnabled("appKey", rememberAppKey);
}, [hasHydrated, rememberAppKey]);
useEffect(() => {
if (!hasHydrated || !rememberAppKey) {
return;
}
// [Step 2] 앱키 입력값이 바뀔 때 기억하기가 켜져 있으면 값을 갱신합니다.
setRememberedKisValue("appKey", kisAppKeyInput);
}, [hasHydrated, rememberAppKey, kisAppKeyInput]);
useEffect(() => {
if (!hasHydrated) {
return;
}
// [Step 2] 앱시크릿키 기억하기 체크 상태를 저장/해제합니다.
setKisRememberEnabled("appSecret", rememberAppSecret);
}, [hasHydrated, rememberAppSecret]);
useEffect(() => {
if (!hasHydrated || !rememberAppSecret) {
return;
}
// [Step 2] 앱시크릿키 입력값이 바뀔 때 기억하기가 켜져 있으면 값을 갱신합니다.
setRememberedKisValue("appSecret", kisAppSecretInput);
}, [hasHydrated, rememberAppSecret, kisAppSecretInput]);
function handleValidate() { function handleValidate() {
startValidateTransition(async () => { startValidateTransition(async () => {
@@ -243,22 +320,39 @@ export function KisAuthForm() {
{/* ========== APP KEY INPUTS ========== */} {/* ========== APP KEY INPUTS ========== */}
<div className="space-y-3"> <div className="space-y-3">
<CredentialInput <div className="space-y-2">
id="kis-app-key" <CredentialInput
label="앱키" id="kis-app-key"
placeholder="한국투자증권 앱키 입력" label="앱키"
value={kisAppKeyInput} placeholder="한국투자증권 앱키 입력"
onChange={setKisAppKeyInput} value={kisAppKeyInput}
icon={KeySquare} onChange={setKisAppKeyInput}
/> icon={KeySquare}
<CredentialInput />
id="kis-app-secret" <RememberCheckbox
label="앱시크릿키" id="remember-kis-app-key"
placeholder="한국투자증권 앱시크릿키 입력" checked={rememberAppKey}
value={kisAppSecretInput} onChange={setRememberAppKey}
onChange={setKisAppSecretInput} label="앱키 기억하기"
icon={Lock} />
/> </div>
<div className="space-y-2">
<CredentialInput
id="kis-app-secret"
label="앱시크릿키"
placeholder="한국투자증권 앱시크릿키 입력"
value={kisAppSecretInput}
onChange={setKisAppSecretInput}
icon={Lock}
/>
<RememberCheckbox
id="remember-kis-app-secret"
checked={rememberAppSecret}
onChange={setRememberAppSecret}
label="앱시크릿키 기억하기"
/>
</div>
</div> </div>
</div> </div>
</SettingsCard> </SettingsCard>
@@ -306,3 +400,31 @@ function CredentialInput({
</div> </div>
); );
} }
function RememberCheckbox({
id,
checked,
onChange,
label,
}: {
id: string;
checked: boolean;
onChange: (checked: boolean) => void;
label: string;
}) {
return (
<div className="flex items-center gap-2 pl-1">
<Checkbox
id={id}
checked={checked}
onCheckedChange={(next) => onChange(next === true)}
/>
<Label
htmlFor={id}
className="cursor-pointer text-xs font-medium text-zinc-500 dark:text-zinc-300"
>
{label}
</Label>
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import { useEffect, useState, useTransition } from "react";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { import {
CreditCard, CreditCard,
@@ -13,8 +13,15 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { InlineSpinner } from "@/components/ui/loading-spinner"; import { InlineSpinner } from "@/components/ui/loading-spinner";
import { validateKisProfile } from "@/features/settings/apis/kis-auth.api"; import { validateKisProfile } from "@/features/settings/apis/kis-auth.api";
import {
getRememberedKisValue,
isKisRememberEnabled,
setKisRememberEnabled,
setRememberedKisValue,
} from "@/features/settings/lib/kis-remember-storage";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store"; import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
import { SettingsCard } from "./SettingsCard"; import { SettingsCard } from "./SettingsCard";
@@ -28,6 +35,7 @@ export function KisProfileForm() {
const { const {
kisAccountNoInput, kisAccountNoInput,
verifiedCredentials, verifiedCredentials,
hasHydrated,
isKisVerified, isKisVerified,
isKisProfileVerified, isKisProfileVerified,
verifiedAccountNo, verifiedAccountNo,
@@ -38,6 +46,7 @@ export function KisProfileForm() {
useShallow((state) => ({ useShallow((state) => ({
kisAccountNoInput: state.kisAccountNoInput, kisAccountNoInput: state.kisAccountNoInput,
verifiedCredentials: state.verifiedCredentials, verifiedCredentials: state.verifiedCredentials,
hasHydrated: state._hasHydrated,
isKisVerified: state.isKisVerified, isKisVerified: state.isKisVerified,
isKisProfileVerified: state.isKisProfileVerified, isKisProfileVerified: state.isKisProfileVerified,
verifiedAccountNo: state.verifiedAccountNo, verifiedAccountNo: state.verifiedAccountNo,
@@ -50,6 +59,40 @@ export function KisProfileForm() {
const [statusMessage, setStatusMessage] = useState<string | null>(null); const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isValidating, startValidateTransition] = useTransition(); const [isValidating, startValidateTransition] = useTransition();
// [State] 계좌번호 입력값을 브라우저 재접속 후 자동 복원할지 여부
const [rememberAccountNo, setRememberAccountNo] = useState(() =>
isKisRememberEnabled("accountNo"),
);
useEffect(() => {
if (!hasHydrated || kisAccountNoInput.trim()) {
return;
}
// [Step 1] 세션 입력값이 비어 있을 때만 저장된 계좌번호를 복원합니다.
const rememberedAccountNo = getRememberedKisValue("accountNo");
if (rememberedAccountNo) {
setKisAccountNoInput(rememberedAccountNo);
}
}, [hasHydrated, kisAccountNoInput, setKisAccountNoInput]);
useEffect(() => {
if (!hasHydrated) {
return;
}
// [Step 2] 계좌 기억하기 체크 상태를 저장/해제합니다.
setKisRememberEnabled("accountNo", rememberAccountNo);
}, [hasHydrated, rememberAccountNo]);
useEffect(() => {
if (!hasHydrated || !rememberAccountNo) {
return;
}
// [Step 2] 계좌번호 입력값이 바뀔 때 기억하기가 켜져 있으면 값을 갱신합니다.
setRememberedKisValue("accountNo", kisAccountNoInput);
}, [hasHydrated, rememberAccountNo, kisAccountNoInput]);
/** /**
* @description 계좌번호 인증을 해제하고 입력값을 비웁니다. * @description 계좌번호 인증을 해제하고 입력값을 비웁니다.
@@ -220,6 +263,21 @@ export function KisProfileForm() {
autoComplete="off" autoComplete="off"
/> />
</div> </div>
<div className="flex items-center gap-2 pl-1 pt-0.5">
<Checkbox
id="remember-kis-account-no"
checked={rememberAccountNo}
onCheckedChange={(checked) =>
setRememberAccountNo(checked === true)
}
/>
<Label
htmlFor="remember-kis-account-no"
className="cursor-pointer text-xs font-medium text-zinc-500 dark:text-zinc-300"
>
</Label>
</div>
</div> </div>
</div> </div>
</SettingsCard> </SettingsCard>

View File

@@ -0,0 +1,95 @@
/**
* [파일 역할]
* KIS 설정 화면의 "기억하기" 항목(localStorage) 키와 읽기/쓰기 유틸을 관리합니다.
*
* [주요 책임]
* - 앱키/앱시크릿키/계좌번호의 기억하기 체크 상태 저장
* - 기억하기 값 저장/조회/삭제
* - 로그아웃/세션만료 시 일괄 정리할 키 목록 제공
*/
export type KisRememberField = "appKey" | "appSecret" | "accountNo";
const KIS_REMEMBER_ENABLED_KEY_MAP = {
appKey: "autotrade-kis-remember-app-key-enabled",
appSecret: "autotrade-kis-remember-app-secret-enabled",
accountNo: "autotrade-kis-remember-account-no-enabled",
} as const;
const KIS_REMEMBER_VALUE_KEY_MAP = {
appKey: "autotrade-kis-remember-app-key",
appSecret: "autotrade-kis-remember-app-secret",
accountNo: "autotrade-kis-remember-account-no",
} as const;
export const KIS_REMEMBER_LOCAL_STORAGE_KEYS = [
...Object.values(KIS_REMEMBER_ENABLED_KEY_MAP),
...Object.values(KIS_REMEMBER_VALUE_KEY_MAP),
] as const;
function getBrowserLocalStorage() {
if (typeof window === "undefined") {
return null;
}
return window.localStorage;
}
function readLocalStorage(key: string) {
const storage = getBrowserLocalStorage();
if (!storage) {
return "";
}
return storage.getItem(key) ?? "";
}
function writeLocalStorage(key: string, value: string) {
const storage = getBrowserLocalStorage();
if (!storage) {
return;
}
storage.setItem(key, value);
}
function removeLocalStorage(key: string) {
const storage = getBrowserLocalStorage();
if (!storage) {
return;
}
storage.removeItem(key);
}
export function isKisRememberEnabled(field: KisRememberField) {
return readLocalStorage(KIS_REMEMBER_ENABLED_KEY_MAP[field]) === "1";
}
export function setKisRememberEnabled(field: KisRememberField, enabled: boolean) {
const enabledKey = KIS_REMEMBER_ENABLED_KEY_MAP[field];
if (enabled) {
writeLocalStorage(enabledKey, "1");
return;
}
removeLocalStorage(enabledKey);
removeLocalStorage(KIS_REMEMBER_VALUE_KEY_MAP[field]);
}
export function getRememberedKisValue(field: KisRememberField) {
return readLocalStorage(KIS_REMEMBER_VALUE_KEY_MAP[field]);
}
export function setRememberedKisValue(field: KisRememberField, value: string) {
const normalized = value.trim();
const valueKey = KIS_REMEMBER_VALUE_KEY_MAP[field];
if (!normalized) {
removeLocalStorage(valueKey);
return;
}
writeLocalStorage(valueKey, normalized);
}

View File

@@ -8,6 +8,8 @@ import type {
DashboardChartTimeframe, DashboardChartTimeframe,
DashboardStockCashOrderRequest, DashboardStockCashOrderRequest,
DashboardStockCashOrderResponse, DashboardStockCashOrderResponse,
DashboardStockOrderableCashRequest,
DashboardStockOrderableCashResponse,
DashboardStockChartResponse, DashboardStockChartResponse,
DashboardStockOrderBookResponse, DashboardStockOrderBookResponse,
DashboardStockOverviewResponse, DashboardStockOverviewResponse,
@@ -168,3 +170,34 @@ export async function fetchOrderCash(
return payload as DashboardStockCashOrderResponse; return payload as DashboardStockCashOrderResponse;
} }
/**
* 매수가능금액(주문가능현금) 조회 API 호출
* @param request 종목/가격 기준 조회 요청
* @param credentials KIS 인증 정보
*/
export async function fetchOrderableCashEstimate(
request: DashboardStockOrderableCashRequest,
credentials: KisRuntimeCredentials,
): Promise<DashboardStockOrderableCashResponse> {
const response = await fetch("/api/kis/domestic/orderable-cash", {
method: "POST",
headers: buildKisRequestHeaders(credentials, {
jsonContentType: true,
includeAccountNo: true,
includeSessionOverride: true,
}),
body: JSON.stringify(request),
cache: "no-store",
});
const payload = (await response.json()) as
| DashboardStockOrderableCashResponse
| KisApiErrorPayload;
if (!response.ok) {
throw new Error(resolveKisApiErrorMessage(payload, "매수가능금액 조회 중 오류가 발생했습니다."));
}
return payload as DashboardStockOrderableCashResponse;
}

View File

@@ -10,6 +10,7 @@ import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-st
import { TradeAccessGate } from "@/features/trade/components/guards/TradeAccessGate"; import { TradeAccessGate } from "@/features/trade/components/guards/TradeAccessGate";
import { TradeDashboardContent } from "@/features/trade/components/layout/TradeDashboardContent"; import { TradeDashboardContent } from "@/features/trade/components/layout/TradeDashboardContent";
import { TradeSearchSection } from "@/features/trade/components/search/TradeSearchSection"; import { TradeSearchSection } from "@/features/trade/components/search/TradeSearchSection";
import { AutotradeControlPanel } from "@/features/autotrade/components/AutotradeControlPanel";
import { useStockSearch } from "@/features/trade/hooks/useStockSearch"; import { useStockSearch } from "@/features/trade/hooks/useStockSearch";
import { useOrderBook } from "@/features/trade/hooks/useOrderBook"; import { useOrderBook } from "@/features/trade/hooks/useOrderBook";
import { useKisTradeWebSocket } from "@/features/trade/hooks/useKisTradeWebSocket"; import { useKisTradeWebSocket } from "@/features/trade/hooks/useKisTradeWebSocket";
@@ -40,6 +41,8 @@ export function TradeContainer() {
useState<DashboardStockOrderBookResponse | null>(null); useState<DashboardStockOrderBookResponse | null>(null);
// [State] 선택 종목과 매칭할 보유 종목 목록 // [State] 선택 종목과 매칭할 보유 종목 목록
const [holdings, setHoldings] = useState<DashboardHoldingItem[]>([]); const [holdings, setHoldings] = useState<DashboardHoldingItem[]>([]);
// [State] 주문 패널에서 사용할 가용 예수금 스냅샷(원)
const [availableCashBalance, setAvailableCashBalance] = useState<number | null>(null);
const { verifiedCredentials, isKisVerified, _hasHydrated } = const { verifiedCredentials, isKisVerified, _hasHydrated } =
useKisRuntimeStore( useKisRuntimeStore(
useShallow((state) => ({ useShallow((state) => ({
@@ -125,15 +128,18 @@ export function TradeContainer() {
const loadHoldingsSnapshot = useCallback(async () => { const loadHoldingsSnapshot = useCallback(async () => {
if (!verifiedCredentials?.accountNo?.trim()) { if (!verifiedCredentials?.accountNo?.trim()) {
setHoldings([]); setHoldings([]);
setAvailableCashBalance(null);
return; return;
} }
try { try {
const balance = await fetchDashboardBalance(verifiedCredentials); const balance = await fetchDashboardBalance(verifiedCredentials);
setHoldings(balance.holdings); setHoldings(balance.holdings.filter((item) => item.quantity > 0));
setAvailableCashBalance(balance.summary.cashBalance);
} catch { } catch {
// 상단 요약은 보조 정보이므로 실패 시 화면 전체를 막지 않고 빈 상태로 처리합니다. // 상단 요약은 보조 정보이므로 실패 시 화면 전체를 막지 않고 빈 상태로 처리합니다.
setHoldings([]); setHoldings([]);
setAvailableCashBalance(null);
} }
}, [verifiedCredentials]); }, [verifiedCredentials]);
@@ -328,6 +334,13 @@ export function TradeContainer() {
onClearHistory={clearSearchHistory} onClearHistory={clearSearchHistory}
/> />
<AutotradeControlPanel
selectedStock={selectedStock}
latestTick={latestTick}
credentials={verifiedCredentials}
canTrade={canTrade}
/>
{/* ========== DASHBOARD SECTION ========== */} {/* ========== DASHBOARD SECTION ========== */}
<TradeDashboardContent <TradeDashboardContent
selectedStock={selectedStock} selectedStock={selectedStock}
@@ -338,6 +351,7 @@ export function TradeContainer() {
isOrderBookLoading={isOrderBookLoading} isOrderBookLoading={isOrderBookLoading}
referencePrice={referencePrice} referencePrice={referencePrice}
matchedHolding={matchedHolding} matchedHolding={matchedHolding}
availableCashBalance={availableCashBalance}
/> />
</div> </div>
); );

View File

@@ -39,10 +39,14 @@ import {
CHART_MIN_HEIGHT, CHART_MIN_HEIGHT,
DEFAULT_CHART_PALETTE, DEFAULT_CHART_PALETTE,
getChartPaletteFromCssVars, getChartPaletteFromCssVars,
HISTORY_LOAD_TRIGGER_BARS_BEFORE,
INITIAL_MINUTE_PREFETCH_BUDGET_MS,
MINUTE_SYNC_INTERVAL_MS, MINUTE_SYNC_INTERVAL_MS,
MINUTE_TIMEFRAMES, MINUTE_TIMEFRAMES,
PERIOD_TIMEFRAMES, PERIOD_TIMEFRAMES,
REALTIME_STALE_THRESHOLD_MS, REALTIME_STALE_THRESHOLD_MS,
resolveInitialMinutePrefetchPages,
resolveInitialMinuteTargetBars,
UP_COLOR, UP_COLOR,
} from "./stock-line-chart-meta"; } from "./stock-line-chart-meta";
@@ -101,6 +105,9 @@ export function StockLineChart({
const loadingMoreRef = useRef(false); const loadingMoreRef = useRef(false);
const loadMoreHandlerRef = useRef<() => Promise<void>>(async () => {}); const loadMoreHandlerRef = useRef<() => Promise<void>>(async () => {});
const initialLoadCompleteRef = useRef(false); const initialLoadCompleteRef = useRef(false);
const pendingFitContentRef = useRef(false);
const nextCursorRef = useRef<string | null>(null);
const autoFillLeftGapRef = useRef(false);
// API 오류 시 fallback 용도로 유지 // API 오류 시 fallback 용도로 유지
const latestCandlesRef = useRef(candles); const latestCandlesRef = useRef(candles);
@@ -108,6 +115,10 @@ export function StockLineChart({
latestCandlesRef.current = candles; latestCandlesRef.current = candles;
}, [candles]); }, [candles]);
useEffect(() => {
nextCursorRef.current = nextCursor;
}, [nextCursor]);
const latest = bars.at(-1); const latest = bars.at(-1);
const prevClose = bars.length > 1 ? (bars.at(-2)?.close ?? 0) : 0; const prevClose = bars.length > 1 ? (bars.at(-2)?.close ?? 0) : 0;
const change = latest ? latest.close - prevClose : 0; const change = latest ? latest.close - prevClose : 0;
@@ -196,7 +207,13 @@ export function StockLineChart({
const olderBars = normalizeCandles(response.candles, timeframe); const olderBars = normalizeCandles(response.candles, timeframe);
setBars((prev) => mergeBars(olderBars, prev)); setBars((prev) => mergeBars(olderBars, prev));
setNextCursor(response.hasMore ? response.nextCursor : null); setNextCursor(
response.hasMore &&
response.nextCursor &&
response.nextCursor !== nextCursor
? response.nextCursor
: null,
);
} catch (error) { } catch (error) {
const message = const message =
error instanceof Error error instanceof Error
@@ -213,6 +230,58 @@ export function StockLineChart({
loadMoreHandlerRef.current = handleLoadMore; loadMoreHandlerRef.current = handleLoadMore;
}, [handleLoadMore]); }, [handleLoadMore]);
const fillLeftWhitespaceIfNeeded = useCallback(async () => {
if (!isMinuteTimeframe(timeframe)) return;
if (autoFillLeftGapRef.current) return;
if (loadingMoreRef.current) return;
if (!nextCursorRef.current) return;
const chart = chartRef.current;
const candleSeries = candleSeriesRef.current;
if (!chart || !candleSeries) return;
autoFillLeftGapRef.current = true;
const startedAt = Date.now();
let rounds = 0;
try {
while (
rounds < 16 &&
Date.now() - startedAt < INITIAL_MINUTE_PREFETCH_BUDGET_MS
) {
const range = chart.timeScale().getVisibleLogicalRange();
if (!range) break;
const barsInfo = candleSeries.barsInLogicalRange(range);
const hasLeftWhitespace =
Boolean(
barsInfo &&
Number.isFinite(barsInfo.barsBefore) &&
barsInfo.barsBefore < 0,
) || false;
if (!hasLeftWhitespace) break;
const cursorBefore = nextCursorRef.current;
if (!cursorBefore) break;
await loadMoreHandlerRef.current();
rounds += 1;
await new Promise<void>((resolve) => {
window.setTimeout(() => resolve(), 120);
});
chart.timeScale().fitContent();
const cursorAfter = nextCursorRef.current;
if (!cursorAfter || cursorAfter === cursorBefore) break;
}
} finally {
autoFillLeftGapRef.current = false;
}
}, [timeframe]);
useEffect(() => { useEffect(() => {
lastRealtimeKeyRef.current = ""; lastRealtimeKeyRef.current = "";
lastRealtimeAppliedAtRef.current = 0; lastRealtimeAppliedAtRef.current = 0;
@@ -257,7 +326,10 @@ export function StockLineChart({
borderColor: palette.borderColor, borderColor: palette.borderColor,
timeVisible: true, timeVisible: true,
secondsVisible: false, secondsVisible: false,
rightOffset: 2, rightOffset: 4,
barSpacing: 6,
minBarSpacing: 1,
rightBarStaysOnScroll: true,
tickMarkFormatter: formatKstTickMark, tickMarkFormatter: formatKstTickMark,
}, },
handleScroll: { handleScroll: {
@@ -298,15 +370,29 @@ export function StockLineChart({
}); });
let scrollTimeout: number | undefined; let scrollTimeout: number | undefined;
chart.timeScale().subscribeVisibleLogicalRangeChange((range) => { const handleVisibleLogicalRangeChange = (range: {
from: number;
to: number;
} | null) => {
if (!range || !initialLoadCompleteRef.current) return; if (!range || !initialLoadCompleteRef.current) return;
if (range.from >= 10) return;
const barsInfo = candleSeries.barsInLogicalRange(range);
if (!barsInfo) return;
if (
Number.isFinite(barsInfo.barsBefore) &&
barsInfo.barsBefore > HISTORY_LOAD_TRIGGER_BARS_BEFORE
) {
return;
}
if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout); if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout);
scrollTimeout = window.setTimeout(() => { scrollTimeout = window.setTimeout(() => {
void loadMoreHandlerRef.current(); void loadMoreHandlerRef.current();
}, 250); }, 250);
}); };
chart
.timeScale()
.subscribeVisibleLogicalRangeChange(handleVisibleLogicalRangeChange);
chartRef.current = chart; chartRef.current = chart;
candleSeriesRef.current = candleSeries; candleSeriesRef.current = candleSeries;
@@ -330,6 +416,9 @@ export function StockLineChart({
return () => { return () => {
if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout); if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout);
chart
.timeScale()
.unsubscribeVisibleLogicalRangeChange(handleVisibleLogicalRangeChange);
window.cancelAnimationFrame(rafId); window.cancelAnimationFrame(rafId);
resizeObserver.disconnect(); resizeObserver.disconnect();
chart.remove(); chart.remove();
@@ -386,6 +475,8 @@ export function StockLineChart({
if (!symbol || !credentials) return; if (!symbol || !credentials) return;
initialLoadCompleteRef.current = false; initialLoadCompleteRef.current = false;
pendingFitContentRef.current = true;
autoFillLeftGapRef.current = false;
let disposed = false; let disposed = false;
let initialLoadTimer: number | null = null; let initialLoadTimer: number | null = null;
@@ -401,16 +492,24 @@ export function StockLineChart({
? firstPage.nextCursor ? firstPage.nextCursor
: null; : null;
// 분봉은 기본 3페이지까지 순차 조회해 오전 구간 누락을 줄입니다. // 분봉은 시간프레임별 목표 봉 수까지 순차 조회해 초기 과거 가시성을 보강합니다.
if ( if (
isMinuteTimeframe(timeframe) && isMinuteTimeframe(timeframe) &&
firstPage.hasMore && firstPage.hasMore &&
firstPage.nextCursor firstPage.nextCursor
) { ) {
const targetBars = resolveInitialMinuteTargetBars(timeframe);
const maxPrefetchPages = resolveInitialMinutePrefetchPages(timeframe);
const prefetchStartedAt = Date.now();
let minuteCursor: string | null = firstPage.nextCursor; let minuteCursor: string | null = firstPage.nextCursor;
let extraPageCount = 0; let extraPageCount = 0;
while (minuteCursor && extraPageCount < 2) { while (
minuteCursor &&
extraPageCount < maxPrefetchPages &&
Date.now() - prefetchStartedAt < INITIAL_MINUTE_PREFETCH_BUDGET_MS &&
mergedBars.length < targetBars
) {
try { try {
const olderPage = await fetchStockChart( const olderPage = await fetchStockChart(
symbol, symbol,
@@ -421,10 +520,14 @@ export function StockLineChart({
const olderBars = normalizeCandles(olderPage.candles, timeframe); const olderBars = normalizeCandles(olderPage.candles, timeframe);
mergedBars = mergeBars(olderBars, mergedBars); mergedBars = mergeBars(olderBars, mergedBars);
resolvedNextCursor = olderPage.hasMore const nextMinuteCursor = olderPage.hasMore
? olderPage.nextCursor ? olderPage.nextCursor
: null; : null;
minuteCursor = olderPage.hasMore ? olderPage.nextCursor : null; resolvedNextCursor = nextMinuteCursor;
minuteCursor =
nextMinuteCursor && nextMinuteCursor !== minuteCursor
? nextMinuteCursor
: null;
extraPageCount += 1; extraPageCount += 1;
} catch { } catch {
// 추가 페이지 실패는 치명적이지 않으므로 현재 데이터는 유지합니다. // 추가 페이지 실패는 치명적이지 않으므로 현재 데이터는 유지합니다.
@@ -469,10 +572,25 @@ export function StockLineChart({
if (!isChartReady) return; if (!isChartReady) return;
setSeriesData(renderableBars); setSeriesData(renderableBars);
if (!initialLoadCompleteRef.current && renderableBars.length > 0) { if (renderableBars.length === 0) return;
if (pendingFitContentRef.current) {
chartRef.current?.timeScale().fitContent();
pendingFitContentRef.current = false;
} else if (!initialLoadCompleteRef.current) {
chartRef.current?.timeScale().fitContent(); chartRef.current?.timeScale().fitContent();
} }
}, [isChartReady, renderableBars, setSeriesData]);
if (nextCursorRef.current) {
void fillLeftWhitespaceIfNeeded();
}
}, [
fillLeftWhitespaceIfNeeded,
isChartReady,
renderableBars,
setSeriesData,
timeframe,
]);
/** /**
* @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다. * @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다.
@@ -495,7 +613,7 @@ export function StockLineChart({
}, [latestTick, timeframe]); }, [latestTick, timeframe]);
/** /**
* @description 분봉(1m/30m/1h)에서는 WS 공백 상황을 대비해 최신 페이지를 주기적으로 동기화합니다. * @description 분봉(1m/5m/10m/15m/30m/1h)에서는 WS 공백 상황을 대비해 최신 페이지를 주기적으로 동기화합니다.
* @see features/trade/apis/kis-stock.api.ts fetchStockChart * @see features/trade/apis/kis-stock.api.ts fetchStockChart
* @see lib/kis/domestic.ts getDomesticChart * @see lib/kis/domestic.ts getDomesticChart
*/ */

View File

@@ -150,8 +150,7 @@ function resolveBarTimestamp(
/** /**
* 타임스탬프를 타임프레임 버킷 경계에 정렬 * 타임스탬프를 타임프레임 버킷 경계에 정렬
* - 1m: 초/밀리초를 제거해 분 경계에 정렬 * - 분봉(1/5/10/15/30/60분): 분 단위를 버킷 경계에 정렬
* - 30m/1h: 분 단위를 버킷에 정렬
* - 1d: 00:00:00 * - 1d: 00:00:00
* - 1w: 월요일 00:00:00 * - 1w: 월요일 00:00:00
*/ */
@@ -160,12 +159,14 @@ function alignTimestamp(
timeframe: DashboardChartTimeframe, timeframe: DashboardChartTimeframe,
): UTCTimestamp { ): UTCTimestamp {
const d = new Date(timestamp * 1000); const d = new Date(timestamp * 1000);
const minuteBucket = resolveMinuteBucket(timeframe);
if (timeframe === "1m") { if (minuteBucket !== null) {
d.setUTCSeconds(0, 0); d.setUTCMinutes(
} else if (timeframe === "30m" || timeframe === "1h") { Math.floor(d.getUTCMinutes() / minuteBucket) * minuteBucket,
const bucket = timeframe === "30m" ? 30 : 60; 0,
d.setUTCMinutes(Math.floor(d.getUTCMinutes() / bucket) * bucket, 0, 0); 0,
);
} else if (timeframe === "1d") { } else if (timeframe === "1d") {
d.setUTCHours(0, 0, 0, 0); d.setUTCHours(0, 0, 0, 0);
} else if (timeframe === "1w") { } else if (timeframe === "1w") {
@@ -300,7 +301,17 @@ export function formatSignedPercent(value: number) {
* 분봉 타임프레임인지 판별 * 분봉 타임프레임인지 판별
*/ */
export function isMinuteTimeframe(tf: DashboardChartTimeframe) { export function isMinuteTimeframe(tf: DashboardChartTimeframe) {
return tf === "1m" || tf === "30m" || tf === "1h"; return resolveMinuteBucket(tf) !== null;
}
function resolveMinuteBucket(tf: DashboardChartTimeframe): number | null {
if (tf === "1m") return 1;
if (tf === "5m") return 5;
if (tf === "10m") return 10;
if (tf === "15m") return 15;
if (tf === "30m") return 30;
if (tf === "1h") return 60;
return null;
} }
function normalizeTickTime(value?: string) { function normalizeTickTime(value?: string) {

View File

@@ -5,6 +5,8 @@ export const UP_COLOR = "#ef4444";
export const MINUTE_SYNC_INTERVAL_MS = 30000; export const MINUTE_SYNC_INTERVAL_MS = 30000;
export const REALTIME_STALE_THRESHOLD_MS = 12000; export const REALTIME_STALE_THRESHOLD_MS = 12000;
export const CHART_MIN_HEIGHT = 220; export const CHART_MIN_HEIGHT = 220;
export const HISTORY_LOAD_TRIGGER_BARS_BEFORE = 40;
export const INITIAL_MINUTE_PREFETCH_BUDGET_MS = 12000;
export interface ChartPalette { export interface ChartPalette {
backgroundColor: string; backgroundColor: string;
@@ -31,6 +33,9 @@ export const MINUTE_TIMEFRAMES: Array<{
label: string; label: string;
}> = [ }> = [
{ value: "1m", label: "1분" }, { value: "1m", label: "1분" },
{ value: "5m", label: "5분" },
{ value: "10m", label: "10분" },
{ value: "15m", label: "15분" },
{ value: "30m", label: "30분" }, { value: "30m", label: "30분" },
{ value: "1h", label: "1시간" }, { value: "1h", label: "1시간" },
]; ];
@@ -43,6 +48,30 @@ export const PERIOD_TIMEFRAMES: Array<{
{ value: "1w", label: "주" }, { value: "1w", label: "주" },
]; ];
export function resolveInitialMinuteTargetBars(
timeframe: DashboardChartTimeframe,
) {
if (timeframe === "1m") return 260;
if (timeframe === "5m") return 240;
if (timeframe === "10m") return 220;
if (timeframe === "15m") return 200;
if (timeframe === "30m") return 180;
if (timeframe === "1h") return 260;
return 140;
}
export function resolveInitialMinutePrefetchPages(
timeframe: DashboardChartTimeframe,
) {
if (timeframe === "1m") return 24;
if (timeframe === "5m") return 28;
if (timeframe === "10m") return 32;
if (timeframe === "15m") return 36;
if (timeframe === "30m") return 44;
if (timeframe === "1h") return 80;
return 20;
}
/** /**
* @description 브랜드 CSS 변수에서 차트 팔레트를 읽어옵니다. * @description 브랜드 CSS 변수에서 차트 팔레트를 읽어옵니다.
* @see features/trade/components/chart/StockLineChart.tsx 차트 생성/테마 반영 * @see features/trade/components/chart/StockLineChart.tsx 차트 생성/테마 반영

View File

@@ -51,7 +51,7 @@ export function HoldingsPanel({ credentials }: HoldingsPanelProps) {
try { try {
const data = await fetchDashboardBalance(credentials); const data = await fetchDashboardBalance(credentials);
setSummary(data.summary); setSummary(data.summary);
setHoldings(data.holdings); setHoldings(data.holdings.filter((item) => item.quantity > 0));
} catch (err) { } catch (err) {
setError( setError(
err instanceof Error err instanceof Error
@@ -185,9 +185,10 @@ export function HoldingsPanel({ credentials }: HoldingsPanelProps) {
{!isLoading && !error && holdings.length > 0 && ( {!isLoading && !error && holdings.length > 0 && (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
{/* 테이블 헤더 */} {/* 테이블 헤더 */}
<div className="grid min-w-[600px] grid-cols-[2fr_1fr_1fr_1fr_1fr_1fr] border-b border-border/50 bg-muted/15 px-4 py-1.5 text-[11px] font-medium text-muted-foreground dark:border-brand-800/35 dark:bg-brand-900/20 dark:text-brand-100/65"> <div className="grid min-w-[700px] grid-cols-[2fr_1fr_1fr_1fr_1fr_1fr_1fr] border-b border-border/50 bg-muted/15 px-4 py-1.5 text-[11px] font-medium text-muted-foreground dark:border-brand-800/35 dark:bg-brand-900/20 dark:text-brand-100/65">
<div></div> <div></div>
<div className="text-right"></div> <div className="text-right"></div>
<div className="text-right"></div>
<div className="text-right"></div> <div className="text-right"></div>
<div className="text-right"></div> <div className="text-right"></div>
<div className="text-right"></div> <div className="text-right"></div>
@@ -238,7 +239,7 @@ function SummaryItem({
/** 보유 종목 행 */ /** 보유 종목 행 */
function HoldingRow({ holding }: { holding: DashboardHoldingItem }) { function HoldingRow({ holding }: { holding: DashboardHoldingItem }) {
return ( return (
<div className="grid min-w-[600px] grid-cols-[2fr_1fr_1fr_1fr_1fr_1fr] items-center border-b border-border/30 px-4 py-2 text-xs hover:bg-muted/20 dark:border-brand-800/25 dark:hover:bg-brand-900/20"> <div className="grid min-w-[700px] grid-cols-[2fr_1fr_1fr_1fr_1fr_1fr_1fr] items-center border-b border-border/30 px-4 py-2 text-xs hover:bg-muted/20 dark:border-brand-800/25 dark:hover:bg-brand-900/20">
{/* 종목명 */} {/* 종목명 */}
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-medium text-foreground dark:text-brand-50"> <p className="truncate font-medium text-foreground dark:text-brand-50">
@@ -254,6 +255,11 @@ function HoldingRow({ holding }: { holding: DashboardHoldingItem }) {
{fmt(holding.quantity)} {fmt(holding.quantity)}
</div> </div>
{/* 매도가능수량 */}
<div className="text-right tabular-nums text-foreground dark:text-brand-50">
{fmt(holding.sellableQuantity)}
</div>
{/* 평균단가 */} {/* 평균단가 */}
<div className="text-right tabular-nums text-foreground dark:text-brand-50"> <div className="text-right tabular-nums text-foreground dark:text-brand-50">
{fmt(holding.averagePrice)} {fmt(holding.averagePrice)}

View File

@@ -15,6 +15,7 @@ import { cn } from "@/lib/utils";
interface TradeDashboardContentProps { interface TradeDashboardContentProps {
selectedStock: DashboardStockItem | null; selectedStock: DashboardStockItem | null;
matchedHolding?: DashboardHoldingItem | null; matchedHolding?: DashboardHoldingItem | null;
availableCashBalance: number | null;
verifiedCredentials: KisRuntimeCredentials | null; verifiedCredentials: KisRuntimeCredentials | null;
latestTick: DashboardRealtimeTradeTick | null; latestTick: DashboardRealtimeTradeTick | null;
recentTradeTicks: DashboardRealtimeTradeTick[]; recentTradeTicks: DashboardRealtimeTradeTick[];
@@ -31,6 +32,7 @@ interface TradeDashboardContentProps {
export function TradeDashboardContent({ export function TradeDashboardContent({
selectedStock, selectedStock,
matchedHolding, matchedHolding,
availableCashBalance,
verifiedCredentials, verifiedCredentials,
latestTick, latestTick,
recentTradeTicks, recentTradeTicks,
@@ -78,8 +80,10 @@ export function TradeDashboardContent({
} }
orderForm={ orderForm={
<OrderForm <OrderForm
key={selectedStock?.symbol ?? "order-form-empty"}
stock={selectedStock ?? undefined} stock={selectedStock ?? undefined}
matchedHolding={matchedHolding} matchedHolding={matchedHolding}
availableCashBalance={availableCashBalance}
/> />
} }
isChartVisible={isChartVisible} isChartVisible={isChartVisible}

View File

@@ -1,11 +1,12 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types"; import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { fetchOrderableCashEstimate } from "@/features/trade/apis/kis-stock.api";
import { useOrder } from "@/features/trade/hooks/useOrder"; import { useOrder } from "@/features/trade/hooks/useOrder";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store"; import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
import type { import type {
@@ -18,6 +19,7 @@ import { cn } from "@/lib/utils";
interface OrderFormProps { interface OrderFormProps {
stock?: DashboardStockItem; stock?: DashboardStockItem;
matchedHolding?: DashboardHoldingItem | null; matchedHolding?: DashboardHoldingItem | null;
availableCashBalance?: number | null;
} }
/** /**
@@ -25,7 +27,11 @@ interface OrderFormProps {
* @see features/trade/hooks/useOrder.ts - placeOrder 주문 API 호출 * @see features/trade/hooks/useOrder.ts - placeOrder 주문 API 호출
* @see features/trade/components/layout/TradeDashboardContent.tsx - orderForm prop으로 DashboardLayout에 전달 * @see features/trade/components/layout/TradeDashboardContent.tsx - orderForm prop으로 DashboardLayout에 전달
*/ */
export function OrderForm({ stock, matchedHolding }: OrderFormProps) { export function OrderForm({
stock,
matchedHolding,
availableCashBalance = null,
}: OrderFormProps) {
const verifiedCredentials = useKisRuntimeStore( const verifiedCredentials = useKisRuntimeStore(
(state) => state.verifiedCredentials, (state) => state.verifiedCredentials,
); );
@@ -37,6 +43,69 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
); );
const [quantity, setQuantity] = useState<string>(""); const [quantity, setQuantity] = useState<string>("");
const [activeTab, setActiveTab] = useState<"buy" | "sell">("buy"); const [activeTab, setActiveTab] = useState<"buy" | "sell">("buy");
const [orderableCash, setOrderableCash] = useState<number | null>(null);
const [isOrderableCashLoading, setIsOrderableCashLoading] = useState(false);
const stockSymbol = stock?.symbol ?? null;
const sellableQuantity = matchedHolding?.sellableQuantity ?? 0;
const hasSellableQuantity = sellableQuantity > 0;
const effectiveOrderableCash = orderableCash ?? availableCashBalance ?? null;
// [Effect] 종목/가격 변경 시 매수가능금액(주문가능 예수금)을 다시 조회합니다.
useEffect(() => {
if (activeTab !== "buy") return;
if (!stockSymbol || !verifiedCredentials) {
const resetTimerId = window.setTimeout(() => {
setOrderableCash(null);
setIsOrderableCashLoading(false);
}, 0);
return () => {
window.clearTimeout(resetTimerId);
};
}
const priceNum = parseInt(price.replace(/,/g, ""), 10);
if (Number.isNaN(priceNum) || priceNum <= 0) {
const resetTimerId = window.setTimeout(() => {
setOrderableCash(null);
setIsOrderableCashLoading(false);
}, 0);
return () => {
window.clearTimeout(resetTimerId);
};
}
let cancelled = false;
const timerId = window.setTimeout(() => {
setIsOrderableCashLoading(true);
void fetchOrderableCashEstimate(
{
symbol: stockSymbol,
price: priceNum,
orderType: "limit",
},
verifiedCredentials,
)
.then((response) => {
if (cancelled) return;
setOrderableCash(Math.max(0, Math.floor(response.orderableCash)));
})
.catch(() => {
if (cancelled) return;
// 조회 실패 시 대시보드 예수금 스냅샷을 fallback으로 사용합니다.
setOrderableCash(null);
})
.finally(() => {
if (cancelled) return;
setIsOrderableCashLoading(false);
});
}, 250);
return () => {
cancelled = true;
window.clearTimeout(timerId);
};
}, [activeTab, stockSymbol, verifiedCredentials, price]);
// ========== ORDER HANDLER ========== // ========== ORDER HANDLER ==========
/** /**
@@ -56,6 +125,31 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
alert("수량을 올바르게 입력해 주세요."); alert("수량을 올바르게 입력해 주세요.");
return; return;
} }
if (side === "buy" && effectiveOrderableCash !== null) {
const requestedAmount = priceNum * qtyNum;
if (requestedAmount > effectiveOrderableCash) {
alert(
`주문가능 예수금(${effectiveOrderableCash.toLocaleString("ko-KR")}원)을 초과했습니다.`,
);
return;
}
}
if (side === "sell") {
if (!matchedHolding) {
alert("보유 종목 정보가 없어 매도 주문을 진행할 수 없습니다.");
return;
}
if (sellableQuantity <= 0) {
alert("매도가능수량이 0주입니다. 체결/정산 상태를 확인해 주세요.");
return;
}
if (qtyNum > sellableQuantity) {
alert(`매도가능수량(${sellableQuantity.toLocaleString("ko-KR")}주)을 초과했습니다.`);
return;
}
}
if (!verifiedCredentials.accountNo) { if (!verifiedCredentials.accountNo) {
alert("계좌번호가 없습니다. 설정에서 계좌번호를 입력해 주세요."); alert("계좌번호가 없습니다. 설정에서 계좌번호를 입력해 주세요.");
return; return;
@@ -96,11 +190,34 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
const ratio = Number.parseInt(pct.replace("%", ""), 10) / 100; const ratio = Number.parseInt(pct.replace("%", ""), 10) / 100;
if (!Number.isFinite(ratio) || ratio <= 0) return; if (!Number.isFinite(ratio) || ratio <= 0) return;
// UI 흐름: 비율 버튼 클릭 -> 주문가능 예수금 기준 계산(매수 탭) -> 주문수량 입력값 반영
if (activeTab === "buy") {
const priceNum = parseInt(price.replace(/,/g, ""), 10);
if (Number.isNaN(priceNum) || priceNum <= 0) {
alert("가격을 먼저 입력해 주세요.");
return;
}
if (effectiveOrderableCash === null || effectiveOrderableCash <= 0) {
alert("주문가능 예수금을 확인할 수 없어 비율 계산을 할 수 없습니다.");
return;
}
const calculatedQuantity = Math.floor((effectiveOrderableCash * ratio) / priceNum);
if (calculatedQuantity <= 0) {
alert("선택한 비율로 주문 가능한 수량이 없습니다. 가격 또는 비율을 조정해 주세요.");
return;
}
setQuantity(String(calculatedQuantity));
return;
}
// UI 흐름: 비율 버튼 클릭 -> 보유수량 기준 계산(매도 탭) -> 주문수량 입력값 반영 // UI 흐름: 비율 버튼 클릭 -> 보유수량 기준 계산(매도 탭) -> 주문수량 입력값 반영
if (activeTab === "sell" && matchedHolding?.quantity) { if (activeTab === "sell" && hasSellableQuantity) {
const calculatedQuantity = Math.max( const calculatedQuantity = Math.max(
1, 1,
Math.floor(matchedHolding.quantity * ratio), Math.floor(sellableQuantity * ratio),
); );
setQuantity(String(calculatedQuantity)); setQuantity(String(calculatedQuantity));
} }
@@ -108,6 +225,12 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
const isMarketDataAvailable = Boolean(stock); const isMarketDataAvailable = Boolean(stock);
const isBuy = activeTab === "buy"; const isBuy = activeTab === "buy";
const buyOrderableValue =
isOrderableCashLoading
? "조회 중..."
: effectiveOrderableCash === null
? "- KRW"
: `${effectiveOrderableCash.toLocaleString("ko-KR")}`;
return ( return (
<div className="h-full bg-background p-3 dark:bg-brand-950/55 sm:p-4"> <div className="h-full bg-background p-3 dark:bg-brand-950/55 sm:p-4">
@@ -179,9 +302,18 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
disabled={!isMarketDataAvailable} disabled={!isMarketDataAvailable}
hasError={Boolean(error)} hasError={Boolean(error)}
errorMessage={error} errorMessage={error}
orderableValue={buyOrderableValue}
/> />
<p className="text-[11px] text-muted-foreground dark:text-brand-100/65">
.
</p>
<PercentButtons onSelect={setPercent} /> <PercentButtons onSelect={setPercent} />
<div className="mt-auto space-y-2.5 sm:space-y-3"> <div className="mt-auto space-y-2.5 sm:space-y-3">
{!matchedHolding && (
<p className="text-xs text-muted-foreground dark:text-brand-100/70">
.
</p>
)}
<HoldingInfoPanel holding={matchedHolding} /> <HoldingInfoPanel holding={matchedHolding} />
<Button <Button
className="h-11 w-full rounded-lg bg-red-600 text-base font-bold text-white shadow-[0_4px_14px_rgba(220,38,38,0.4)] ring-1 ring-red-300/30 transition-all hover:bg-red-700 hover:shadow-[0_4px_20px_rgba(220,38,38,0.5)] dark:bg-red-500 dark:ring-red-300/40 dark:hover:bg-red-400 sm:h-12 sm:text-lg" className="h-11 w-full rounded-lg bg-red-600 text-base font-bold text-white shadow-[0_4px_14px_rgba(220,38,38,0.4)] ring-1 ring-red-300/30 transition-all hover:bg-red-700 hover:shadow-[0_4px_20px_rgba(220,38,38,0.5)] dark:bg-red-500 dark:ring-red-300/40 dark:hover:bg-red-400 sm:h-12 sm:text-lg"
@@ -212,19 +344,29 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
disabled={!isMarketDataAvailable} disabled={!isMarketDataAvailable}
hasError={Boolean(error)} hasError={Boolean(error)}
errorMessage={error} errorMessage={error}
orderableValue={
matchedHolding
? `${sellableQuantity.toLocaleString("ko-KR")}`
: "- 주"
}
/> />
<PercentButtons onSelect={setPercent} /> <PercentButtons onSelect={setPercent} />
<div className="mt-auto space-y-2.5 sm:space-y-3"> <div className="mt-auto space-y-2.5 sm:space-y-3">
<HoldingInfoPanel holding={matchedHolding} /> <HoldingInfoPanel holding={matchedHolding} />
<Button <Button
className="h-11 w-full rounded-lg bg-blue-600 text-base font-bold text-white shadow-[0_4px_14px_rgba(37,99,235,0.4)] ring-1 ring-blue-300/30 transition-all hover:bg-blue-700 hover:shadow-[0_4px_20px_rgba(37,99,235,0.5)] dark:bg-blue-500 dark:ring-blue-300/40 dark:hover:bg-blue-400 sm:h-12 sm:text-lg" className="h-11 w-full rounded-lg bg-blue-600 text-base font-bold text-white shadow-[0_4px_14px_rgba(37,99,235,0.4)] ring-1 ring-blue-300/30 transition-all hover:bg-blue-700 hover:shadow-[0_4px_20px_rgba(37,99,235,0.5)] dark:bg-blue-500 dark:ring-blue-300/40 dark:hover:bg-blue-400 sm:h-12 sm:text-lg"
disabled={isLoading || !isMarketDataAvailable} disabled={
isLoading ||
!isMarketDataAvailable ||
!matchedHolding ||
!hasSellableQuantity
}
onClick={() => handleOrder("sell")} onClick={() => handleOrder("sell")}
> >
{isLoading ? ( {isLoading ? (
<Loader2 className="mr-2 animate-spin" /> <Loader2 className="mr-2 animate-spin" />
) : ( ) : (
"매도하기" hasSellableQuantity ? "매도하기" : "매도가능수량 없음"
)} )}
</Button> </Button>
</div> </div>
@@ -248,6 +390,7 @@ function OrderInputs({
disabled, disabled,
hasError, hasError,
errorMessage, errorMessage,
orderableValue,
}: { }: {
type: "buy" | "sell"; type: "buy" | "sell";
price: string; price: string;
@@ -258,6 +401,7 @@ function OrderInputs({
disabled: boolean; disabled: boolean;
hasError: boolean; hasError: boolean;
errorMessage: string | null; errorMessage: string | null;
orderableValue: string;
}) { }) {
const labelClass = const labelClass =
"text-xs font-medium text-foreground/80 dark:text-brand-100/80 min-w-[56px]"; "text-xs font-medium text-foreground/80 dark:text-brand-100/80 min-w-[56px]";
@@ -272,7 +416,7 @@ function OrderInputs({
</span> </span>
<span className="font-medium text-foreground dark:text-brand-50"> <span className="font-medium text-foreground dark:text-brand-50">
- {type === "buy" ? "KRW" : "주"} {orderableValue}
</span> </span>
</div> </div>
@@ -366,6 +510,10 @@ function HoldingInfoPanel({
</p> </p>
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs"> <div className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
<HoldingInfoRow label="보유수량" value={`${holding.quantity.toLocaleString("ko-KR")}`} /> <HoldingInfoRow label="보유수량" value={`${holding.quantity.toLocaleString("ko-KR")}`} />
<HoldingInfoRow
label="매도가능수량"
value={`${holding.sellableQuantity.toLocaleString("ko-KR")}`}
/>
<HoldingInfoRow <HoldingInfoRow
label="평균단가" label="평균단가"
value={`${holding.averagePrice.toLocaleString("ko-KR")}`} value={`${holding.averagePrice.toLocaleString("ko-KR")}`}

View File

@@ -160,7 +160,7 @@ export function BookSideRows({
</span> </span>
<span <span
className={cn( className={cn(
"w-[50px] shrink-0 text-right text-[10px] tabular-nums xl:w-[58px]", "w-[48px] shrink-0 text-right text-[10px] tabular-nums xl:w-[56px]",
getChangeToneClass(row.changeValue), getChangeToneClass(row.changeValue),
)} )}
> >
@@ -168,6 +168,14 @@ export function BookSideRows({
? "-" ? "-"
: fmtSignedChange(row.changeValue)} : fmtSignedChange(row.changeValue)}
</span> </span>
<span
className={cn(
"w-[52px] shrink-0 text-right text-[10px] tabular-nums xl:w-[58px]",
getChangeToneClass(row.changeRate),
)}
>
{row.changeRate === null ? "-" : fmtPct(row.changeRate)}
</span>
</div> </div>
<div className="relative flex items-center justify-start overflow-hidden px-1"> <div className="relative flex items-center justify-start overflow-hidden px-1">

View File

@@ -9,6 +9,7 @@ export interface BookRow {
price: number; price: number;
size: number; size: number;
changeValue: number | null; changeValue: number | null;
changeRate: number | null;
isHighlighted: boolean; isHighlighted: boolean;
} }
@@ -166,11 +167,13 @@ export function buildBookRows({
const price = side === "ask" ? level.askPrice : level.bidPrice; const price = side === "ask" ? level.askPrice : level.bidPrice;
const size = side === "ask" ? level.askSize : level.bidSize; const size = side === "ask" ? level.askSize : level.bidSize;
const changeValue = resolvePriceChange(price, basePrice); const changeValue = resolvePriceChange(price, basePrice);
const changeRate = resolvePriceChangeRate(price, basePrice);
return { return {
price, price,
size: Math.max(size, 0), size: Math.max(size, 0),
changeValue, changeValue,
changeRate,
isHighlighted: latestPrice > 0 && price === latestPrice, isHighlighted: latestPrice > 0 && price === latestPrice,
} satisfies BookRow; } satisfies BookRow;
}); });
@@ -208,3 +211,10 @@ function resolvePriceChange(price: number, basePrice: number) {
} }
return price - basePrice; return price - basePrice;
} }
function resolvePriceChangeRate(price: number, basePrice: number) {
if (price <= 0 || basePrice <= 0) {
return null;
}
return ((price - basePrice) / basePrice) * 100;
}

View File

@@ -34,7 +34,15 @@ export interface StockCandlePoint {
timestamp?: number; timestamp?: number;
} }
export type DashboardChartTimeframe = "1m" | "30m" | "1h" | "1d" | "1w"; export type DashboardChartTimeframe =
| "1m"
| "5m"
| "10m"
| "15m"
| "30m"
| "1h"
| "1d"
| "1w";
/** /**
* 호가창 1레벨(가격 + 잔량) * 호가창 1레벨(가격 + 잔량)
@@ -168,6 +176,29 @@ export interface DashboardRealtimeTradeTick {
export type DashboardOrderSide = "buy" | "sell"; export type DashboardOrderSide = "buy" | "sell";
export type DashboardOrderType = "limit" | "market"; export type DashboardOrderType = "limit" | "market";
/**
* 국내주식 매수가능금액 조회 요청 모델
*/
export interface DashboardStockOrderableCashRequest {
symbol: string;
price: number;
orderType?: DashboardOrderType;
}
/**
* 국내주식 매수가능금액 조회 응답 모델
*/
export interface DashboardStockOrderableCashResponse {
ok: boolean;
tradingEnv: KisTradingEnv;
orderableCash: number;
noReceivableBuyAmount: number;
maxBuyAmount: number;
maxBuyQuantity: number;
noReceivableBuyQuantity: number;
fetchedAt: string;
}
/** /**
* 국내주식 현금 주문 요청 모델 * 국내주식 현금 주문 요청 모델
*/ */

View File

@@ -0,0 +1,649 @@
/**
* [파일 역할]
* 구독형 CLI(Codex/Gemini)로 전략/신호 JSON을 생성하는 Provider 계층입니다.
*
* [주요 책임]
* - vendor 선택(auto/codex/gemini) 및 실행
* - vendor별 model 옵션 적용(--model)
* - 실행 결과를 파싱 가능한 형태로 반환(vendor/model/attempts)
*/
import { spawn } from "node:child_process";
import { existsSync, readdirSync } from "node:fs";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
AutotradeCompiledStrategy,
AutotradeMarketSnapshot,
AutotradeTechniqueId,
} from "@/features/autotrade/types/autotrade.types";
type CliVendor = "gemini" | "codex";
type CliVendorPreference = "auto" | CliVendor;
const DEFAULT_TIMEOUT_MS = 60_000;
export interface SubscriptionCliAttemptInfo {
vendor: CliVendor;
model: string | null;
status: "ok" | "empty_output" | "process_error" | "timeout";
command?: string;
detail?: string;
}
export interface SubscriptionCliParsedResult {
parsed: unknown | null;
vendor: CliVendor | null;
model: string | null;
attempts: SubscriptionCliAttemptInfo[];
}
const ATTEMPT_STATUS_LABEL: Record<SubscriptionCliAttemptInfo["status"], string> = {
ok: "ok",
empty_output: "empty",
process_error: "error",
timeout: "timeout",
};
// [목적] 구독형 CLI(codex/gemini)로 전략 JSON을 생성합니다.
export async function compileStrategyWithSubscriptionCli(params: {
prompt: string;
selectedTechniques: AutotradeTechniqueId[];
confidenceThreshold: number;
}) {
const result = await compileStrategyWithSubscriptionCliDetailed(params);
return result.parsed;
}
// [목적] 구독형 CLI(codex/gemini) 전략 생성 + vendor/시도 정보 추적
export async function compileStrategyWithSubscriptionCliDetailed(params: {
prompt: string;
selectedTechniques: AutotradeTechniqueId[];
confidenceThreshold: number;
preferredVendor?: CliVendorPreference;
preferredModel?: string;
}): Promise<SubscriptionCliParsedResult> {
const prompt = [
"너는 자동매매 전략 컴파일러다.",
"반드시 JSON 객체만 출력한다. 코드블록/설명문 금지.",
"summary는 반드시 한국어 1문장으로 작성하고, 사용자 프롬프트의 핵심 단어를 1개 이상 포함한다.",
"summary에 'provided prompt', '테스트 목적', 'sample/example' 같은 추상 문구만 쓰지 않는다.",
'필수 키: summary, confidenceThreshold, maxDailyOrders, cooldownSec, maxOrderAmountRatio',
"제약: confidenceThreshold(0.45~0.95), maxDailyOrders(1~200), cooldownSec(10~600), maxOrderAmountRatio(0.05~1)",
`입력: ${JSON.stringify(params)}`,
].join("\n");
const execution = await executeSubscriptionCliPrompt(prompt, {
preferredVendor: params.preferredVendor,
preferredModel: params.preferredModel,
});
if (!execution.output) {
return {
parsed: null,
vendor: execution.vendor,
model: execution.model,
attempts: execution.attempts,
};
}
return {
parsed: extractJsonObject(execution.output),
vendor: execution.vendor,
model: execution.model,
attempts: execution.attempts,
};
}
// [목적] 구독형 CLI(codex/gemini)로 신호 JSON을 생성합니다.
export async function generateSignalWithSubscriptionCli(params: {
prompt: string;
strategy: AutotradeCompiledStrategy;
snapshot: AutotradeMarketSnapshot;
}) {
const result = await generateSignalWithSubscriptionCliDetailed(params);
return result.parsed;
}
// [목적] 구독형 CLI(codex/gemini) 신호 생성 + vendor/시도 정보 추적
export async function generateSignalWithSubscriptionCliDetailed(params: {
prompt: string;
strategy: AutotradeCompiledStrategy;
snapshot: AutotradeMarketSnapshot;
preferredVendor?: CliVendorPreference;
preferredModel?: string;
}): Promise<SubscriptionCliParsedResult> {
const prompt = [
"너는 자동매매 신호 생성기다.",
"반드시 JSON 객체만 출력한다. 코드블록/설명문 금지.",
"필수 키: signal, confidence, reason, ttlSec, riskFlags",
"signal은 buy/sell/hold, confidence는 0~1",
"reason은 반드시 한국어 한 문장으로 작성한다.",
"operatorPrompt가 비어있지 않으면 strategy.summary보다 우선 참고하되, 무리한 진입은 금지한다.",
"tradeVolume은 단일 체결 수량일 수 있으니 accumulatedVolume/recentTradeCount/recentTradeVolumeSum/liquidityDepth/orderBookImbalance를 함께 보고 유동성을 판단한다.",
"tickTime/requestAtKst/marketDataLatencySec으로 데이터 시차를 확인하고, 시차가 크면 공격적 진입을 피한다.",
"recentTradeVolumes/recentNetBuyTrail/recentTickAgesSec으로 체결 흐름의 연속성을 확인한다.",
"topLevelOrderBookImbalance와 buySellExecutionRatio를 함께 보고 단기 수급 왜곡을 판단한다.",
"recentMinuteCandles와 minutePatternContext가 있으면 직전 3~10봉 추세와 최근 2~8봉 압축 구간을 먼저 판별한다.",
"minutePatternContext.impulseDirection/impulseChangeRate/consolidationRangePercent/consolidationVolumeRatio/breakoutUpper/breakoutLower를 함께 보고 박스권 돌파/이탈 가능성을 판단한다.",
"budgetContext가 있으면 estimatedBuyableQuantity(예산 기준 최대 매수 가능 주수)를 넘는 과도한 매수는 피한다.",
"portfolioContext가 있으면 sellableQuantity(실매도 가능 수량)가 0일 때는 sell을 피한다.",
"executionCostProfile이 있으면 매도 수수료/세금까지 포함한 순손익 관점으로 판단한다.",
"압축 구간이 불명확하거나 박스 내부 중간값이면 hold를 우선한다.",
"확신이 낮거나 위험하면 hold를 반환한다.",
`입력: ${JSON.stringify({
operatorPrompt: params.prompt,
strategy: params.strategy,
snapshot: params.snapshot,
})}`,
].join("\n");
const execution = await executeSubscriptionCliPrompt(prompt, {
preferredVendor: params.preferredVendor,
preferredModel: params.preferredModel,
});
if (!execution.output) {
return {
parsed: null,
vendor: execution.vendor,
model: execution.model,
attempts: execution.attempts,
};
}
return {
parsed: extractJsonObject(execution.output),
vendor: execution.vendor,
model: execution.model,
attempts: execution.attempts,
};
}
// [목적] API 응답/로그에서 codex/gemini 실제 실행 흐름을 사람이 읽기 쉬운 문자열로 제공합니다.
export function summarizeSubscriptionCliExecution(execution: {
vendor: CliVendor | null;
model: string | null;
attempts: SubscriptionCliAttemptInfo[];
}) {
const selectedVendor = execution.vendor ? execution.vendor : "none";
const selectedModel = execution.model ?? "default";
const attemptsText =
execution.attempts.length > 0
? execution.attempts
.map((attempt) => {
const model = attempt.model ?? "default";
const detail = attempt.detail ? `(${sanitizeAttemptDetail(attempt.detail)})` : "";
return `${attempt.vendor}:${model}:${ATTEMPT_STATUS_LABEL[attempt.status]}${detail}`;
})
.join(", ")
: "none";
return `selected=${selectedVendor}:${selectedModel}; attempts=${attemptsText}`;
}
async function executeSubscriptionCliPrompt(
prompt: string,
options?: {
preferredVendor?: CliVendorPreference;
preferredModel?: string;
},
) {
// auto 모드: codex 우선 시도 후 gemini로 폴백
const preferredVendor = normalizeCliVendorPreference(options?.preferredVendor);
const mode = preferredVendor ?? normalizeCliVendorPreference(process.env.AUTOTRADE_SUBSCRIPTION_CLI) ?? "auto";
const vendors: CliVendor[] =
mode === "gemini" ? ["gemini"] : mode === "codex" ? ["codex"] : ["codex", "gemini"];
const attempts: SubscriptionCliAttemptInfo[] = [];
const preferredModel = normalizeCliModel(options?.preferredModel);
const debugEnabled = isSubscriptionCliDebugEnabled();
if (debugEnabled) {
console.info(
`[autotrade-cli] mode=${mode} preferredVendor=${preferredVendor ?? "none"} preferredModel=${preferredModel ?? "default"} prompt="${toPromptPreview(prompt)}"`,
);
}
for (const vendor of vendors) {
// [Step 1] vendor별 모델 선택: vendor 전용 변수 -> 공통 변수 순서로 해석
const model = preferredModel ?? resolveSubscriptionCliModel(vendor);
// [Step 2] CLI 실행 후 시도 결과를 누적합니다.
const result = await runCliVendor({ vendor, prompt, model });
attempts.push(result.attempt);
if (debugEnabled) {
console.info(
`[autotrade-cli] vendor=${vendor} model=${model ?? "default"} status=${result.attempt.status} command=${result.attempt.command ?? vendor} detail=${result.attempt.detail ?? "-"}`,
);
}
if (result.output) {
return {
output: result.output,
vendor,
model,
attempts,
};
}
}
return {
output: null,
vendor: null,
model: null,
attempts,
};
}
async function runCliVendor(params: {
vendor: CliVendor;
prompt: string;
model: string | null;
}) {
const { vendor, prompt, model } = params;
const commands = resolveCliCommandCandidates(vendor);
// gemini는 headless prompt 옵션으로 직접 결과를 stdout으로 받습니다.
if (vendor === "gemini") {
const tempDir = await mkdtemp(join(tmpdir(), "autotrade-gemini-"));
try {
// [안전 설정]
// - approvalMode=plan으로 전역 설정된 환경에서도 비대화형 호출이 깨지지 않도록 default를 명시합니다.
// - 실행 CWD는 임시 디렉터리(tempDir)로 고정해, 도구 호출이 발생해도 프로젝트 소스 수정 위험을 줄입니다.
const args = ["-p", prompt, "--output-format", "text", "--approval-mode", "default"];
if (model) {
// 공식 문서 기준: Gemini CLI는 --model(-m)로 모델 지정 지원
args.push("--model", model);
}
const commandResult = await runProcessWithCommandCandidates(commands, args, {
cwd: tempDir,
// Windows에서 gemini는 npm shim(.cmd) 실행을 고려해 shell 모드를 사용합니다.
shell: process.platform === "win32",
});
const result = commandResult.result;
const output = result.stdout.trim();
if (output) {
// 출력이 있으면 우선 파싱 단계로 넘깁니다.
return {
output,
attempt: {
vendor,
model,
status: "ok",
command: commandResult.command,
} satisfies SubscriptionCliAttemptInfo,
};
}
if (result.exitCode !== 0) {
const status = result.stderr.includes("[timeout]") ? "timeout" : "process_error";
return {
output: null,
attempt: {
vendor,
model,
status,
command: commandResult.command,
detail: resolveAttemptDetail(result),
} satisfies SubscriptionCliAttemptInfo,
};
}
return {
output: null,
attempt: {
vendor,
model,
status: "empty_output",
command: commandResult.command,
detail: resolveAttemptDetail(result),
} satisfies SubscriptionCliAttemptInfo,
};
} finally {
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}
const tempDir = await mkdtemp(join(tmpdir(), "autotrade-codex-"));
const outputPath = join(tempDir, "last-message.txt");
try {
// codex는 마지막 메시지를 파일로 받아 안정적으로 파싱합니다.
const args = ["exec", "--skip-git-repo-check", "--sandbox", "read-only"];
if (model) {
// 공식 문서 기준: Codex CLI는 exec에서 --model(-m) 옵션으로 모델 지정 지원
args.push("--model", model);
}
const codexEffortOverride = resolveCodexReasoningEffortOverride(model);
if (codexEffortOverride) {
// gpt-5-codex는 xhigh를 지원하지 않아 high로 강제합니다.
args.push("-c", `model_reasoning_effort=\"${codexEffortOverride}\"`);
}
args.push("-o", outputPath, prompt);
const commandResult = await runProcessWithCommandCandidates(commands, args, {
cwd: tempDir,
// codex는 prompt 문자열을 인자로 안정 전달해야 하므로 shell 모드를 사용하지 않습니다.
shell: false,
});
const result = commandResult.result;
const output = await readFile(outputPath, "utf-8")
.then((value) => value.trim())
.catch(() => result.stdout.trim());
if (!output) {
if (result.exitCode !== 0) {
const status = result.stderr.includes("[timeout]") ? "timeout" : "process_error";
return {
output: null,
attempt: {
vendor,
model,
status,
command: commandResult.command,
detail: resolveAttemptDetail(result),
} satisfies SubscriptionCliAttemptInfo,
};
}
return {
output: null,
attempt: {
vendor,
model,
status: "empty_output",
command: commandResult.command,
detail: resolveAttemptDetail(result),
} satisfies SubscriptionCliAttemptInfo,
};
}
return {
output,
attempt: {
vendor,
model,
status: "ok",
command: commandResult.command,
} satisfies SubscriptionCliAttemptInfo,
};
} catch (error) {
const detail = error instanceof Error ? error.message : "unexpected_error";
return {
output: null,
attempt: {
vendor,
model,
status: "process_error",
detail,
} satisfies SubscriptionCliAttemptInfo,
};
} finally {
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}
function runProcess(
command: string,
args: string[],
options?: {
cwd?: string;
shell?: boolean;
},
) {
return new Promise<{
stdout: string;
stderr: string;
exitCode: number;
spawnErrorCode: string | null;
spawnErrorMessage: string | null;
}>((resolve) => {
const child = spawn(command, args, {
cwd: options?.cwd,
shell: options?.shell ?? false,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let killedByTimeout = false;
let spawnErrorCode: string | null = null;
let spawnErrorMessage: string | null = null;
let completed = false;
const timeoutMs = resolveCliTimeoutMs();
const resolveOnce = (payload: {
stdout: string;
stderr: string;
exitCode: number;
spawnErrorCode: string | null;
spawnErrorMessage: string | null;
}) => {
if (completed) return;
completed = true;
resolve(payload);
};
const timer = setTimeout(() => {
killedByTimeout = true;
child.kill("SIGKILL");
}, timeoutMs);
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("error", (error) => {
clearTimeout(timer);
spawnErrorCode =
typeof (error as NodeJS.ErrnoException).code === "string"
? (error as NodeJS.ErrnoException).code!
: "SPAWN_ERROR";
spawnErrorMessage = error.message;
resolveOnce({
stdout,
stderr,
exitCode: 1,
spawnErrorCode,
spawnErrorMessage,
});
});
child.on("close", (code) => {
clearTimeout(timer);
resolveOnce({
stdout,
stderr: killedByTimeout ? `${stderr}\n[timeout]` : stderr,
exitCode: typeof code === "number" ? code : 1,
spawnErrorCode,
spawnErrorMessage,
});
});
});
}
function resolveCliTimeoutMs() {
const raw = Number.parseInt(process.env.AUTOTRADE_SUBSCRIPTION_CLI_TIMEOUT_MS ?? "", 10);
if (!Number.isFinite(raw)) return DEFAULT_TIMEOUT_MS;
return Math.max(5_000, Math.min(120_000, raw));
}
function resolveSubscriptionCliModel(vendor: CliVendor) {
// [Step 1] vendor별 전용 모델 환경변수를 우선 적용합니다.
const vendorSpecific =
vendor === "codex" ? process.env.AUTOTRADE_CODEX_MODEL : process.env.AUTOTRADE_GEMINI_MODEL;
const normalizedVendorSpecific = normalizeCliModel(vendorSpecific);
if (normalizedVendorSpecific) {
return normalizedVendorSpecific;
}
// [Step 2] 공통 모델 환경변수(AUTOTRADE_SUBSCRIPTION_CLI_MODEL)를 fallback으로 사용합니다.
return normalizeCliModel(process.env.AUTOTRADE_SUBSCRIPTION_CLI_MODEL);
}
function resolveCodexReasoningEffortOverride(model: string | null) {
const normalized = model?.trim().toLowerCase();
if (!normalized) return null;
if (normalized === "gpt-5-codex") return "high";
return null;
}
function normalizeCliVendorPreference(raw: string | undefined): CliVendorPreference | null {
const normalized = raw?.trim().toLowerCase();
if (normalized === "codex") return "codex";
if (normalized === "gemini") return "gemini";
if (normalized === "auto") return "auto";
return null;
}
function normalizeCliModel(raw: string | undefined) {
const normalized = raw?.trim();
return normalized ? normalized : null;
}
function sanitizeAttemptDetail(detail: string) {
return detail.replace(/\s+/g, " ").trim().slice(0, 180);
}
function resolveAttemptDetail(result: {
stderr: string;
spawnErrorCode: string | null;
spawnErrorMessage: string | null;
}) {
if (result.spawnErrorCode) {
const message = result.spawnErrorMessage
? `spawn:${result.spawnErrorCode} ${result.spawnErrorMessage}`
: `spawn:${result.spawnErrorCode}`;
return sanitizeAttemptDetail(message);
}
const stderr = result.stderr.trim();
if (!stderr) return undefined;
return sanitizeAttemptDetail(stderr);
}
function resolveCliCommandCandidates(vendor: CliVendor) {
const envCommand =
vendor === "codex"
? normalizeCliCommand(process.env.AUTOTRADE_CODEX_COMMAND)
: normalizeCliCommand(process.env.AUTOTRADE_GEMINI_COMMAND);
const windowsNpmCommand = vendor === "gemini" ? resolveWindowsNpmCommand(vendor) : null;
const windowsCodexExecutable = vendor === "codex" ? resolveWindowsCodexExecutable() : null;
const baseCommand = vendor;
const candidates = [envCommand, baseCommand, windowsCodexExecutable, windowsNpmCommand].filter(
(value): value is string => Boolean(value),
);
return Array.from(new Set(candidates));
}
async function runProcessWithCommandCandidates(
commands: string[],
args: string[],
options?: {
cwd?: string;
shell?: boolean;
},
) {
let lastResult: Awaited<ReturnType<typeof runProcess>> | null = null;
let lastCommand: string | null = null;
for (const command of commands) {
const result = await runProcess(command, args, options);
lastResult = result;
lastCommand = command;
if (result.spawnErrorCode !== "ENOENT") {
break;
}
}
if (!lastResult || !lastCommand) {
throw new Error("CLI command resolution failed");
}
return { command: lastCommand, result: lastResult };
}
function normalizeCliCommand(raw: string | undefined) {
const normalized = raw?.trim();
return normalized ? normalized : null;
}
function resolveWindowsNpmCommand(vendor: CliVendor) {
if (process.platform !== "win32") return null;
const appData = process.env.APPDATA;
if (!appData) return null;
const fileName = vendor === "codex" ? "codex.cmd" : "gemini.cmd";
const candidate = join(appData, "npm", fileName);
return existsSync(candidate) ? candidate : null;
}
function resolveWindowsCodexExecutable() {
if (process.platform !== "win32") return null;
const userProfile = process.env.USERPROFILE;
if (!userProfile) return null;
const extensionRoot = join(userProfile, ".vscode", "extensions");
if (!existsSync(extensionRoot)) return null;
const candidates = readdirSync(extensionRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith("openai.chatgpt-"))
.map((entry) => join(extensionRoot, entry.name, "bin", "windows-x86_64", "codex.exe"))
.filter((candidate) => existsSync(candidate));
return candidates[0] ?? null;
}
function isSubscriptionCliDebugEnabled() {
const value = process.env.AUTOTRADE_SUBSCRIPTION_CLI_DEBUG?.trim().toLowerCase();
return value === "1" || value === "true" || value === "yes" || value === "on";
}
function toPromptPreview(prompt: string) {
return prompt.replace(/\s+/g, " ").trim().slice(0, 180);
}
function extractJsonObject(raw: string) {
// CLI 출력이 코드블록/설명문을 섞어도 첫 JSON 객체를 최대한 추출합니다.
const trimmed = raw.trim();
if (!trimmed) return null;
const fenced = trimmed.match(/```json\s*([\s\S]*?)```/i)?.[1];
if (fenced) {
try {
return JSON.parse(fenced) as unknown;
} catch {
// no-op
}
}
try {
return JSON.parse(trimmed) as unknown;
} catch {
// no-op
}
const firstBrace = trimmed.indexOf("{");
if (firstBrace < 0) return null;
let depth = 0;
for (let index = firstBrace; index < trimmed.length; index += 1) {
const char = trimmed[index];
if (char === "{") depth += 1;
if (char === "}") depth -= 1;
if (depth !== 0) continue;
const candidate = trimmed.slice(firstBrace, index + 1);
try {
return JSON.parse(candidate) as unknown;
} catch {
return null;
}
}
return null;
}

View File

@@ -0,0 +1,56 @@
export type ExecutableOrderSide = "buy" | "sell";
export interface ClampExecutableOrderQuantityParams {
side: ExecutableOrderSide;
requestedQuantity: number;
maxBuyQuantity?: number;
holdingQuantity?: number;
sellableQuantity?: number;
}
export interface ClampExecutableOrderQuantityResult {
ok: boolean;
quantity: number;
adjusted: boolean;
}
/**
* [목적]
* 주문 전 계좌 제약(매수가능/보유/매도가능)에 맞춰 최종 실행 수량을 순수 계산합니다.
*
* [입력 -> 처리 -> 결과]
* 요청 수량/사이드/제약값 -> 가능 최대 수량으로 clamp -> 실행 가능 여부와 수량 반환
*/
export function clampExecutableOrderQuantity(
params: ClampExecutableOrderQuantityParams,
): ClampExecutableOrderQuantityResult {
const requestedQuantity = Math.max(0, Math.floor(params.requestedQuantity));
if (requestedQuantity <= 0) {
return {
ok: false,
quantity: 0,
adjusted: false,
};
}
if (params.side === "buy") {
const maxBuyQuantity = Math.max(0, Math.floor(params.maxBuyQuantity ?? 0));
const quantity = Math.min(requestedQuantity, maxBuyQuantity);
return {
ok: quantity > 0,
quantity,
adjusted: quantity !== requestedQuantity,
};
}
const holdingQuantity = Math.max(0, Math.floor(params.holdingQuantity ?? 0));
const sellableQuantity = Math.max(0, Math.floor(params.sellableQuantity ?? 0));
const maxSellQuantity = Math.min(holdingQuantity, sellableQuantity);
const quantity = Math.min(requestedQuantity, maxSellQuantity);
return {
ok: quantity > 0,
quantity,
adjusted: quantity !== requestedQuantity,
};
}

View File

@@ -0,0 +1,94 @@
export interface AutotradeExecutionCostProfile {
buyFeeRate: number;
sellFeeRate: number;
sellTaxRate: number;
}
export interface AutotradeEstimatedOrderCost {
side: "buy" | "sell";
price: number;
quantity: number;
grossAmount: number;
feeAmount: number;
taxAmount: number;
netAmount: number;
}
export function resolveExecutionCostProfile(): AutotradeExecutionCostProfile {
return {
buyFeeRate: readRateFromEnv("AUTOTRADE_BUY_FEE_RATE", 0.00015),
sellFeeRate: readRateFromEnv("AUTOTRADE_SELL_FEE_RATE", 0.00015),
sellTaxRate: readRateFromEnv("AUTOTRADE_SELL_TAX_RATE", 0.0018),
};
}
export function estimateBuyUnitCost(
price: number,
profile: AutotradeExecutionCostProfile,
) {
const safePrice = Math.max(0, price);
if (safePrice <= 0) return 0;
return safePrice * (1 + Math.max(0, profile.buyFeeRate));
}
export function estimateSellNetUnit(
price: number,
profile: AutotradeExecutionCostProfile,
) {
const safePrice = Math.max(0, price);
if (safePrice <= 0) return 0;
const totalRate = Math.max(0, profile.sellFeeRate) + Math.max(0, profile.sellTaxRate);
return safePrice * (1 - totalRate);
}
export function estimateOrderCost(params: {
side: "buy" | "sell";
price: number;
quantity: number;
profile: AutotradeExecutionCostProfile;
}): AutotradeEstimatedOrderCost {
const safePrice = Math.max(0, params.price);
const safeQuantity = Math.max(0, Math.floor(params.quantity));
const grossAmount = safePrice * safeQuantity;
if (params.side === "buy") {
const feeAmount = grossAmount * Math.max(0, params.profile.buyFeeRate);
return {
side: params.side,
price: safePrice,
quantity: safeQuantity,
grossAmount,
feeAmount,
taxAmount: 0,
netAmount: grossAmount + feeAmount,
};
}
const feeAmount = grossAmount * Math.max(0, params.profile.sellFeeRate);
const taxAmount = grossAmount * Math.max(0, params.profile.sellTaxRate);
return {
side: params.side,
price: safePrice,
quantity: safeQuantity,
grossAmount,
feeAmount,
taxAmount,
netAmount: grossAmount - feeAmount - taxAmount,
};
}
function readRateFromEnv(envName: string, fallback: number) {
const raw = Number.parseFloat(process.env[envName] ?? "");
if (!Number.isFinite(raw)) return fallback;
if (raw >= 1 && raw <= 100) {
return clamp(raw / 100, 0, 0.5);
}
return clamp(raw, 0, 0.5);
}
function clamp(value: number, min: number, max: number) {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, value));
}

293
lib/autotrade/openai.ts Normal file
View File

@@ -0,0 +1,293 @@
/**
* [파일 역할]
* OpenAI Chat Completions를 이용해 자동매매 전략/신호 JSON을 생성하는 유틸입니다.
*
* [주요 책임]
* - OpenAI 연결 가능 여부 확인
* - 전략 compile 응답 스키마 검증
* - 신호 generate 응답 스키마 검증
*/
import { z } from "zod";
import type {
AutotradeCompiledStrategy,
AutotradeMarketSnapshot,
AutotradeSignalCandidate,
AutotradeTechniqueId,
} from "@/features/autotrade/types/autotrade.types";
const OPENAI_CHAT_COMPLETIONS_URL = "https://api.openai.com/v1/chat/completions";
const compileResultSchema = z.object({
summary: z.string().min(1).max(320),
confidenceThreshold: z.number().min(0.45).max(0.95),
maxDailyOrders: z.number().int().min(1).max(200),
cooldownSec: z.number().int().min(10).max(600),
maxOrderAmountRatio: z.number().min(0.05).max(1),
});
const signalResultSchema = z.object({
signal: z.enum(["buy", "sell", "hold"]),
confidence: z.number().min(0).max(1),
reason: z.string().min(1).max(160),
ttlSec: z.number().int().min(5).max(300),
riskFlags: z.array(z.string()).max(10).default([]),
proposedOrder: z
.object({
symbol: z.string().trim().regex(/^\d{6}$/),
side: z.enum(["buy", "sell"]),
orderType: z.enum(["limit", "market"]),
price: z.number().positive().optional(),
quantity: z.number().int().positive().optional(),
})
.optional(),
});
export function isOpenAiConfigured() {
return Boolean(process.env.OPENAI_API_KEY?.trim());
}
// [목적] 자연어 프롬프트 + 기법 선택을 실행 가능한 전략 JSON으로 변환합니다.
export async function compileStrategyWithOpenAi(params: {
prompt: string;
selectedTechniques: AutotradeTechniqueId[];
confidenceThreshold: number;
}) {
// [Step 1] 전략 컴파일용 JSON 스키마 + 프롬프트를 구성합니다.
const response = await callOpenAiJson({
schemaName: "autotrade_strategy_compile",
schema: {
type: "object",
additionalProperties: false,
properties: {
summary: { type: "string" },
confidenceThreshold: { type: "number" },
maxDailyOrders: { type: "integer" },
cooldownSec: { type: "integer" },
maxOrderAmountRatio: { type: "number" },
},
required: [
"summary",
"confidenceThreshold",
"maxDailyOrders",
"cooldownSec",
"maxOrderAmountRatio",
],
},
messages: [
{
role: "system",
content:
"너는 자동매매 전략 컴파일러다. 설명문 없이 JSON만 반환하고 리스크를 보수적으로 설정한다.",
},
{
role: "user",
content: JSON.stringify(
{
prompt: params.prompt,
selectedTechniques: params.selectedTechniques,
techniqueGuide: {
orb: "시가 범위 돌파 추세",
vwap_reversion: "VWAP 평균회귀",
volume_breakout: "거래량 동반 돌파",
ma_crossover: "단기/중기 이평 교차",
gap_breakout: "갭 이후 돌파/이탈",
intraday_box_reversion: "당일 상승 후 박스권 상하단 단타",
intraday_breakout_scalp:
"1분봉 상승 추세에서 눌림 후 재돌파(거래량 재유입) 단타",
},
baselineConfidenceThreshold: params.confidenceThreshold,
rules: [
"confidenceThreshold는 0.45~0.95",
"maxDailyOrders는 1~200",
"cooldownSec는 10~600",
"maxOrderAmountRatio는 0.05~1",
],
},
null,
2,
),
},
],
});
// [Step 2] 응답이 없거나 스키마 불일치면 null로 반환해 상위 라우트가 fallback을 적용하게 합니다.
if (!response) return null;
const parsed = compileResultSchema.safeParse(response);
if (!parsed.success) {
return null;
}
return parsed.data;
}
// [목적] 시세 스냅샷을 기반으로 buy/sell/hold 후보 신호 JSON을 생성합니다.
export async function generateSignalWithOpenAi(params: {
prompt: string;
strategy: AutotradeCompiledStrategy;
snapshot: AutotradeMarketSnapshot;
}) {
// [Step 1] 전략 + 시세 스냅샷을 신호 생성 JSON 스키마에 맞춰 전달합니다.
const response = await callOpenAiJson({
schemaName: "autotrade_signal_candidate",
schema: {
type: "object",
additionalProperties: false,
properties: {
signal: {
type: "string",
enum: ["buy", "sell", "hold"],
},
confidence: { type: "number" },
reason: { type: "string" },
ttlSec: { type: "integer" },
riskFlags: {
type: "array",
items: { type: "string" },
},
proposedOrder: {
type: "object",
additionalProperties: false,
properties: {
symbol: { type: "string" },
side: {
type: "string",
enum: ["buy", "sell"],
},
orderType: {
type: "string",
enum: ["limit", "market"],
},
price: { type: "number" },
quantity: { type: "integer" },
},
required: ["symbol", "side", "orderType"],
},
},
required: ["signal", "confidence", "reason", "ttlSec", "riskFlags"],
},
messages: [
{
role: "system",
content:
"너는 주문 실행기가 아니라 신호 생성기다. JSON 외 텍스트를 출력하지 말고, 근거 없는 공격적 신호를 피한다. 스냅샷의 체결/호가/모멘텀/박스권 지표와 최근 1분봉 구조를 함께 보고 판단한다.",
},
{
role: "user",
content: JSON.stringify(
{
operatorPrompt: params.prompt,
strategy: {
summary: params.strategy.summary,
selectedTechniques: params.strategy.selectedTechniques,
confidenceThreshold: params.strategy.confidenceThreshold,
maxDailyOrders: params.strategy.maxDailyOrders,
cooldownSec: params.strategy.cooldownSec,
},
snapshot: params.snapshot,
constraints: [
"reason은 한국어 한 줄로 작성",
"확신이 낮으면 hold를 우선 선택",
"operatorPrompt에 세부 규칙이 있으면 strategy.summary보다 우선 참고하되, 리스크 보수성은 유지",
"spreadRate(호가 스프레드), dayRangePosition(당일 범위 위치), volumeRatio(체결량 비율), intradayMomentum(단기 모멘텀), recentReturns(최근 수익률 흐름)을 함께 고려",
"tradeVolume은 단일 틱 체결 수량일 수 있으므로 accumulatedVolume, recentTradeCount, recentTradeVolumeSum, liquidityDepth(호가 총잔량), orderBookImbalance를 함께 보고 유동성을 판단",
"tickTime/requestAtKst/marketDataLatencySec을 보고 데이터 시차가 큰 경우 과감한 진입을 피한다",
"recentTradeVolumes/recentNetBuyTrail/recentTickAgesSec에서 체결 흐름의 연속성(매수 우위 지속/이탈)을 확인한다",
"topLevelOrderBookImbalance와 buySellExecutionRatio를 함께 보고 상단 호가에서의 단기 수급 왜곡을 확인한다",
"recentMinuteCandles와 minutePatternContext가 있으면 직전 3~10봉 추세와 최근 2~8봉 압축 구간을 우선 판별한다",
"minutePatternContext.impulseDirection/impulseChangeRate/consolidationRangePercent/consolidationVolumeRatio/breakoutUpper/breakoutLower를 함께 보고 박스 압축 후 돌파/이탈 가능성을 판단한다",
"budgetContext가 있으면 estimatedBuyableQuantity(예산 기준 최대 매수 가능 주수)를 초과하는 공격적 진입을 피한다",
"portfolioContext가 있으면 sellableQuantity(실제 매도 가능 수량)가 0인 상태에서는 sell 신호를 매우 보수적으로 본다",
"executionCostProfile이 있으면 매도 시 수수료+세금을 고려한 순손익 관점으로 판단한다",
"압축 구간이 애매하거나 박스 내부 중간값이면 추격 진입 대신 hold를 우선한다",
"intraday_breakout_scalp 기법이 포함되면 상승 추세, 눌림 깊이, 재돌파 거래량 확인이 동시에 만족될 때만 buy를 고려",
],
},
null,
2,
),
},
],
});
// [Step 2] 응답이 없거나 스키마 불일치면 null 반환 -> 상위 라우트가 fallback 신호로 대체합니다.
if (!response) return null;
const parsed = signalResultSchema.safeParse(response);
if (!parsed.success) {
return null;
}
return {
...parsed.data,
source: "openai",
} satisfies AutotradeSignalCandidate;
}
async function callOpenAiJson(params: {
schemaName: string;
schema: Record<string, unknown>;
messages: Array<{ role: "system" | "user"; content: string }>;
}) {
// [데이터 흐름] route.ts -> callOpenAiJson -> OpenAI chat/completions -> JSON parse -> route.ts 반환
const apiKey = process.env.OPENAI_API_KEY?.trim();
if (!apiKey) return null;
const model = process.env.AUTOTRADE_AI_MODEL?.trim() || "gpt-4o-mini";
let response: Response;
try {
// [Step 1] OpenAI Chat Completions(JSON Schema 강제) 호출
response = await fetch(OPENAI_CHAT_COMPLETIONS_URL, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
temperature: 0.2,
response_format: {
type: "json_schema",
json_schema: {
name: params.schemaName,
strict: true,
schema: params.schema,
},
},
messages: params.messages,
}),
cache: "no-store",
});
} catch {
return null;
}
let payload: unknown;
try {
// [Step 2] HTTP 응답 JSON 파싱
payload = await response.json();
} catch {
return null;
}
if (!response.ok || !payload || typeof payload !== "object") {
return null;
}
const content =
(payload as { choices?: Array<{ message?: { content?: string } }> }).choices?.[0]?.message
?.content ?? "";
if (!content.trim()) {
return null;
}
try {
// [Step 3] 모델이 반환한 JSON 문자열을 실제 객체로 변환
return JSON.parse(content) as unknown;
} catch {
return null;
}
}

289
lib/autotrade/risk.ts Normal file
View File

@@ -0,0 +1,289 @@
import type {
AutotradeCompiledStrategy,
AutotradeSignalCandidate,
AutotradeValidationResult,
} from "@/features/autotrade/types/autotrade.types";
interface RiskEnvelopeInput {
cashBalance: number;
allocationPercent: number;
allocationAmount: number;
dailyLossPercent: number;
dailyLossAmount: number;
}
interface ValidationCashBalanceInput {
cashBalance: number;
orderableCash?: number | null;
}
type ValidationCashBalanceSource = "cash_balance" | "orderable_cash" | "min_of_both" | "none";
export interface ValidationCashBalanceResult {
cashBalance: number;
source: ValidationCashBalanceSource;
originalCashBalance: number;
originalOrderableCash: number;
}
interface SignalGateInput {
signal: AutotradeSignalCandidate;
strategy: AutotradeCompiledStrategy;
validation: AutotradeValidationResult;
dailyOrderCount: number;
lastOrderAtMs?: number;
nowMs: number;
}
export function clampNumber(value: number, min: number, max: number) {
if (Number.isNaN(value)) return min;
return Math.min(max, Math.max(min, value));
}
export function calculateEffectiveAllocationAmount(
cashBalance: number,
allocationPercent: number,
allocationAmount: number,
) {
const safeCashBalance = Math.floor(Math.max(0, cashBalance));
const safeAllocationAmount = Math.floor(Math.max(0, allocationAmount));
const safeAllocationPercent = clampNumber(allocationPercent, 0, 100);
if (safeCashBalance <= 0 || safeAllocationPercent <= 0) {
return 0;
}
// 설정창 입력(비율/금액)을 동시에 상한으로 사용합니다.
const allocationByPercent = Math.floor((safeCashBalance * safeAllocationPercent) / 100);
return Math.min(safeCashBalance, safeAllocationAmount, allocationByPercent);
}
export function resolveValidationCashBalance(
input: ValidationCashBalanceInput,
): ValidationCashBalanceResult {
const normalizedCashBalance = Math.floor(Math.max(0, input.cashBalance));
const normalizedOrderableCash = Math.floor(Math.max(0, input.orderableCash ?? 0));
if (normalizedCashBalance > 0 && normalizedOrderableCash > 0) {
return {
cashBalance: Math.min(normalizedCashBalance, normalizedOrderableCash),
source: "min_of_both",
originalCashBalance: normalizedCashBalance,
originalOrderableCash: normalizedOrderableCash,
};
}
if (normalizedOrderableCash > 0) {
return {
cashBalance: normalizedOrderableCash,
source: "orderable_cash",
originalCashBalance: normalizedCashBalance,
originalOrderableCash: normalizedOrderableCash,
};
}
if (normalizedCashBalance > 0) {
return {
cashBalance: normalizedCashBalance,
source: "cash_balance",
originalCashBalance: normalizedCashBalance,
originalOrderableCash: normalizedOrderableCash,
};
}
return {
cashBalance: 0,
source: "none",
originalCashBalance: normalizedCashBalance,
originalOrderableCash: normalizedOrderableCash,
};
}
export function calculateEffectiveDailyLossLimit(
effectiveAllocationAmount: number,
dailyLossPercent: number,
dailyLossAmount: number,
) {
const safeDailyLossAmount = Math.floor(Math.max(0, dailyLossAmount));
if (safeDailyLossAmount <= 0) {
return 0;
}
const safeDailyLossPercent = clampNumber(dailyLossPercent, 0, 100);
if (safeDailyLossPercent <= 0) {
return safeDailyLossAmount;
}
const limitByPercent = Math.floor(
(Math.max(0, effectiveAllocationAmount) * safeDailyLossPercent) / 100,
);
return Math.min(safeDailyLossAmount, Math.max(0, limitByPercent));
}
export function buildRiskEnvelope(input: RiskEnvelopeInput) {
const blockedReasons: string[] = [];
const warnings: string[] = [];
if (input.allocationAmount <= 0) {
blockedReasons.push("투자 금액은 0보다 커야 합니다.");
}
if (input.allocationPercent <= 0) {
blockedReasons.push("투자 비율은 0%보다 커야 합니다.");
}
if (input.dailyLossAmount <= 0) {
blockedReasons.push("일일 손실 금액은 0보다 커야 합니다.");
}
if (input.cashBalance <= 0) {
blockedReasons.push("가용 자산이 0원이라 자동매매를 시작할 수 없습니다.");
}
const effectiveAllocationAmount = calculateEffectiveAllocationAmount(
input.cashBalance,
input.allocationPercent,
input.allocationAmount,
);
const effectiveDailyLossLimit = calculateEffectiveDailyLossLimit(
effectiveAllocationAmount,
input.dailyLossPercent,
input.dailyLossAmount,
);
if (effectiveAllocationAmount <= 0) {
blockedReasons.push("실적용 투자금이 0원으로 계산되었습니다.");
}
if (effectiveDailyLossLimit <= 0) {
blockedReasons.push("실적용 손실 한도가 0원으로 계산되었습니다.");
}
if (input.allocationAmount > input.cashBalance) {
warnings.push("입력한 투자금이 가용자산보다 커서 가용자산 한도로 자동 보정됩니다.");
}
if (effectiveDailyLossLimit > effectiveAllocationAmount) {
warnings.push("손실 금액이 투자금보다 큽니다. 손실 기준을 투자금 이하로 설정하는 것을 권장합니다.");
}
if (input.dailyLossPercent <= 0) {
warnings.push("손실 비율이 0%라 참고 비율 경고를 계산하지 않습니다.");
}
const allocationReferenceAmount =
input.allocationPercent > 0
? (Math.max(0, input.cashBalance) * Math.max(0, input.allocationPercent)) / 100
: 0;
if (allocationReferenceAmount > 0 && input.allocationAmount > allocationReferenceAmount) {
warnings.push(
`입력 투자금이 비율 상한(${input.allocationPercent}%) 기준 ${Math.floor(allocationReferenceAmount).toLocaleString("ko-KR")}원을 초과해 비율 상한으로 적용됩니다.`,
);
}
const dailyLossReferenceAmount =
input.dailyLossPercent > 0
? (Math.max(0, effectiveAllocationAmount) * Math.max(0, input.dailyLossPercent)) / 100
: 0;
if (dailyLossReferenceAmount > 0 && input.dailyLossAmount > dailyLossReferenceAmount) {
warnings.push(
`입력 손실금이 참고 비율(${input.dailyLossPercent}%) 기준 ${Math.floor(dailyLossReferenceAmount).toLocaleString("ko-KR")}원을 초과합니다.`,
);
}
if (input.allocationPercent > 100) {
warnings.push("투자 비율이 100%를 초과했습니다. 가용자산 기준으로 자동 보정됩니다.");
}
if (input.dailyLossPercent > 20) {
warnings.push("일일 손실 비율이 높습니다. 기본값 2% 수준을 권장합니다.");
}
return {
isValid: blockedReasons.length === 0,
blockedReasons,
warnings,
cashBalance: Math.max(0, Math.floor(input.cashBalance)),
effectiveAllocationAmount,
effectiveDailyLossLimit,
} satisfies AutotradeValidationResult;
}
export function evaluateSignalBlockers(input: SignalGateInput) {
const blockers: string[] = [];
const { signal, strategy, validation, dailyOrderCount, lastOrderAtMs, nowMs } = input;
if (!validation.isValid) {
blockers.push("리스크 검증이 통과되지 않았습니다.");
}
if (!signal.reason.trim()) {
blockers.push("AI 신호 근거(reason)가 비어 있습니다.");
}
if (signal.confidence < strategy.confidenceThreshold) {
blockers.push(
`신뢰도 ${signal.confidence.toFixed(2)}가 임계치 ${strategy.confidenceThreshold.toFixed(2)}보다 낮습니다.`,
);
}
if (signal.riskFlags.some((flag) => flag.toLowerCase().includes("block"))) {
blockers.push("신호에 차단 플래그가 포함되어 있습니다.");
}
if (dailyOrderCount >= strategy.maxDailyOrders) {
blockers.push("일일 최대 주문 건수를 초과했습니다.");
}
if (lastOrderAtMs && nowMs - lastOrderAtMs < strategy.cooldownSec * 1000) {
blockers.push("종목 쿨다운 시간 안에서는 신규 주문을 차단합니다.");
}
return blockers;
}
export function resolveOrderQuantity(params: {
side: "buy" | "sell";
price: number;
requestedQuantity?: number;
effectiveAllocationAmount: number;
maxOrderAmountRatio: number;
unitCost?: number;
}) {
if (params.side === "sell") {
const requestedQuantity = Math.max(0, Math.floor(params.requestedQuantity ?? 0));
// 매도는 예산 제한이 아니라 보유/매도가능 수량 제한을 우선 적용합니다.
return requestedQuantity > 0 ? requestedQuantity : 1;
}
const price = Math.max(0, params.price);
if (!price) return 0;
const unitCost = Math.max(price, params.unitCost ?? price);
const effectiveAllocationAmount = Math.max(0, params.effectiveAllocationAmount);
const maxOrderAmount =
effectiveAllocationAmount * clampNumber(params.maxOrderAmountRatio, 0.05, 1);
const affordableQuantity = Math.floor(maxOrderAmount / unitCost);
if (affordableQuantity <= 0) {
// 국내주식은 1주 단위 주문이라, 전체 예산으로 1주를 살 수 있으면
// 과도하게 낮은 주문 비율 때문에 0주로 막히지 않도록 최소 1주를 허용합니다.
const fullBudgetQuantity = Math.floor(effectiveAllocationAmount / unitCost);
if (fullBudgetQuantity <= 0) {
return 0;
}
if (!params.requestedQuantity || params.requestedQuantity <= 0) {
return 1;
}
return Math.min(Math.max(1, params.requestedQuantity), fullBudgetQuantity);
}
if (!params.requestedQuantity || params.requestedQuantity <= 0) {
return Math.max(1, affordableQuantity);
}
return Math.max(1, Math.min(params.requestedQuantity, affordableQuantity));
}

424
lib/autotrade/strategy.ts Normal file
View File

@@ -0,0 +1,424 @@
/**
* [파일 역할]
* 자동매매 기본 전략값/폴백 신호 생성 로직을 제공하는 핵심 유틸입니다.
*
* [주요 책임]
* - AI 실패/비활성 상황에서도 동작할 fallback 전략 생성
* - 시세 기반 fallback 신호(buy/sell/hold) 계산
* - 자동매매 설정 기본값 제공
*/
import {
AUTOTRADE_DEFAULT_TECHNIQUES,
type AutotradeCompiledStrategy,
type AutotradeMarketSnapshot,
type AutotradeSignalCandidate,
type AutotradeSetupFormValues,
type AutotradeTechniqueId,
} from "@/features/autotrade/types/autotrade.types";
import { clampNumber } from "@/lib/autotrade/risk";
interface CompileFallbackInput {
prompt: string;
selectedTechniques: AutotradeTechniqueId[];
confidenceThreshold: number;
}
export function createFallbackCompiledStrategy(
input: CompileFallbackInput,
): AutotradeCompiledStrategy {
// [Step 1] 사용자가 입력한 프롬프트를 요약해 최소 전략 설명문을 만듭니다.
const summary =
input.prompt.trim().length > 0
? `프롬프트 기반 전략: ${input.prompt.trim().slice(0, 120)}`
: "기본 보수형 자동매매 전략";
// [Step 2] AI 없이도 동작 가능한 보수형 기본 파라미터를 반환합니다.
return {
provider: "fallback",
summary,
selectedTechniques: input.selectedTechniques,
confidenceThreshold: clampNumber(input.confidenceThreshold, 0.45, 0.95),
maxDailyOrders: resolveMaxDailyOrders(),
cooldownSec: 60,
maxOrderAmountRatio: 0.25,
createdAt: new Date().toISOString(),
};
}
export function createFallbackSignalCandidate(params: {
strategy: AutotradeCompiledStrategy;
snapshot: AutotradeMarketSnapshot;
}): AutotradeSignalCandidate {
// [Step 1] 선택한 기법별로 buy/sell 점수를 계산합니다.
const { strategy, snapshot } = params;
const activeTechniques =
strategy.selectedTechniques.length > 0
? strategy.selectedTechniques
: AUTOTRADE_DEFAULT_TECHNIQUES;
const reasons: string[] = [];
let buyScore = 0;
let sellScore = 0;
const shortMa = average(snapshot.recentPrices.slice(-3));
const longMa = average(snapshot.recentPrices.slice(-7));
const vwap = average(snapshot.recentPrices);
const gapRate = snapshot.open
? ((snapshot.currentPrice - snapshot.open) / snapshot.open) * 100
: 0;
if (activeTechniques.includes("ma_crossover")) {
if (shortMa > longMa * 1.001) {
buyScore += 1;
reasons.push("단기 이평이 중기 이평 위로 교차했습니다.");
} else if (shortMa < longMa * 0.999) {
sellScore += 1;
reasons.push("단기 이평이 중기 이평 아래로 교차했습니다.");
}
}
if (activeTechniques.includes("vwap_reversion") && vwap > 0) {
if (snapshot.currentPrice < vwap * 0.995) {
buyScore += 1;
reasons.push("가격이 VWAP 대비 과매도 구간입니다.");
} else if (snapshot.currentPrice > vwap * 1.005) {
sellScore += 1;
reasons.push("가격이 VWAP 대비 과매수 구간입니다.");
}
}
if (activeTechniques.includes("volume_breakout")) {
const volumeRatio =
snapshot.volumeRatio ??
(snapshot.accumulatedVolume > 0
? snapshot.tradeVolume / Math.max(snapshot.accumulatedVolume / 120, 1)
: 1);
if (volumeRatio > 1.8 && snapshot.changeRate > 0.25) {
buyScore += 1;
reasons.push("거래량 증가와 상승 모멘텀이 같이 나타났습니다.");
}
if (volumeRatio > 1.8 && snapshot.changeRate < -0.25) {
sellScore += 1;
reasons.push("거래량 증가와 하락 모멘텀이 같이 나타났습니다.");
}
}
if (activeTechniques.includes("gap_breakout")) {
if (gapRate > 0.8 && snapshot.currentPrice >= snapshot.high * 0.998) {
buyScore += 1;
reasons.push("상승 갭 이후 고점 돌파 구간입니다.");
} else if (gapRate < -0.8 && snapshot.currentPrice <= snapshot.low * 1.002) {
sellScore += 1;
reasons.push("하락 갭 이후 저점 이탈 구간입니다.");
}
}
if (activeTechniques.includes("orb")) {
const nearHighBreak = snapshot.currentPrice >= snapshot.high * 0.999;
const nearLowBreak = snapshot.currentPrice <= snapshot.low * 1.001;
if (nearHighBreak && snapshot.changeRate > 0) {
buyScore += 1;
reasons.push("시가 범위 상단 돌파 조건이 충족되었습니다.");
}
if (nearLowBreak && snapshot.changeRate < 0) {
sellScore += 1;
reasons.push("시가 범위 하단 이탈 조건이 충족되었습니다.");
}
}
if (activeTechniques.includes("intraday_box_reversion")) {
const boxReversion = resolveIntradayBoxReversionSignal(snapshot);
if (boxReversion.side === "buy") {
buyScore += 1.2;
reasons.push(boxReversion.reason);
} else if (boxReversion.side === "sell") {
sellScore += 1.2;
reasons.push(boxReversion.reason);
}
}
if (activeTechniques.includes("intraday_breakout_scalp")) {
const breakoutScalp = resolveIntradayBreakoutScalpSignal(snapshot);
if (breakoutScalp.side === "buy") {
buyScore += 1.3;
reasons.push(breakoutScalp.reason);
} else if (breakoutScalp.side === "sell") {
sellScore += 0.9;
reasons.push(breakoutScalp.reason);
}
}
const riskFlags: string[] = [];
if ((snapshot.marketDataLatencySec ?? 0) > 5) {
riskFlags.push("market_data_stale");
}
// [Step 2] 방향성이 부족하면 hold 신호를 우선 반환합니다.
if (buyScore === 0 && sellScore === 0) {
return {
signal: "hold",
confidence: 0.55,
reason: "선택한 기법에서 유의미한 방향 신호가 아직 없습니다.",
ttlSec: 20,
riskFlags,
source: "fallback",
};
}
const isBuy = buyScore >= sellScore;
const scoreDiff = Math.abs(buyScore - sellScore);
const confidence = clampNumber(
0.58 + scoreDiff * 0.12 + Math.min(0.12, Math.abs(snapshot.changeRate) / 10),
0.5,
0.95,
);
if (confidence < strategy.confidenceThreshold) {
riskFlags.push("confidence_low");
}
// [Step 3] 점수 우세 방향 + 신뢰도를 기반으로 주문 후보를 구성합니다.
return {
signal: isBuy ? "buy" : "sell",
confidence,
reason: reasons[0] ?? "복합 신호 점수가 기준치를 넘었습니다.",
ttlSec: 20,
riskFlags,
proposedOrder: {
symbol: snapshot.symbol,
side: isBuy ? "buy" : "sell",
orderType: "limit",
price: snapshot.currentPrice,
quantity: 1,
},
source: "fallback",
};
}
export function resolveSetupDefaults(): AutotradeSetupFormValues {
const subscriptionCliVendor = resolveDefaultSubscriptionCliVendor();
return {
aiMode: resolveDefaultAiMode(),
subscriptionCliVendor,
subscriptionCliModel: resolveDefaultSubscriptionCliModel(subscriptionCliVendor),
prompt: "",
selectedTechniques: [],
allocationPercent: 10,
allocationAmount: 500000,
dailyLossPercent: 2,
dailyLossAmount: 50000,
confidenceThreshold: resolveDefaultConfidenceThreshold(),
agreeStopOnExit: false,
};
}
function resolveDefaultAiMode(): AutotradeSetupFormValues["aiMode"] {
const raw = (process.env.AUTOTRADE_AI_MODE ?? "auto").trim().toLowerCase();
if (raw === "openai_api") return "openai_api";
if (raw === "subscription_cli") return "subscription_cli";
if (raw === "rule_fallback") return "rule_fallback";
return "auto";
}
function resolveDefaultSubscriptionCliVendor(): AutotradeSetupFormValues["subscriptionCliVendor"] {
const raw = (process.env.AUTOTRADE_SUBSCRIPTION_CLI ?? "auto").trim().toLowerCase();
if (raw === "codex") return "codex";
if (raw === "gemini") return "gemini";
return "auto";
}
function resolveDefaultSubscriptionCliModel(vendor: AutotradeSetupFormValues["subscriptionCliVendor"]) {
const commonModel = (process.env.AUTOTRADE_SUBSCRIPTION_CLI_MODEL ?? "").trim();
if (commonModel) return commonModel;
if (vendor === "codex") {
const codexModel = (process.env.AUTOTRADE_CODEX_MODEL ?? "").trim();
return codexModel || "gpt-5-codex";
}
if (vendor === "gemini") {
const geminiModel = (process.env.AUTOTRADE_GEMINI_MODEL ?? "").trim();
return geminiModel || "auto";
}
return "";
}
function resolveMaxDailyOrders() {
const raw = Number.parseInt(
process.env.AUTOTRADE_MAX_DAILY_ORDERS_DEFAULT ?? "20",
10,
);
if (!Number.isFinite(raw)) return 20;
return clampNumber(raw, 1, 200);
}
function resolveDefaultConfidenceThreshold() {
const raw = Number.parseFloat(
process.env.AUTOTRADE_CONFIDENCE_THRESHOLD_DEFAULT ?? "0.65",
);
if (!Number.isFinite(raw)) return 0.65;
return clampNumber(raw, 0.45, 0.95);
}
function average(values: number[]) {
const normalized = values.filter((value) => Number.isFinite(value) && value > 0);
if (normalized.length === 0) return 0;
const sum = normalized.reduce((acc, value) => acc + value, 0);
return sum / normalized.length;
}
function resolveIntradayBoxReversionSignal(
snapshot: AutotradeMarketSnapshot,
): { side: "buy" | "sell" | null; reason: string } {
const windowPrices = snapshot.recentPrices
.slice(-12)
.filter((price) => Number.isFinite(price) && price > 0);
if (windowPrices.length < 6) {
return { side: null, reason: "" };
}
const boxHigh = Math.max(...windowPrices);
const boxLow = Math.min(...windowPrices);
const boxRange = boxHigh - boxLow;
if (boxRange <= 0 || snapshot.currentPrice <= 0) {
return { side: null, reason: "" };
}
const dayRise = snapshot.changeRate;
const boxRangeRate = boxRange / snapshot.currentPrice;
const oscillationCount = countDirectionFlips(windowPrices);
const isRisingDay = dayRise >= 0.8;
const isBoxRange = boxRangeRate >= 0.003 && boxRangeRate <= 0.02;
const isOscillating = oscillationCount >= 2;
if (!isRisingDay || !isBoxRange || !isOscillating) {
return { side: null, reason: "" };
}
const rangePosition = clampNumber(
(snapshot.currentPrice - boxLow) / boxRange,
0,
1,
);
if (rangePosition <= 0.28) {
return {
side: "buy",
reason: `당일 상승(+${dayRise.toFixed(2)}%) 뒤 박스권 하단에서 반등 단타 조건이 확인됐습니다.`,
};
}
if (rangePosition >= 0.72) {
return {
side: "sell",
reason: `당일 상승(+${dayRise.toFixed(2)}%) 뒤 박스권 상단에서 되돌림 단타 조건이 확인됐습니다.`,
};
}
return { side: null, reason: "" };
}
function resolveIntradayBreakoutScalpSignal(
snapshot: AutotradeMarketSnapshot,
): { side: "buy" | "sell" | null; reason: string } {
// [Step 1] 1분봉 최근 구간에서 추세/눌림/돌파 판단에 필요한 표본을 준비합니다.
const prices = snapshot.recentPrices
.slice(-18)
.filter((price) => Number.isFinite(price) && price > 0);
if (prices.length < 12 || snapshot.currentPrice <= 0) {
return { side: null, reason: "" };
}
const trendBase = prices.slice(0, prices.length - 6);
const pullbackWindow = prices.slice(prices.length - 6, prices.length - 1);
if (trendBase.length < 5 || pullbackWindow.length < 4) {
return { side: null, reason: "" };
}
const trendHigh = Math.max(...trendBase);
const trendLow = Math.min(...trendBase);
const pullbackHigh = Math.max(...pullbackWindow);
const pullbackLow = Math.min(...pullbackWindow);
const pullbackRange = pullbackHigh - pullbackLow;
if (trendHigh <= 0 || pullbackRange <= 0) {
return { side: null, reason: "" };
}
const trendRiseRate = ((trendHigh - trendLow) / trendLow) * 100;
const pullbackDepthRate = ((trendHigh - pullbackLow) / trendHigh) * 100;
const pullbackRangeRate = (pullbackRange / snapshot.currentPrice) * 100;
const volumeRatio = snapshot.volumeRatio ?? 1;
const tradeStrength = snapshot.tradeStrength ?? 100;
const hasBidSupport =
(snapshot.totalBidSize ?? 0) > (snapshot.totalAskSize ?? 0) * 1.02;
const momentum = snapshot.intradayMomentum ?? 0;
// 추세 필터: 상승 구간인지 먼저 확인
const isRisingTrend =
trendRiseRate >= 0.7 && snapshot.changeRate >= 0.4 && momentum >= 0.15;
// 눌림 필터: 박스형 눌림인지 확인 (과도한 급락 제외)
const isControlledPullback =
pullbackDepthRate >= 0.2 &&
pullbackDepthRate <= 1.6 &&
pullbackRangeRate >= 0.12 &&
pullbackRangeRate <= 0.9;
if (!isRisingTrend || !isControlledPullback) {
return { side: null, reason: "" };
}
// [Step 2] 눌림 상단 재돌파 + 거래량 재유입 조건이 맞으면 매수 우세로 판단합니다.
const breakoutTrigger = snapshot.currentPrice >= pullbackHigh * 1.0007;
const breakoutConfirmed = breakoutTrigger && volumeRatio >= 1.15;
const orderflowConfirmed = tradeStrength >= 98 || hasBidSupport;
if (breakoutConfirmed && orderflowConfirmed) {
return {
side: "buy",
reason: `상승 추세(+${trendRiseRate.toFixed(2)}%)에서 눌림 상단 재돌파(거래량비 ${volumeRatio.toFixed(2)})가 확인됐습니다.`,
};
}
// [Step 3] 재돌파 실패 후 눌림 하단 이탈 시 보수적으로 매도(또는 익절/청산) 신호를 냅니다.
const failedBreakdown =
snapshot.currentPrice <= pullbackLow * 0.9995 &&
volumeRatio >= 1.05 &&
(snapshot.netBuyExecutionCount ?? 0) < 0;
if (failedBreakdown) {
return {
side: "sell",
reason: "눌림 하단 이탈로 상승 단타 시나리오가 약화되어 보수적 청산 신호를 냅니다.",
};
}
return { side: null, reason: "" };
}
function countDirectionFlips(prices: number[]) {
let flips = 0;
let prevDirection = 0;
for (let i = 1; i < prices.length; i += 1) {
const diff = prices[i] - prices[i - 1];
const direction = diff > 0 ? 1 : diff < 0 ? -1 : 0;
if (direction === 0) continue;
if (prevDirection !== 0 && direction !== prevDirection) {
flips += 1;
}
prevDirection = direction;
}
return flips;
}

View File

@@ -35,6 +35,7 @@ interface KisBalanceOutput1Row {
pdno?: string; pdno?: string;
prdt_name?: string; prdt_name?: string;
hldg_qty?: string; hldg_qty?: string;
ord_psbl_qty?: string;
pchs_avg_pric?: string; pchs_avg_pric?: string;
pchs_amt?: string; pchs_amt?: string;
prpr?: string; prpr?: string;
@@ -72,6 +73,45 @@ interface KisIndexOutputRow {
prdy_vrss_sign?: string; prdy_vrss_sign?: string;
} }
interface KisFluctuationOutputRow {
data_rank?: string;
stck_shrn_iscd?: string;
hts_kor_isnm?: string;
stck_prpr?: string;
prdy_vrss?: string;
prdy_vrss_sign?: string;
prdy_ctrt?: string;
acml_vol?: string;
acml_tr_pbmn?: string;
}
interface KisVolumeRankOutputRow {
data_rank?: string;
mksc_shrn_iscd?: string;
stck_shrn_iscd?: string;
hts_kor_isnm?: string;
stck_prpr?: string;
prdy_vrss?: string;
prdy_vrss_sign?: string;
prdy_ctrt?: string;
acml_vol?: string;
acml_tr_pbmn?: string;
}
interface KisNewsTitleOutputRow {
cntt_usiq_srno?: string;
data_dt?: string;
data_tm?: string;
hts_pbnt_titl_cntt?: string;
dorg?: string;
news_ofer_entp_code?: string;
iscd1?: string;
iscd2?: string;
iscd3?: string;
iscd4?: string;
iscd5?: string;
}
interface KisDailyCcldOutput1Row { interface KisDailyCcldOutput1Row {
ord_dt?: string; ord_dt?: string;
ord_tmd?: string; ord_tmd?: string;
@@ -133,6 +173,7 @@ export interface DomesticHoldingItem {
name: string; name: string;
market: "KOSPI" | "KOSDAQ"; market: "KOSPI" | "KOSDAQ";
quantity: number; quantity: number;
sellableQuantity: number;
averagePrice: number; averagePrice: number;
currentPrice: number; currentPrice: number;
evaluationAmount: number; evaluationAmount: number;
@@ -154,6 +195,44 @@ export interface DomesticMarketIndexResult {
changeRate: number; changeRate: number;
} }
export interface DomesticMarketRankItem {
rank: number;
symbol: string;
name: string;
market: "KOSPI" | "KOSDAQ";
price: number;
change: number;
changeRate: number;
volume: number;
tradingValue: number;
}
export interface DomesticNewsHeadlineItem {
id: string;
title: string;
source: string;
publishedAt: string;
symbols: string[];
}
export interface DomesticMarketPulse {
gainersCount: number;
losersCount: number;
popularByVolumeCount: number;
popularByValueCount: number;
newsCount: number;
}
export interface DomesticDashboardMarketHubResult {
gainers: DomesticMarketRankItem[];
losers: DomesticMarketRankItem[];
popularByVolume: DomesticMarketRankItem[];
popularByValue: DomesticMarketRankItem[];
news: DomesticNewsHeadlineItem[];
pulse: DomesticMarketPulse;
warnings: string[];
}
export interface DomesticOrderHistoryItem { export interface DomesticOrderHistoryItem {
orderDate: string; orderDate: string;
orderTime: string; orderTime: string;
@@ -217,6 +296,7 @@ const INDEX_TARGETS: Array<{
const DASHBOARD_ORDER_LOOKBACK_DAYS = 30; const DASHBOARD_ORDER_LOOKBACK_DAYS = 30;
const DASHBOARD_JOURNAL_LOOKBACK_DAYS = 90; const DASHBOARD_JOURNAL_LOOKBACK_DAYS = 90;
const DASHBOARD_MARKET_HUB_LIMIT = 10;
interface DashboardBalanceInquirePreset { interface DashboardBalanceInquirePreset {
inqrDvsn: "01" | "02"; inqrDvsn: "01" | "02";
@@ -263,6 +343,7 @@ export async function getDomesticDashboardBalance(
const symbol = (row.pdno ?? "").trim(); const symbol = (row.pdno ?? "").trim();
if (!/^\d{6}$/.test(symbol)) return null; if (!/^\d{6}$/.test(symbol)) return null;
const quantity = toNumber(row.hldg_qty); const quantity = toNumber(row.hldg_qty);
const sellableQuantity = Math.max(0, Math.floor(toNumber(row.ord_psbl_qty)));
const averagePrice = toNumber(row.pchs_avg_pric); const averagePrice = toNumber(row.pchs_avg_pric);
const currentPrice = toNumber(row.prpr); const currentPrice = toNumber(row.prpr);
const purchaseAmountBase = pickPreferredAmount( const purchaseAmountBase = pickPreferredAmount(
@@ -287,6 +368,7 @@ export async function getDomesticDashboardBalance(
name: (row.prdt_name ?? "").trim() || symbol, name: (row.prdt_name ?? "").trim() || symbol,
market: MARKET_BY_SYMBOL.get(symbol) ?? "KOSPI", market: MARKET_BY_SYMBOL.get(symbol) ?? "KOSPI",
quantity, quantity,
sellableQuantity,
averagePrice, averagePrice,
currentPrice, currentPrice,
evaluationAmount, evaluationAmount,
@@ -296,14 +378,18 @@ export async function getDomesticDashboardBalance(
} satisfies DomesticHoldingItem & { purchaseAmountBase: number }; } satisfies DomesticHoldingItem & { purchaseAmountBase: number };
}) })
.filter( .filter(
(item): item is DomesticHoldingItem & { purchaseAmountBase: number } => (item): item is DomesticHoldingItem & { purchaseAmountBase: number } => {
Boolean(item), if (!item) return false;
// [Step 3] 전량 매도 후 잔존(수량 0) 레코드는 대시보드 보유종목에서 제외합니다.
return item.quantity > 0;
},
); );
const holdings = normalizedHoldings.map((item) => ({ const holdings = normalizedHoldings.map((item) => ({
symbol: item.symbol, symbol: item.symbol,
name: item.name, name: item.name,
market: item.market, market: item.market,
quantity: item.quantity, quantity: item.quantity,
sellableQuantity: item.sellableQuantity,
averagePrice: item.averagePrice, averagePrice: item.averagePrice,
currentPrice: item.currentPrice, currentPrice: item.currentPrice,
evaluationAmount: item.evaluationAmount, evaluationAmount: item.evaluationAmount,
@@ -518,6 +604,367 @@ export async function getDomesticDashboardIndices(
return results; return results;
} }
/**
* 급등/인기/뉴스 시장 허브 데이터를 조회합니다.
* @param credentials 사용자 입력 키(선택)
* @returns 시장 허브 목록/요약/경고
* @remarks UI 흐름: /dashboard 시장 탭 진입 -> market-hub API -> 카드별 리스트 렌더링
* @see app/api/kis/domestic/market-hub/route.ts 시장 허브 API 응답 생성
*/
export async function getDomesticDashboardMarketHub(
credentials?: KisCredentialInput,
): Promise<DomesticDashboardMarketHubResult> {
const [
gainersResult,
losersResult,
popularVolumeResult,
popularValueResult,
newsResult,
] =
await Promise.allSettled([
getDomesticTopGainers(credentials),
getDomesticTopLosers(credentials),
getDomesticVolumeRank(credentials, "0"),
getDomesticVolumeRank(credentials, "3"),
getDomesticNewsHeadlines(credentials),
]);
const warnings: string[] = [];
const gainers =
gainersResult.status === "fulfilled" ? gainersResult.value : [];
if (gainersResult.status === "rejected") {
warnings.push(
gainersResult.reason instanceof Error
? `급등주식 조회 실패: ${gainersResult.reason.message}`
: "급등주식 조회에 실패했습니다.",
);
}
const losers = losersResult.status === "fulfilled" ? losersResult.value : [];
if (losersResult.status === "rejected") {
warnings.push(
losersResult.reason instanceof Error
? `급락주식 조회 실패: ${losersResult.reason.message}`
: "급락주식 조회에 실패했습니다.",
);
}
const popularByVolume =
popularVolumeResult.status === "fulfilled" ? popularVolumeResult.value : [];
if (popularVolumeResult.status === "rejected") {
warnings.push(
popularVolumeResult.reason instanceof Error
? `인기종목(거래량) 조회 실패: ${popularVolumeResult.reason.message}`
: "인기종목(거래량) 조회에 실패했습니다.",
);
}
const popularByValue =
popularValueResult.status === "fulfilled" ? popularValueResult.value : [];
if (popularValueResult.status === "rejected") {
warnings.push(
popularValueResult.reason instanceof Error
? `거래대금 상위 조회 실패: ${popularValueResult.reason.message}`
: "거래대금 상위 조회에 실패했습니다.",
);
}
const news = newsResult.status === "fulfilled" ? newsResult.value : [];
if (newsResult.status === "rejected") {
warnings.push(
newsResult.reason instanceof Error
? `주요 뉴스 조회 실패: ${newsResult.reason.message}`
: "주요 뉴스 조회에 실패했습니다.",
);
}
if (
gainersResult.status === "rejected" &&
losersResult.status === "rejected" &&
popularVolumeResult.status === "rejected" &&
popularValueResult.status === "rejected" &&
newsResult.status === "rejected"
) {
throw new Error("시장 허브 데이터를 모두 조회하지 못했습니다.");
}
return {
gainers,
losers,
popularByVolume,
popularByValue,
news,
pulse: {
gainersCount: gainers.length,
losersCount: losers.length,
popularByVolumeCount: popularByVolume.length,
popularByValueCount: popularByValue.length,
newsCount: news.length,
},
warnings,
};
}
async function getDomesticTopGainers(credentials?: KisCredentialInput) {
const fluctuationRows = await getDomesticFluctuationRows(credentials);
const fluctuationMapped = fluctuationRows
.map((row, index) => normalizeMarketRankItemFromFluctuation(row, index))
.filter((item): item is DomesticMarketRankItem => Boolean(item))
.filter((item) => item.changeRate > 0)
.sort((a, b) => b.changeRate - a.changeRate);
if (fluctuationMapped.length >= DASHBOARD_MARKET_HUB_LIMIT) {
return fluctuationMapped.slice(0, DASHBOARD_MARKET_HUB_LIMIT);
}
// 변동률 순위 API가 빈 응답일 때 거래량 상위 중 상승률 상위로 폴백합니다.
const fallbackRows = await getDomesticVolumeRank(credentials, "0");
const fallbackGainers = fallbackRows
.filter((item) => item.changeRate > 0)
.sort((a, b) => b.changeRate - a.changeRate);
return dedupeMarketRankItems([...fluctuationMapped, ...fallbackGainers]).slice(
0,
DASHBOARD_MARKET_HUB_LIMIT,
);
}
async function getDomesticTopLosers(credentials?: KisCredentialInput) {
const fluctuationRows = await getDomesticFluctuationRows(credentials);
const fluctuationMapped = fluctuationRows
.map((row, index) => normalizeMarketRankItemFromFluctuation(row, index))
.filter((item): item is DomesticMarketRankItem => Boolean(item))
.filter((item) => item.changeRate < 0)
.sort((a, b) => a.changeRate - b.changeRate);
if (fluctuationMapped.length >= DASHBOARD_MARKET_HUB_LIMIT) {
return fluctuationMapped.slice(0, DASHBOARD_MARKET_HUB_LIMIT);
}
const fallbackRows = await getDomesticVolumeRank(credentials, "0");
const fallbackLosers = fallbackRows
.filter((item) => item.changeRate < 0)
.sort((a, b) => a.changeRate - b.changeRate);
return dedupeMarketRankItems([...fluctuationMapped, ...fallbackLosers]).slice(
0,
DASHBOARD_MARKET_HUB_LIMIT,
);
}
async function getDomesticVolumeRank(
credentials: KisCredentialInput | undefined,
sortClassCode: "0" | "3",
) {
const response = await kisGet<unknown>(
"/uapi/domestic-stock/v1/quotations/volume-rank",
"FHPST01710000",
{
FID_COND_MRKT_DIV_CODE: "J",
FID_COND_SCR_DIV_CODE: "20171",
FID_INPUT_ISCD: "0000",
FID_DIV_CLS_CODE: "0",
FID_BLNG_CLS_CODE: sortClassCode,
FID_TRGT_CLS_CODE: "111111111",
FID_TRGT_EXLS_CLS_CODE: "0000000000",
FID_INPUT_PRICE_1: "",
FID_INPUT_PRICE_2: "",
FID_VOL_CNT: "",
FID_INPUT_DATE_1: "",
},
credentials,
);
const rows = parseRows<KisVolumeRankOutputRow>(response.output);
return rows
.map((row, index) => normalizeMarketRankItemFromVolume(row, index))
.filter((item): item is DomesticMarketRankItem => Boolean(item))
.slice(0, DASHBOARD_MARKET_HUB_LIMIT);
}
async function getDomesticNewsHeadlines(credentials?: KisCredentialInput) {
const response = await kisGet<unknown>(
"/uapi/domestic-stock/v1/quotations/news-title",
"FHKST01011800",
{
FID_NEWS_OFER_ENTP_CODE: "",
FID_COND_MRKT_CLS_CODE: "",
FID_INPUT_ISCD: "",
FID_TITL_CNTT: "",
FID_INPUT_DATE_1: "",
FID_INPUT_HOUR_1: "",
FID_RANK_SORT_CLS_CODE: "",
FID_INPUT_SRNO: "",
},
credentials,
);
const rows = parseRows<KisNewsTitleOutputRow>(response.output);
return rows
.map((row, index) => normalizeNewsHeadlineItem(row, index))
.filter((item): item is DomesticNewsHeadlineItem => Boolean(item))
.slice(0, DASHBOARD_MARKET_HUB_LIMIT);
}
async function getDomesticFluctuationRows(credentials?: KisCredentialInput) {
const presets = [
{
fid_rank_sort_cls_code: "0000",
fid_trgt_cls_code: "0",
fid_trgt_exls_cls_code: "0",
fid_rsfl_rate1: "",
fid_rsfl_rate2: "",
},
{
fid_rank_sort_cls_code: "0",
fid_trgt_cls_code: "111111111",
fid_trgt_exls_cls_code: "0000000000",
fid_rsfl_rate1: "",
fid_rsfl_rate2: "",
},
] as const;
const errors: string[] = [];
for (const preset of presets) {
try {
const response = await kisGet<unknown>(
"/uapi/domestic-stock/v1/ranking/fluctuation",
"FHPST01700000",
{
fid_cond_mrkt_div_code: "J",
fid_cond_scr_div_code: "20170",
fid_input_iscd: "0000",
fid_rank_sort_cls_code: preset.fid_rank_sort_cls_code,
fid_input_cnt_1: "0",
fid_prc_cls_code: "0",
fid_input_price_1: "",
fid_input_price_2: "",
fid_vol_cnt: "",
fid_trgt_cls_code: preset.fid_trgt_cls_code,
fid_trgt_exls_cls_code: preset.fid_trgt_exls_cls_code,
fid_div_cls_code: "0",
fid_rsfl_rate1: preset.fid_rsfl_rate1,
fid_rsfl_rate2: preset.fid_rsfl_rate2,
},
credentials,
);
const rows = parseRows<KisFluctuationOutputRow>(response.output);
if (rows.length > 0) {
return rows;
}
} catch (error) {
errors.push(error instanceof Error ? error.message : "호출 실패");
}
}
if (errors.length > 0) {
throw new Error(errors.join(" | "));
}
return [] as KisFluctuationOutputRow[];
}
function normalizeMarketRankItemFromFluctuation(
row: KisFluctuationOutputRow,
index: number,
) {
const symbol = (row.stck_shrn_iscd ?? "").trim();
if (!/^\d{6}$/.test(symbol)) return null;
const signedChange = normalizeSignedValue(
toNumber(row.prdy_vrss),
row.prdy_vrss_sign,
);
const signedChangeRate = normalizeSignedValue(
toNumber(row.prdy_ctrt),
row.prdy_vrss_sign,
);
return {
rank: resolveRank(row.data_rank, index),
symbol,
name: (row.hts_kor_isnm ?? "").trim() || symbol,
market: MARKET_BY_SYMBOL.get(symbol) ?? "KOSPI",
price: toNumber(row.stck_prpr),
change: signedChange,
changeRate: signedChangeRate,
volume: toNumber(row.acml_vol),
tradingValue: toNumber(row.acml_tr_pbmn),
} satisfies DomesticMarketRankItem;
}
function normalizeMarketRankItemFromVolume(
row: KisVolumeRankOutputRow,
index: number,
) {
const symbol = (row.mksc_shrn_iscd ?? row.stck_shrn_iscd ?? "").trim();
if (!/^\d{6}$/.test(symbol)) return null;
const signedChange = normalizeSignedValue(
toNumber(row.prdy_vrss),
row.prdy_vrss_sign,
);
const signedChangeRate = normalizeSignedValue(
toNumber(row.prdy_ctrt),
row.prdy_vrss_sign,
);
return {
rank: resolveRank(row.data_rank, index),
symbol,
name: (row.hts_kor_isnm ?? "").trim() || symbol,
market: MARKET_BY_SYMBOL.get(symbol) ?? "KOSPI",
price: toNumber(row.stck_prpr),
change: signedChange,
changeRate: signedChangeRate,
volume: toNumber(row.acml_vol),
tradingValue: toNumber(row.acml_tr_pbmn),
} satisfies DomesticMarketRankItem;
}
function normalizeNewsHeadlineItem(row: KisNewsTitleOutputRow, index: number) {
const title = (row.hts_pbnt_titl_cntt ?? "").trim();
if (!title) return null;
const id = (row.cntt_usiq_srno ?? "").trim();
const symbols = [row.iscd1, row.iscd2, row.iscd3, row.iscd4, row.iscd5]
.map((value) => (value ?? "").trim())
.filter((value, position, all) => /^\d{6}$/.test(value) && all.indexOf(value) === position);
return {
id: id || `news-${index + 1}`,
title,
source: (row.dorg ?? row.news_ofer_entp_code ?? "").trim() || "KIS",
publishedAt: formatNewsTimestamp(row.data_dt, row.data_tm),
symbols,
} satisfies DomesticNewsHeadlineItem;
}
function dedupeMarketRankItems(items: DomesticMarketRankItem[]) {
const seen = new Set<string>();
return items.filter((item) => {
if (seen.has(item.symbol)) return false;
seen.add(item.symbol);
return true;
});
}
function resolveRank(rawRank: string | undefined, fallbackIndex: number) {
const parsed = Math.floor(toNumber(rawRank));
if (parsed > 0) return parsed;
return fallbackIndex + 1;
}
function formatNewsTimestamp(rawDate?: string, rawTime?: string) {
const dateDigits = toDigits(rawDate);
if (dateDigits.length !== 8) return "-";
const timeDigits = normalizeTimeDigits(rawTime);
return `${formatDateLabel(dateDigits)} ${formatTimeLabel(timeDigits)}`;
}
/** /**
* 대시보드 하단의 주문내역/매매일지 데이터를 조회합니다. * 대시보드 하단의 주문내역/매매일지 데이터를 조회합니다.
* @param account KIS 계좌번호(8-2) 파트 * @param account KIS 계좌번호(8-2) 파트

View File

@@ -328,6 +328,9 @@ export function nowHmsInKst() {
} }
export function minutesForTimeframe(tf: DashboardChartTimeframe) { export function minutesForTimeframe(tf: DashboardChartTimeframe) {
if (tf === "5m") return 5;
if (tf === "10m") return 10;
if (tf === "15m") return 15;
if (tf === "30m") return 30; if (tf === "30m") return 30;
if (tf === "1h") return 60; if (tf === "1h") return 60;
return 1; return 1;

View File

@@ -459,6 +459,7 @@ export async function getDomesticDailyTimeChart(
* - 일봉/주봉: inquire-daily-itemchartprice (FHKST03010100), 과거 페이징 지원 * - 일봉/주봉: inquire-daily-itemchartprice (FHKST03010100), 과거 페이징 지원
* - 분봉 (오늘): inquire-time-itemchartprice (FHKST03010200) * - 분봉 (오늘): inquire-time-itemchartprice (FHKST03010200)
* - 분봉 (과거): inquire-time-dailychartprice (FHKST03010230) * - 분봉 (과거): inquire-time-dailychartprice (FHKST03010230)
* - 지원 분봉: 1m/5m/10m/15m/30m/1h (서버에서 minute bucket 집계)
*/ */
export async function getDomesticChart( export async function getDomesticChart(
symbol: string, symbol: string,
@@ -505,7 +506,7 @@ export async function getDomesticChart(
return { candles: parsed, hasMore: Boolean(nextCursor), nextCursor }; return { candles: parsed, hasMore: Boolean(nextCursor), nextCursor };
} }
// ── 분봉 (1m / 30m / 1h) ── // ── 분봉 (1m / 5m / 10m / 15m / 30m / 1h) ──
const minuteBucket = minutesForTimeframe(timeframe); const minuteBucket = minutesForTimeframe(timeframe);
let rawRows: Array<Record<string, unknown>> = []; let rawRows: Array<Record<string, unknown>> = [];
let nextCursor: string | null = null; let nextCursor: string | null = null;
@@ -522,44 +523,40 @@ export async function getDomesticChart(
); );
// 다음 커서 계산 // 다음 커서 계산
// 데이터가 있으면 가장 오래된 시간 - 1분? 혹은 해당 날짜의 09:00 도달 시 전일로 이동
// API가 시간 역순으로 데이터를 준다고 가정 (output[0]이 가장 최신, output[last]가 가장 과거)
// 실제 KIS API는 보통 최신순 정렬
if (rawRows.length > 0) { if (rawRows.length > 0) {
// 가장 과거 데이터의 시간 확인 const oldestRow = rawRows[rawRows.length - 1];
const oldestRow = rawRows[rawRows.length - 1]; // 마지막이 가장 과거라 가정 const oldestTimeRaw = oldestRow
const oldestTime = readRowString(oldestRow, "stck_cntg_hour"); ? readRowString(oldestRow, "stck_cntg_hour", "STCK_CNTG_HOUR")
: "";
// 09:00:00보다 크면 계속 같은 날짜 페이징 (단, KIS가 120건씩 주므로) const oldestDateRaw = oldestRow
// 만약 09시 근처라면 전일로 이동 ? readRowString(oldestRow, "stck_bsop_date", "STCK_BSOP_DATE")
// 간단히: 가져온 데이터 중 090000이 포함되어 있거나, 더 가져올 게 없어 보이면 전일로 : "";
// 여기서는 단순히 전일 153000으로 넘어가는 로직을 사용하거나, const oldestTime = /^\d{6}$/.test(oldestTimeRaw)
// 현재 날짜에서 시간을 줄여서 재요청해야 함. ? oldestTimeRaw
// KIS API가 '다음 커서'를 주지 않으므로, 마지막 데이터 시간을 기준으로 다음 요청 : /^\d{4}$/.test(oldestTimeRaw)
? `${oldestTimeRaw}00`
: "";
const oldestDate = /^\d{8}$/.test(oldestDateRaw)
? oldestDateRaw
: targetDate;
if (oldestTime && Number(oldestTime) > 90000) { if (oldestTime && Number(oldestTime) > 90000) {
// 같은 날짜, 시간만 조정 (1분 전) // 120건을 꽉 채웠으면 같은 날짜에서 더 과거 시간을, 아니면 전일로 이동합니다.
// HHMMSS -> number -> subtract -> string if (rawRows.length >= 120) {
// 편의상 120개 꽉 찼으면 마지막 시간 사용, 아니면 전일로 const nextTime = subOneMinute(oldestTime);
if (rawRows.length >= 120) { nextCursor =
nextCursor = targetDate + oldestTime; // 다음 요청 시 이 시간 '이전'을 달라고 해야 함 (Inclusive 여부 확인 필요) nextTime === targetTime
// 만약 Inclusive라면 -1분 해야 함. 안전하게 -1분 처리 ? shiftYmd(oldestDate, -1) + "153000"
// 시간 연산이 복잡하므로, 단순히 전일로 넘어가는 게 나을 수도 있으나, : oldestDate + nextTime;
// 하루치 분봉이 380개라 120개로는 부족함. } else {
// 따라서 시간 연산 필요. nextCursor = shiftYmd(oldestDate, -1) + "153000";
nextCursor = targetDate + subOneMinute(oldestTime); }
} else {
// 120개 미만이면 장 시작까지 다 가져왔다고 가정 -> 전일로
nextCursor = shiftYmd(targetDate, -1) + "153000";
}
} else { } else {
// 09:00 도달 -> 전일로 nextCursor = shiftYmd(oldestDate, -1) + "153000";
nextCursor = shiftYmd(targetDate, -1) + "153000";
} }
} else { } else {
// 데이터 없음 (휴장일 등) -> 전일로 계속 시도 (최대 5일? 무한 루프 방지 필요하나 UI에서 제어) // 데이터 없음(휴장일 등)인 경우 전일 기준으로 한 번 더 탐색합니다.
nextCursor = shiftYmd(targetDate, -1) + "153000"; nextCursor = shiftYmd(targetDate, -1) + "153000";
// 너무 과거(1년)면 중단? 일단 생략
} }
} else { } else {

View File

@@ -1,4 +1,4 @@
import { kisPost } from "@/lib/kis/client"; import { kisGet, kisPost } from "@/lib/kis/client";
import { KisCredentialInput } from "@/lib/kis/config"; import { KisCredentialInput } from "@/lib/kis/config";
import { import {
DashboardOrderSide, DashboardOrderSide,
@@ -25,6 +25,14 @@ interface KisOrderCashBody {
ORD_UNPR: string; // 주문단가 ORD_UNPR: string; // 주문단가
} }
interface KisInquirePsblOrderOutput {
ord_psbl_cash?: string; // 주문가능현금
nrcvb_buy_amt?: string; // 미수없는매수금액
max_buy_amt?: string; // 최대매수금액
nrcvb_buy_qty?: string; // 미수없는매수수량
max_buy_qty?: string; // 최대매수수량
}
/** /**
* 현금 주문(매수/매도) 실행 * 현금 주문(매수/매도) 실행
*/ */
@@ -62,6 +70,57 @@ export async function executeOrderCash(
return response.output ?? {}; return response.output ?? {};
} }
/**
* 매수가능금액(주문가능현금) 조회
*/
export async function executeInquireOrderableCash(
params: {
symbol: string;
price: number;
orderType: DashboardOrderType;
accountNo: string;
accountProductCode: string;
},
credentials?: KisCredentialInput,
) {
const trId = resolveInquireOrderableTrId(credentials?.tradingEnv);
const ordDvsn = resolveOrderDivision(params.orderType);
const ordUnpr = Math.max(1, Math.floor(params.price || 0));
const response = await kisGet<KisInquirePsblOrderOutput | KisInquirePsblOrderOutput[]>(
"/uapi/domestic-stock/v1/trading/inquire-psbl-order",
trId,
{
CANO: params.accountNo,
ACNT_PRDT_CD: params.accountProductCode,
PDNO: params.symbol,
ORD_UNPR: String(ordUnpr),
ORD_DVSN: ordDvsn,
CMA_EVLU_AMT_ICLD_YN: "N",
OVRS_ICLD_YN: "N",
},
credentials,
);
const rawRow = Array.isArray(response.output)
? (response.output[0] ?? {})
: (response.output ?? {});
const orderableCash = pickFirstPositive(
toSafeNumber(rawRow.nrcvb_buy_amt),
toSafeNumber(rawRow.ord_psbl_cash),
toSafeNumber(rawRow.max_buy_amt),
);
return {
orderableCash,
noReceivableBuyAmount: toSafeNumber(rawRow.nrcvb_buy_amt),
maxBuyAmount: toSafeNumber(rawRow.max_buy_amt),
noReceivableBuyQuantity: Math.floor(toSafeNumber(rawRow.nrcvb_buy_qty)),
maxBuyQuantity: Math.floor(toSafeNumber(rawRow.max_buy_qty)),
};
}
function resolveOrderTrId(side: DashboardOrderSide, env?: "real" | "mock") { function resolveOrderTrId(side: DashboardOrderSide, env?: "real" | "mock") {
const isMock = env === "mock"; const isMock = env === "mock";
if (side === "buy") { if (side === "buy") {
@@ -73,8 +132,27 @@ function resolveOrderTrId(side: DashboardOrderSide, env?: "real" | "mock") {
} }
} }
function resolveInquireOrderableTrId(env?: "real" | "mock") {
return env === "mock" ? "VTTC8908R" : "TTTC8908R";
}
function resolveOrderDivision(type: DashboardOrderType) { function resolveOrderDivision(type: DashboardOrderType) {
// 00: 지정가, 01: 시장가 // 00: 지정가, 01: 시장가
if (type === "market") return "01"; if (type === "market") return "01";
return "00"; return "00";
} }
function toSafeNumber(value?: string) {
if (!value) return 0;
const parsed = Number(value.replaceAll(",", "").trim());
if (!Number.isFinite(parsed)) return 0;
return parsed;
}
function pickFirstPositive(...values: number[]) {
for (const value of values) {
if (value > 0) return value;
}
return 0;
}

520
package-lock.json generated
View File

@@ -49,6 +49,7 @@
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.6", "eslint-config-next": "16.1.6",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.20.6",
"typescript": "^5" "typescript": "^5"
} }
}, },
@@ -338,6 +339,448 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@eslint-community/eslint-utils": { "node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1", "version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@@ -6417,6 +6860,48 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.3",
"@esbuild/android-arm": "0.27.3",
"@esbuild/android-arm64": "0.27.3",
"@esbuild/android-x64": "0.27.3",
"@esbuild/darwin-arm64": "0.27.3",
"@esbuild/darwin-x64": "0.27.3",
"@esbuild/freebsd-arm64": "0.27.3",
"@esbuild/freebsd-x64": "0.27.3",
"@esbuild/linux-arm": "0.27.3",
"@esbuild/linux-arm64": "0.27.3",
"@esbuild/linux-ia32": "0.27.3",
"@esbuild/linux-loong64": "0.27.3",
"@esbuild/linux-mips64el": "0.27.3",
"@esbuild/linux-ppc64": "0.27.3",
"@esbuild/linux-riscv64": "0.27.3",
"@esbuild/linux-s390x": "0.27.3",
"@esbuild/linux-x64": "0.27.3",
"@esbuild/netbsd-arm64": "0.27.3",
"@esbuild/netbsd-x64": "0.27.3",
"@esbuild/openbsd-arm64": "0.27.3",
"@esbuild/openbsd-x64": "0.27.3",
"@esbuild/openharmony-arm64": "0.27.3",
"@esbuild/sunos-x64": "0.27.3",
"@esbuild/win32-arm64": "0.27.3",
"@esbuild/win32-ia32": "0.27.3",
"@esbuild/win32-x64": "0.27.3"
}
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -10023,6 +10508,41 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tsx/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/tw-animate-css": { "node_modules/tw-animate-css": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",

View File

@@ -7,6 +7,13 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"test:autotrade:unit": "tsx --test tests/autotrade/*.test.ts",
"test:autotrade:smoke": "node scripts/autotrade-dev-smoke.mjs",
"test:autotrade:lifecycle": "node scripts/autotrade-session-e2e.mjs",
"worker:autotrade": "node scripts/autotrade-worker.mjs",
"worker:autotrade:once": "node scripts/autotrade-worker.mjs --once",
"worker:autotrade:dev": "node --env-file=.env.local scripts/autotrade-worker.mjs",
"worker:autotrade:once:dev": "node --env-file=.env.local scripts/autotrade-worker.mjs --once",
"sync:stocks": "node scripts/sync-korean-stocks.mjs", "sync:stocks": "node scripts/sync-korean-stocks.mjs",
"sync:stocks:check": "node scripts/sync-korean-stocks.mjs --check" "sync:stocks:check": "node scripts/sync-korean-stocks.mjs --check"
}, },
@@ -52,6 +59,7 @@
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.6", "eslint-config-next": "16.1.6",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.20.6",
"typescript": "^5" "typescript": "^5"
} }
} }

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,153 @@
#!/usr/bin/env node
/**
* [Purpose]
* Fast dev smoke test for autotrade flow:
* compile -> validate -> start -> heartbeat -> signal -> worker tick -> stop.
*/
const appUrl = process.env.AUTOTRADE_APP_URL || "http://127.0.0.1:3001";
const bypassToken = process.env.AUTOTRADE_DEV_BYPASS_TOKEN || "autotrade-dev-bypass";
const workerToken = process.env.AUTOTRADE_WORKER_TOKEN || "autotrade-worker-local";
async function main() {
// [Step 1] compile strategy
const compile = await callApi("/api/autotrade/strategies/compile", {
method: "POST",
body: {
aiMode: "auto",
prompt: "Use ORB and VWAP reversion conservatively, prefer hold on uncertainty.",
selectedTechniques: ["orb", "vwap_reversion"],
confidenceThreshold: 0.65,
},
});
// [Step 2] validate risk
const validation = await callApi("/api/autotrade/strategies/validate", {
method: "POST",
body: {
cashBalance: 2_000_000,
allocationPercent: 10,
allocationAmount: 300_000,
dailyLossPercent: 2,
dailyLossAmount: 30_000,
},
});
if (!validation?.validation?.isValid) {
throw new Error("validation failed");
}
// [Step 3] start session
const start = await callApi("/api/autotrade/sessions/start", {
method: "POST",
headers: {
"x-kis-app-key": "dev-app-key",
"x-kis-app-secret": "dev-app-secret",
"x-kis-account-no": "12345678-01",
"x-kis-trading-env": "mock",
},
body: {
symbol: "005930",
leaderTabId: "autotrade-dev-smoke-tab",
effectiveAllocationAmount: validation.validation.effectiveAllocationAmount,
effectiveDailyLossLimit: validation.validation.effectiveDailyLossLimit,
strategySummary: compile.compiledStrategy.summary,
},
});
// [Step 4] send heartbeat
await callApi("/api/autotrade/sessions/heartbeat", {
method: "POST",
body: {
sessionId: start.session.sessionId,
leaderTabId: "autotrade-dev-smoke-tab",
},
});
// [Step 5] generate a signal
const signal = await callApi("/api/autotrade/signals/generate", {
method: "POST",
body: {
aiMode: "auto",
strategy: compile.compiledStrategy,
snapshot: {
symbol: "005930",
currentPrice: 73_000,
changeRate: 0.35,
open: 72_800,
high: 73_100,
low: 72_600,
tradeVolume: 120_000,
accumulatedVolume: 450_000,
recentPrices: [72_600, 72_700, 72_800, 72_900, 73_000, 73_050, 73_100],
marketDataLatencySec: 1,
},
},
});
// [Step 6] worker tick auth/path check
await callApi("/api/autotrade/worker/tick", {
method: "POST",
headers: {
"x-autotrade-worker-token": workerToken,
},
body: {},
});
// [Step 7] stop session
const stop = await callApi("/api/autotrade/sessions/stop", {
method: "POST",
body: {
sessionId: start.session.sessionId,
reason: "manual",
},
});
if (stop?.session?.runtimeState !== "STOPPED") {
throw new Error("stop failed");
}
console.log(
"[autotrade-dev-smoke] PASS",
JSON.stringify(
{
appUrl,
compileProvider: compile?.compiledStrategy?.provider,
signal: signal?.signal?.signal,
signalSource: signal?.signal?.source,
},
null,
2,
),
);
}
async function callApi(path, options) {
const headers = {
"content-type": "application/json",
"x-autotrade-dev-bypass": bypassToken,
...(options.headers || {}),
};
const response = await fetch(`${appUrl}${path}`, {
method: options.method,
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.message || `${path} failed (${response.status})`;
throw new Error(message);
}
return payload;
}
main().catch((error) => {
const message = error instanceof Error ? error.message : "autotrade dev smoke failed";
console.error(`[autotrade-dev-smoke] FAIL: ${message}`);
process.exit(1);
});

View File

@@ -0,0 +1,117 @@
#!/usr/bin/env node
const appUrl = process.env.AUTOTRADE_APP_URL || "http://127.0.0.1:3001";
const bypassToken = process.env.AUTOTRADE_DEV_BYPASS_TOKEN || "autotrade-dev-bypass";
async function main() {
const compile = await callApi("/api/autotrade/strategies/compile", {
method: "POST",
body: {
prompt: "장 초반 변동성은 보수적으로 보고, ORB와 VWAP 신호가 동시에 나올 때만 진입",
selectedTechniques: ["orb", "vwap_reversion"],
confidenceThreshold: 0.65,
},
});
const validation = await callApi("/api/autotrade/strategies/validate", {
method: "POST",
body: {
cashBalance: 2_000_000,
allocationPercent: 10,
allocationAmount: 300_000,
dailyLossPercent: 2,
dailyLossAmount: 30_000,
},
});
if (!validation?.validation?.isValid) {
throw new Error("리스크 검증이 실패했습니다.");
}
const start = await callApi("/api/autotrade/sessions/start", {
method: "POST",
headers: {
"x-kis-app-key": "dev-app-key",
"x-kis-app-secret": "dev-app-secret",
"x-kis-account-no": "12345678-01",
"x-kis-trading-env": "mock",
},
body: {
symbol: "005930",
leaderTabId: "autotrade-e2e-tab",
effectiveAllocationAmount: validation.validation.effectiveAllocationAmount,
effectiveDailyLossLimit: validation.validation.effectiveDailyLossLimit,
strategySummary: compile.compiledStrategy.summary,
},
});
const heartbeat = await callApi("/api/autotrade/sessions/heartbeat", {
method: "POST",
body: {
sessionId: start.session.sessionId,
leaderTabId: "autotrade-e2e-tab",
},
});
if (heartbeat.session.sessionId !== start.session.sessionId) {
throw new Error("heartbeat 결과의 sessionId가 시작 세션과 다릅니다.");
}
const active = await callApi("/api/autotrade/sessions/active", {
method: "GET",
});
if (!active.session || active.session.sessionId !== start.session.sessionId) {
throw new Error("active 세션 조회 결과가 기대와 다릅니다.");
}
const stop = await callApi("/api/autotrade/sessions/stop", {
method: "POST",
body: {
sessionId: start.session.sessionId,
reason: "manual",
},
});
if (!stop.session || stop.session.runtimeState !== "STOPPED") {
throw new Error("세션 중지가 정상 반영되지 않았습니다.");
}
const activeAfterStop = await callApi("/api/autotrade/sessions/active", {
method: "GET",
});
if (activeAfterStop.session !== null) {
throw new Error("중지 이후 active 세션이 남아 있습니다.");
}
console.log("[autotrade-session-e2e] PASS: start -> heartbeat -> stop lifecycle verified");
}
async function callApi(path, options) {
const headers = {
"content-type": "application/json",
"x-autotrade-dev-bypass": bypassToken,
...(options.headers || {}),
};
const response = await fetch(`${appUrl}${path}`, {
method: options.method,
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.message || `${path} failed (${response.status})`;
throw new Error(message);
}
return payload;
}
main().catch((error) => {
const message = error instanceof Error ? error.message : "autotrade lifecycle test failed";
console.error(`[autotrade-session-e2e] FAIL: ${message}`);
process.exit(1);
});

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env node
/**
* [목적]
* heartbeat가 끊긴 자동매매 세션을 주기적으로 정리하는 백그라운드 워커입니다.
*
* [데이터 흐름]
* worker loop -> /api/autotrade/worker/tick 호출 -> 만료 세션 sweep -> 로그 출력
*/
const args = new Set(process.argv.slice(2));
const once = args.has("--once");
const appUrl = process.env.AUTOTRADE_APP_URL || "http://127.0.0.1:3001";
const workerToken = process.env.AUTOTRADE_WORKER_TOKEN || "autotrade-worker-local";
const pollMsRaw = Number.parseInt(process.env.AUTOTRADE_WORKER_POLL_MS || "5000", 10);
const pollMs = Number.isFinite(pollMsRaw) ? Math.max(1000, pollMsRaw) : 5000;
let isShuttingDown = false;
function log(message, level = "INFO") {
const time = new Date().toISOString();
const line = `[autotrade-worker][${level}][${time}] ${message}`;
console.log(line);
}
async function runTick() {
// Next API로 tick 요청을 보내 세션 만료 정리를 수행합니다.
const response = await fetch(`${appUrl}/api/autotrade/worker/tick`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-autotrade-worker-token": workerToken,
},
body: JSON.stringify({}),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.message || `HTTP ${response.status}`;
throw new Error(message);
}
return payload;
}
async function loop() {
if (once) {
const payload = await runTick();
log(
`single tick done: expired=${payload?.sweep?.expiredCount ?? 0}, running=${payload?.runningSessions ?? 0}`,
);
return;
}
log(`started (poll=${pollMs}ms, app=${appUrl})`);
// 지정 주기마다 tick을 반복 호출합니다.
while (!isShuttingDown) {
try {
const payload = await runTick();
log(
`tick ok: expired=${payload?.sweep?.expiredCount ?? 0}, running=${payload?.runningSessions ?? 0}, stopped=${payload?.stoppedSessions ?? 0}`,
);
} catch (error) {
const message = error instanceof Error ? error.message : "tick failed";
log(message, "ERROR");
}
await wait(pollMs);
}
log("graceful shutdown complete");
}
function wait(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
process.on("SIGINT", () => {
isShuttingDown = true;
log("SIGINT received, stopping...");
});
process.on("SIGTERM", () => {
isShuttingDown = true;
log("SIGTERM received, stopping...");
});
loop().catch((error) => {
const message = error instanceof Error ? error.message : "worker exited with error";
log(message, "ERROR");
process.exit(1);
});

View File

@@ -0,0 +1,20 @@
module.exports = {
apps: [
{
name: "autotrade-worker",
script: "./scripts/autotrade-worker.mjs",
interpreter: "node",
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: "300M",
env: {
NODE_ENV: "production",
// 서비스 단위 시크릿: "사용자별"이 아니라 앱/워커 프로세스가 공유하는 값입니다.
AUTOTRADE_WORKER_TOKEN: process.env.AUTOTRADE_WORKER_TOKEN || "",
AUTOTRADE_APP_URL: process.env.AUTOTRADE_APP_URL || "http://127.0.0.1:3001",
AUTOTRADE_WORKER_POLL_MS: process.env.AUTOTRADE_WORKER_POLL_MS || "5000",
},
},
],
};

View File

@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}

View File

@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import test from "node:test";
import { clampExecutableOrderQuantity } from "../../lib/autotrade/executable-order-quantity";
import {
estimateBuyUnitCost,
estimateOrderCost,
resolveExecutionCostProfile,
} from "../../lib/autotrade/execution-cost";
test("SELL 차단: 보유 없음/매도가능 없음", () => {
const result = clampExecutableOrderQuantity({
side: "sell",
requestedQuantity: 1,
holdingQuantity: 0,
sellableQuantity: 0,
});
assert.equal(result.ok, false);
assert.equal(result.quantity, 0);
});
test("SELL 제한: 보유보다 큰 수량 또는 매도가능 부족 시 clamp", () => {
const result = clampExecutableOrderQuantity({
side: "sell",
requestedQuantity: 5,
holdingQuantity: 10,
sellableQuantity: 3,
});
assert.equal(result.ok, true);
assert.equal(result.quantity, 3);
assert.equal(result.adjusted, true);
});
test("BUY 제한: 최대 매수 가능 수량으로 clamp", () => {
const result = clampExecutableOrderQuantity({
side: "buy",
requestedQuantity: 4,
maxBuyQuantity: 2,
});
assert.equal(result.ok, true);
assert.equal(result.quantity, 2);
assert.equal(result.adjusted, true);
});
test("수수료/세금 추정: buy/sell 순액 계산", () => {
const profile = resolveExecutionCostProfile();
const buyUnitCost = estimateBuyUnitCost(10_000, profile);
const buy = estimateOrderCost({
side: "buy",
price: 10_000,
quantity: 10,
profile,
});
const sell = estimateOrderCost({
side: "sell",
price: 10_000,
quantity: 10,
profile,
});
assert.ok(buyUnitCost > 10_000);
assert.ok(buy.netAmount > buy.grossAmount);
assert.ok(sell.netAmount < sell.grossAmount);
assert.ok(sell.taxAmount >= 0);
});

View File

@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
calculateEffectiveAllocationAmount,
calculateEffectiveDailyLossLimit,
resolveValidationCashBalance,
resolveOrderQuantity,
} from "../../lib/autotrade/risk";
test("allocationPercent와 allocationAmount를 함께 반영해 실주문 예산 계산", () => {
assert.equal(calculateEffectiveAllocationAmount(2_000_000, 10, 500_000), 200_000);
assert.equal(calculateEffectiveAllocationAmount(2_000_000, 30, 300_000), 300_000);
assert.equal(calculateEffectiveAllocationAmount(2_000_000, 0, 300_000), 0);
assert.equal(calculateEffectiveAllocationAmount(0, 30, 300_000), 0);
});
test("dailyLossPercent와 dailyLossAmount를 함께 반영해 실손실 한도 계산", () => {
assert.equal(calculateEffectiveDailyLossLimit(200_000, 2, 50_000), 4_000);
assert.equal(calculateEffectiveDailyLossLimit(200_000, 10, 5_000), 5_000);
assert.equal(calculateEffectiveDailyLossLimit(200_000, 0, 5_000), 5_000);
});
test("매수 수량은 비용 포함 단가 기준으로 계산", () => {
const quantity = resolveOrderQuantity({
side: "buy",
price: 10_000,
unitCost: 10_015,
requestedQuantity: undefined,
effectiveAllocationAmount: 100_000,
maxOrderAmountRatio: 1,
});
assert.equal(quantity, 9);
});
test("매수 수량: 비율 상한으로 0주여도 전체 예산 1주 가능하면 최소 1주 허용", () => {
const quantity = resolveOrderQuantity({
side: "buy",
price: 16_730,
unitCost: 16_730,
requestedQuantity: undefined,
effectiveAllocationAmount: 21_631,
maxOrderAmountRatio: 0.25,
});
assert.equal(quantity, 1);
});
test("매도 수량은 예산과 무관하게 요청값 기준(이후 보유/매도가능 검증)", () => {
const quantity = resolveOrderQuantity({
side: "sell",
price: 10_000,
requestedQuantity: 3,
effectiveAllocationAmount: 1_000,
maxOrderAmountRatio: 0.1,
});
assert.equal(quantity, 3);
});
test("검증 기준 자금: 예수금/매수가능금액 중 보수적인 값 사용", () => {
const bothAvailable = resolveValidationCashBalance({
cashBalance: 500_000,
orderableCash: 430_000,
});
assert.equal(bothAvailable.cashBalance, 430_000);
assert.equal(bothAvailable.source, "min_of_both");
const onlyOrderable = resolveValidationCashBalance({
cashBalance: 0,
orderableCash: 36_664,
});
assert.equal(onlyOrderable.cashBalance, 36_664);
assert.equal(onlyOrderable.source, "orderable_cash");
const onlyCash = resolveValidationCashBalance({
cashBalance: 120_000,
orderableCash: 0,
});
assert.equal(onlyCash.cashBalance, 120_000);
assert.equal(onlyCash.source, "cash_balance");
});

View File

@@ -48,8 +48,14 @@ export async function updateSession(request: NextRequest) {
// 4. 현재 요청 URL과 복구용 쿠키 확인 // 4. 현재 요청 URL과 복구용 쿠키 확인
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
const isApiRequest = pathname.startsWith("/api/");
const recoveryCookie = request.cookies.get(RECOVERY_COOKIE_NAME)?.value; const recoveryCookie = request.cookies.get(RECOVERY_COOKIE_NAME)?.value;
// API 요청은 각 route에서 인증/권한을 직접 처리하므로 여기서 로그인 페이지 리다이렉트를 하지 않습니다.
if (isApiRequest) {
return supabaseResponse;
}
// 5. 복구 쿠키가 있는데 로그인이 안 된 경우 (세션 만료 등) // 5. 복구 쿠키가 있는데 로그인이 안 된 경우 (세션 만료 등)
// 로그인 페이지로 강제 리다이렉트 후 복구 쿠키 삭제 // 로그인 페이지로 강제 리다이렉트 후 복구 쿠키 삭제
if (recoveryCookie && !user) { if (recoveryCookie && !user) {