전체적인 리팩토링
This commit is contained in:
@@ -47,6 +47,8 @@ description: 기능 개발/버그 수정/리팩토링 같은 구현 요청에서
|
|||||||
|
|
||||||
[3. 리팩토링/성능/가독성]
|
[3. 리팩토링/성능/가독성]
|
||||||
- ...
|
- ...
|
||||||
|
- 파일 상단 역할 주석 반영 여부
|
||||||
|
- 핵심 입력 데이터 흐름 추적표 포함 여부
|
||||||
|
|
||||||
[4. 테스트]
|
[4. 테스트]
|
||||||
- ...
|
- ...
|
||||||
@@ -54,4 +56,9 @@ description: 기능 개발/버그 수정/리팩토링 같은 구현 요청에서
|
|||||||
[5. 계획 대비 완료체크]
|
[5. 계획 대비 완료체크]
|
||||||
- 완료/부분 완료/미완료
|
- 완료/부분 완료/미완료
|
||||||
- 최종 판정: 배포 가능/보완 필요
|
- 최종 판정: 배포 가능/보완 필요
|
||||||
|
|
||||||
|
[6. 핵심 입력 흐름 추적표]
|
||||||
|
- 입력값: (예: 전략 프롬프트)
|
||||||
|
- UI 입력 -> 핸들러 -> 훅/서비스 -> API -> route -> provider -> 결과 반영
|
||||||
|
- 각 단계는 파일/라인 링크 포함
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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 함수 호출 -> 리턴값 반영
|
||||||
|
|
||||||
|
[핵심 입력 흐름 추적표]
|
||||||
|
- 입력값: (예: 전략 프롬프트)
|
||||||
|
- [파일:라인] -> 함수 -> 다음 호출
|
||||||
|
|
||||||
[회귀 위험 점검]
|
[회귀 위험 점검]
|
||||||
- ...
|
- ...
|
||||||
```
|
```
|
||||||
|
|||||||
33
.env.example
33
.env.example
@@ -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
|
||||||
|
|||||||
@@ -15,3 +15,11 @@
|
|||||||
4. `dev-test-gate`
|
4. `dev-test-gate`
|
||||||
5. `dev-plan-completion-checker`
|
5. `dev-plan-completion-checker`
|
||||||
- 단순 설명/문서 요약/잡담 요청에는 파이프라인 스킬을 강제하지 않는다.
|
- 단순 설명/문서 요약/잡담 요청에는 파이프라인 스킬을 강제하지 않는다.
|
||||||
|
|
||||||
|
## 설명 방식 규칙
|
||||||
|
|
||||||
|
- 사용자 설명은 어려운 용어보다 쉬운 한국어를 우선 사용한다.
|
||||||
|
- 기술 용어를 써야 할 때는 바로 아래 줄에 쉬운 말로 다시 풀어쓴다.
|
||||||
|
- 데이터 흐름 설명은 항상 `입력 -> 처리 -> 결과` 순서의 짧은 단계로 말한다.
|
||||||
|
- 사용자가 헷갈린 상황에서는 추상 설명보다 "지금 화면에서 확인할 것"을 먼저 안내한다.
|
||||||
|
- 요청/응답 설명 시에는 핵심 필드 3~5개만 먼저 보여주고, 필요 시 상세를 추가한다.
|
||||||
|
|||||||
@@ -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가 대신할게요.
|
앱키 연결 -> 투자금/손실선 설정 -> 시작 버튼.
|
||||||
<br />
|
<br />
|
||||||
당신은 가벼운 마음으로 '시작' 버튼만 누르세요.
|
어려운 용어 없이, 필요한 것만 빠르게 설정해보세요.
|
||||||
</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
|
||||||
|
|||||||
227
app/api/autotrade/_shared.ts
Normal file
227
app/api/autotrade/_shared.ts
Normal 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;
|
||||||
|
}
|
||||||
25
app/api/autotrade/sessions/active/route.ts
Normal file
25
app/api/autotrade/sessions/active/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
73
app/api/autotrade/sessions/heartbeat/route.ts
Normal file
73
app/api/autotrade/sessions/heartbeat/route.ts
Normal 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 처리 중 오류가 발생했습니다."),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
77
app/api/autotrade/sessions/start/route.ts
Normal file
77
app/api/autotrade/sessions/start/route.ts
Normal 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, "자동매매 세션 시작 중 오류가 발생했습니다."),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
78
app/api/autotrade/sessions/stop/route.ts
Normal file
78
app/api/autotrade/sessions/stop/route.ts
Normal 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, "세션 종료 중 오류가 발생했습니다."),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
440
app/api/autotrade/signals/generate/route.ts
Normal file
440
app/api/autotrade/signals/generate/route.ts
Normal 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;
|
||||||
|
}
|
||||||
411
app/api/autotrade/strategies/compile/route.ts
Normal file
411
app/api/autotrade/strategies/compile/route.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
43
app/api/autotrade/strategies/validate/route.ts
Normal file
43
app/api/autotrade/strategies/validate/route.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
39
app/api/autotrade/worker/tick/route.ts
Normal file
39
app/api/autotrade/worker/tick/route.ts
Normal 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, "자동매매 워커 점검 중 오류가 발생했습니다."),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
72
app/api/kis/domestic/market-hub/route.ts
Normal file
72
app/api/kis/domestic/market-hub/route.ts
Normal 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,
|
||||||
|
"시장 허브 조회 중 오류가 발생했습니다.",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
124
app/api/kis/domestic/orderable-cash/route.ts
Normal file
124
app/api/kis/domestic/orderable-cash/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
64
app/api/kis/indices/route.ts
Normal file
64
app/api/kis/indices/route.ts
Normal 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,
|
||||||
|
"지수 조회 중 오류가 발생했습니다.",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
82
common-docs/features/autotrade-model-catalog-runbook.md
Normal file
82
common-docs/features/autotrade-model-catalog-runbook.md
Normal 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)
|
||||||
|
```
|
||||||
144
common-docs/features/autotrade-prompt-flow-guide.md
Normal file
144
common-docs/features/autotrade-prompt-flow-guide.md
Normal 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 실행 인자에 우선 적용됩니다.
|
||||||
407
common-docs/features/autotrade-usage-security-guide.md
Normal file
407
common-docs/features/autotrade-usage-security-guide.md
Normal 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)
|
||||||
269
common-docs/features/autotrade-worker-pm2.md
Normal file
269
common-docs/features/autotrade-worker-pm2.md
Normal 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)
|
||||||
@@ -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)`) 문서화.
|
||||||
@@ -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를 허용하지 않는 환경으로 로그인 필요 응답 확인(추가 환경 정리 후 재실행 필요)
|
||||||
@@ -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` 검증 완료
|
||||||
@@ -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` 통과
|
||||||
@@ -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: 계획 문서 작성 및 기능 구현 완료.
|
||||||
@@ -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
|
||||||
|
- 부분 완료: 없음
|
||||||
|
- 미완료: 없음
|
||||||
|
- 최종 판정: 배포 가능
|
||||||
@@ -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 재검증(데스크톱/모바일, 로고->메인, 메인->대시보드, 자산 탭 전환) 완료.
|
||||||
@@ -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
|
||||||
|
- 부분 완료: 없음
|
||||||
|
- 미완료: 없음
|
||||||
|
- 최종 판정: 배포 가능
|
||||||
@@ -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
|
||||||
|
- 부분 완료: 없음
|
||||||
|
- 미완료: 없음
|
||||||
|
- 최종 판정: 배포 가능
|
||||||
@@ -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
|
||||||
|
- 부분 완료: 없음
|
||||||
|
- 미완료: 없음
|
||||||
|
- 최종 판정: 배포 가능
|
||||||
@@ -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
|
||||||
|
- 부분 완료: 없음
|
||||||
|
- 미완료: 없음
|
||||||
|
- 최종 판정: 배포 가능
|
||||||
@@ -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)`까지 자동 로드 확인.
|
||||||
@@ -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` 재통과 확인.
|
||||||
@@ -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` 재실행 모두 통과함.
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
217
features/autotrade/apis/autotrade.api.ts
Normal file
217
features/autotrade/apis/autotrade.api.ts
Normal 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;
|
||||||
|
}
|
||||||
1343
features/autotrade/components/AutotradeControlPanel.tsx
Normal file
1343
features/autotrade/components/AutotradeControlPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
37
features/autotrade/components/AutotradeWarningBanner.tsx
Normal file
37
features/autotrade/components/AutotradeWarningBanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
2166
features/autotrade/hooks/useAutotradeEngine.ts
Normal file
2166
features/autotrade/hooks/useAutotradeEngine.ts
Normal file
File diff suppressed because it is too large
Load Diff
212
features/autotrade/stores/use-autotrade-engine-store.ts
Normal file
212
features/autotrade/stores/use-autotrade-engine-store.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
482
features/autotrade/types/autotrade.types.ts
Normal file
482
features/autotrade/types/autotrade.types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 실시간 지수 맵
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
348
features/dashboard/components/MarketHubSection.tsx
Normal file
348
features/dashboard/components/MarketHubSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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" />
|
||||||
빠른 주문(준비 중)
|
빠른 주문(준비 중)
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
74
features/layout/components/market-indices.tsx
Normal file
74
features/layout/components/market-indices.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
98
features/layout/hooks/use-market-indices.ts
Normal file
98
features/layout/hooks/use-market-indices.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
39
features/layout/stores/market-indices-store.ts
Normal file
39
features/layout/stores/market-indices-store.ts
Normal 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 }),
|
||||||
|
}));
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
95
features/settings/lib/kis-remember-storage.ts
Normal file
95
features/settings/lib/kis-remember-storage.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 차트 생성/테마 반영
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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")}원`}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 국내주식 현금 주문 요청 모델
|
* 국내주식 현금 주문 요청 모델
|
||||||
*/
|
*/
|
||||||
|
|||||||
649
lib/autotrade/cli-provider.ts
Normal file
649
lib/autotrade/cli-provider.ts
Normal 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;
|
||||||
|
}
|
||||||
56
lib/autotrade/executable-order-quantity.ts
Normal file
56
lib/autotrade/executable-order-quantity.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
94
lib/autotrade/execution-cost.ts
Normal file
94
lib/autotrade/execution-cost.ts
Normal 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
293
lib/autotrade/openai.ts
Normal 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
289
lib/autotrade/risk.ts
Normal 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
424
lib/autotrade/strategy.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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) 파트
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
520
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
85
playwright-report/index.html
Normal file
85
playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
153
scripts/autotrade-dev-smoke.mjs
Normal file
153
scripts/autotrade-dev-smoke.mjs
Normal 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);
|
||||||
|
});
|
||||||
|
|
||||||
117
scripts/autotrade-session-e2e.mjs
Normal file
117
scripts/autotrade-session-e2e.mjs
Normal 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);
|
||||||
|
});
|
||||||
96
scripts/autotrade-worker.mjs
Normal file
96
scripts/autotrade-worker.mjs
Normal 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);
|
||||||
|
});
|
||||||
20
scripts/pm2.autotrade-worker.config.cjs
Normal file
20
scripts/pm2.autotrade-worker.config.cjs
Normal 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
4
test-results/.last-run.json
Normal file
4
test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "passed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
68
tests/autotrade/order-guard-cost.test.ts
Normal file
68
tests/autotrade/order-guard-cost.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
|
||||||
82
tests/autotrade/risk-budget.test.ts
Normal file
82
tests/autotrade/risk-budget.test.ts
Normal 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");
|
||||||
|
});
|
||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user