23 Commits

Author SHA1 Message Date
e51d767878 전체적인 리팩토링 2026-03-12 09:26:27 +09:00
406af7408a 스킬 정리 및 리팩토링 2026-02-26 09:05:17 +09:00
4c52d6d82f 한국투자증권 전체 api 문서 2026-02-25 10:02:06 +09:00
076f27a12a 호가창 매수,매도 색상 변경 2026-02-25 10:01:58 +09:00
f875e338eb codex rule 업데이트 2026-02-25 10:01:49 +09:00
a16af8ad7d 트레이딩창 UI 배치 및 UX 수정 및 기획서 추가 2026-02-24 15:48:02 +09:00
19ebb1c6ea 실시간 웹소켓 리팩토링 2026-02-23 17:09:16 +09:00
276ef09d89 차트 수정 2026-02-13 16:41:10 +09:00
b73867c65d [공통컴포넌트] alert 제작 2026-02-13 16:12:08 +09:00
7c194d7452 보안 점검 및 대시보드 문구 수정 2026-02-13 15:44:41 +09:00
1ac907cd27 대시보드 실시간 기능 추가 2026-02-13 12:56:56 +09:00
12feeb2775 대시보드 추가기능 + 계좌인증 2026-02-12 17:16:41 +09:00
434a814246 대시보드 구현 2026-02-12 14:20:07 +09:00
8f1d75b4d5 정리 2026-02-12 10:24:03 +09:00
3cea3e66d0 임시커밋 2026-02-11 16:31:28 +09:00
f650d51f68 디자인 변경 2026-02-11 15:27:03 +09:00
95291e6922 테마 적용 2026-02-11 14:06:06 +09:00
def87bd47a git ignore 추가 2026-02-11 11:19:44 +09:00
89bad1d141 차트 수정 2026-02-11 11:18:15 +09:00
e5a518b211 레이아웃 변경 대시보드 2026-02-10 17:29:57 +09:00
ca01f33d71 대시보드 중간 커밋 2026-02-10 17:16:49 +09:00
851a2acd69 대시보드 2026-02-06 17:50:35 +09:00
35916430b7 Merge branch 'features/version1' into dev 2026-02-06 14:48:35 +09:00
223 changed files with 54281 additions and 1537 deletions

View File

@@ -1,34 +0,0 @@
---
trigger: always_on
---
# 개발 기본 원칙
## 언어 및 커뮤니케이션
- 모든 응답은 **한글**로 작성
- 코드 주석, 문서, 플랜, 결과물 모두 한글 사용
## 개발 도구 활용
- **Skills**: 프로젝트에 적합한 스킬을 적극 활용하여 베스트 프랙티스 적용
- **MCP 서버**:
- `sequential-thinking`: 복잡한 문제 해결 시 단계별 사고 과정 정리
- `tavily-remote`: 최신 기술 트렌드 및 문서 검색
- `playwright` / `playwriter`: 브라우저 자동화 테스트
- `next-devtools`: Next.js 프로젝트 개발 및 디버깅
- `context7`: 라이브러리/프레임워크 공식 문서 참조
- `supabase-mcp-server`: Supabase 프로젝트 관리 및 쿼리
## 코드 품질
- 린트 에러는 즉시 수정
- React 베스트 프랙티스 준수 (예: useEffect 내 setState 지양)
- TypeScript 타입 안정성 유지
- 접근성(a11y) 고려한 UI 구현
## 테스트 및 검증
- 브라우저 테스트는 MCP Playwright 활용
- 변경 사항은 반드시 로컬에서 검증 후 완료 보고
- 에러 발생 시 근본 원인 파악 및 해결

View File

@@ -1,333 +0,0 @@
---
trigger: manual
---
# 역할
시니어 프론트엔드 엔지니어이자 "문서화 전문가".
목표: **코드 변경 없이 주석만 추가**하여 신규 개발자가 파일을 열었을 때 5분 내에 '사용자 행동 흐름'과 '데이터 흐름'을 파악할 수 있게 만든다.
# 기술 스택
- TypeScript + React/Next.js
- TanStack Query (React Query)
- Zustand
- React Hook Form + Zod
- shadcn/ui
# 출력 규칙 (절대 준수)
1. **코드 로직 변경 금지**: 타입/런타임/동작/변수명/import 변경 절대 금지
2. **주석만 추가**: TSDoc/라인주석/블록주석만 삽입
3. **충분한 인라인 주석**: state, handler, JSX 각 영역에 주석 추가 (과하지 않게 적당히)
4. **한국어 사용**: 딱딱한 한자어 대신 쉬운 일상 용어 사용
────────────────────────────────────────────────────────
# 1) 파일 상단 TSDoc (모든 주요 파일 필수)
**형식:**
```typescript
/**
* @file <파일명>
* @description <1-2줄로 파일 목적 설명>
* @remarks
* - [레이어] Infrastructure/Hooks/Components/Core 중 하나
* - [사용자 행동] 검색 -> 목록 -> 상세 -> 편집 (1줄)
* - [데이터 흐름] UI -> Service -> API -> DTO -> Adapter -> UI (1줄)
* - [연관 파일] types.ts, useXXXQuery.ts (주요 파일만)
* - [주의사항] 필드 매핑/권한/캐시 무효화 등 (있을 때만)
* @example
* // 핵심 사용 예시 2-3줄
*/
```
**원칙:**
- @remarks는 총 5줄 이내로 간결하게
- 당연한 내용 제외 (예: "에러는 전역 처리")
- 단순 re-export 파일은 @description만
────────────────────────────────────────────────────────
# 2) 함수/타입 TSDoc (export 대상)
**필수 대상:**
- Query Key factory
- API 함수 (Service)
- Adapter 함수
- Zustand store/actions
- React Hook Form schema/handler
- Container/Modal 컴포넌트 (모두)
**형식:**
````typescript
/**
* <1줄 설명 (무엇을 하는지)>
* @param <파라미터명> <설명>
* @returns <반환값 설명>
* @remarks <핵심 주의사항 1줄> (선택)
* @see <연관 파일명.tsx의 함수명 - 어떤 역할로 호출하는지>
*/
## 2-1. 복잡한 로직/Server Action 추가 규칙 (권장)
데이터 흐름이나 로직이 복잡한 함수(특히 Server Action)는 **처리 과정**을 명시하여 흐름을 한눈에 파악할 수 있게 한다.
**형식:**
```typescript
/**
* [함수명]
*
* <상세 설명>
*
* 처리 과정:
* 1. <데이터 추출/준비>
* 2. <검증 로직>
* 3. <외부 API/DB 호출>
* 4. <분기 처리 (성공/실패)>
* 5. <결과 반환/리다이렉트>
*
* @param ...
*/
````
````
## ⭐ @see 강화 규칙 (필수)
모든 함수/컴포넌트에 **@see를 적극적으로 추가**하여 호출 관계를 명확히 한다.
**@see 작성 패턴:**
```typescript
/**
* @see OpptyDetailHeader.tsx - handleApprovalClick()에서 다이얼로그 열기
* @see useContractApproval.ts - onConfirm 콜백으로 날짜 전달
*/
/**
* @see LeadDetailPage.tsx - 리드 상세 페이지에서 목록 조회
* @see LeadSearchForm.tsx - 검색 폼 제출 시 호출
*/
````
**@see 필수 포함 정보:**
1. **파일명** - 어떤 파일에서 호출하는지
2. **함수/이벤트명** - 어떤 함수나 이벤트에서 호출하는지
3. **호출 목적** - 왜 호출하는지 (간단히)
**예시:**
```typescript
/**
* 리드 목록 조회 API (검색/필터/정렬/페이징)
* @param params 조회 조건
* @returns 목록, 페이지정보, 통계
* @remarks 정렬 필드는 sortFieldMap으로 프론트↔백엔드 변환
* @see useMainLeads.ts - useQuery의 queryFn으로 호출
* @see LeadTableContainer.tsx - 테이블 데이터 소스로 사용
*/
```
**DTO/Interface:**
```typescript
/**
* 리드 생성 요청 데이터 구조 (DTO)
* @see LeadCreateModal.tsx - 리드 생성 폼 제출 시 사용
*/
export interface CreateLeadRequest { ... }
```
**Query Key Factory:**
```typescript
/**
* 리드 Query Key Factory
* React Query 캐싱/무효화를 위한 키 구조
* @returns ['leads', { entity: 'mainLeads', page, ... }] 형태
* @see useLeadsQuery.ts - queryKey로 사용
* @see useLeadMutations.ts - invalidateQueries 대상
*/
export const leadKeys = { ... }
/** 메인 리드 목록 키 */
mainLeads: (...) => [...],
```
────────────────────────────────────────────────────────
# 3) 인라인 주석 (적극 활용)
## 3-1. State 주석 (필수)
모든 useState/useRef에 역할 주석 추가
```typescript
// [State] 선택된 날짜 (기본값: 오늘)
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
// [State] 캘린더 팝오버 열림 상태
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
// [Ref] 파일 input 참조 (프로그래밍 방식 클릭용)
const fileInputRef = useRef<HTMLInputElement>(null);
```
## 3-2. Handler/함수 주석 (필수)
이벤트 핸들러에 Step 주석 추가
```typescript
/**
* 작성 확인 버튼 클릭 핸들러
* @see OpptyDetailHeader.tsx - handleConfirm prop으로 전달
*/
const handleConfirm = () => {
// [Step 1] 선택된 날짜를 부모 컴포넌트로 전달
onConfirm(selectedDate);
// [Step 2] 다이얼로그 닫기
onClose();
};
```
## 3-3. JSX 영역 주석 (필수)
UI 구조를 파악하기 쉽게 영역별 주석 추가
```tsx
return (
<Dialog>
{/* ========== 헤더 영역 ========== */}
<DialogHeader>
<DialogTitle>제목</DialogTitle>
</DialogHeader>
{/* ========== 본문: 날짜 선택 영역 ========== */}
<div className="space-y-4">
{/* 날짜 선택 Popover */}
<Popover>
{/* 트리거 버튼: 현재 선택된 날짜 표시 */}
<PopoverTrigger>...</PopoverTrigger>
{/* 캘린더 컨텐츠: 한국어 로케일 */}
<PopoverContent>...</PopoverContent>
</Popover>
</div>
{/* ========== 하단: 액션 버튼 영역 ========== */}
<div className="flex gap-2">
<Button>취소</Button>
<Button>확인</Button>
</div>
</Dialog>
);
```
**JSX 주석 규칙:**
- `{/* ========== 영역명 ========== */}` - 큰 섹션 구분
- `{/* 설명 */}` - 개별 요소 설명
- 스크롤 없이 UI 구조 파악 가능하게
────────────────────────────────────────────────────────
# 4) 함수 내부 Step 주석
**대상:**
조건/분기/데이터 변환/API 호출/상태 변경이 있는 함수
**형식:**
```typescript
// [Step 1] <무엇을 하는지 간결하게>
// [Step 2] <다음 단계>
// [Step 3] <최종 단계>
```
**규칙:**
- 각 Step은 1줄로
- 반드시 1번부터 순차적으로
- "무엇을", "왜"를 명확하게
**예시:**
```typescript
export const getMainLeads = async (params) => {
// [Step 1] UI 정렬 필드를 백엔드 컬럼명으로 매핑
const mappedField = sortFieldMap[sortField] || sortField;
// [Step 2] API 요청 파라미터 구성
const requestParams = { ... };
// [Step 3] 리드 목록 조회 API 호출
const { data } = await axiosInstance.get(...);
// [Step 4] 응답 데이터 검증 및 기본값 설정
let dataList = data?.data?.list || [];
// [Step 5] UI 모델로 변환 및 결과 반환
return { list: dataList.map(convertToRow), ... };
}
```
────────────────────────────────────────────────────────
# 5) 레이어별 특수 규칙
## 5-1. Service/API
- **Step 주석**: API 호출 흐름을 단계별로 명시
- **@see**: 이 API를 호출하는 모든 훅/컴포넌트 명시
## 5-2. Hooks (TanStack Query)
- **Query Key**: 반환 구조 예시 필수
- **캐시 전략**: invalidateQueries/setQueryData 사용 이유
- **@see**: 이 훅을 사용하는 모든 컴포넌트 명시
## 5-3. Adapters
- **간단한 변환**: 주석 불필요
- **복잡한 변환**: 입력→출력 1줄 설명 + 변환 규칙
## 5-4. Components (Container/Modal)
- **상태 관리**: 어떤 state가 어떤 이벤트로 변경되는지
- **Dialog/Modal**: open 상태 소유자, 닫힘 조건
- **Table**: 인라인 편집, 스켈레톤 범위
- **JSX 영역 주석**: UI 구조 파악용 영역 구분 주석 필수
## 5-5. Zustand Store
- **왜 store인지**: 페이지 이동/모달 간 상태 유지 이유
- **reset 조건**: 언제 초기화되는지
- **서버 캐시와 역할 분담**: React Query와의 경계
────────────────────────────────────────────────────────
# 6) 작업 순서
1. 파일 레이어 판별 (Infrastructure/Hooks/UI/Core)
2. 파일 상단 TSDoc 추가 (@see 포함)
3. export 대상에 TSDoc 추가 (@see 필수)
4. State/Ref에 인라인 주석 추가
5. Handler 함수에 TSDoc + Step 주석 추가
6. JSX 영역별 구분 주석 추가
7. Query Key Factory에 반환 구조 예시 추가
# 제약사항
- **@author는 jihoon87.lee 고정**
- **@see는 필수**: 호출 관계 명확히
- **Step 주석은 1줄**: 간결하게
- **JSX 주석 필수**: UI 구조 파악용
- **@see는 파일명 + 함수명 + 역할**: 전체 경로 불필요
# 지금부터 작업
내가 주는 코드를 위 규칙에 맞게 "주석만" 보강하라.

View File

@@ -1,96 +0,0 @@
---
name: find-skills
description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", or "is there a skill that can help with X".
---
# Find Skills
This skill helps you discover and install skills from the open agent skills ecosystem.
## When to Use This Skill
Use this skill when the user:
- Asks "how do I do X" where X might be a common task with an existing skill
- Says "find a skill for X" or "is there a skill for X"
- Asks "can you do X" where X is a specialized capability
- Expresses interest in extending agent capabilities
- Wants to search for tools, templates, or workflows
- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)
## What is the Skills CLI?
The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.
**Key commands:**
- `npx skills find [query]` - Search for skills interactively or by keyword
- `npx skills add` - Install a skill from GitHub or other sources
- `npx skills check` - Check for skill updates
- `npx skills update` - Update all installed skills
**Browse skills at:** <https://skills.sh/>
## How to Help Users Find Skills
### Step 1: Understand What They Need
When a user asks for help with something, identify:
1. The domain (e.g., React, testing, design, deployment)
2. The specific task (e.g., writing tests, creating animations, reviewing PRs)
3. Whether this is a common enough task that a skill likely exists
### Step 2: Search for Skills
Run the find command with a relevant query:
```bash
npx skills find [query]
```
For example:
- User asks "how do I make my React app faster?" → `npx skills find react performance`
- User asks "can you help me with PR reviews?" → `npx skills find pr review`
- User asks "I need to create a changelog" → `npx skills find changelog`
### Step 3: Present Recommendations
When you find relevant skills, present them to the user with:
1. The skill name and what it does
2. The installation command
3. A link to the skill's page
**Example response:**
> I found a skill that might help!
>
> **vercel-react-best-practices**
> Vercel's official React performance guidelines for AI agents.
>
> To install it:
> `npx skills add vercel-labs/agent-skills@vercel-react-best-practices`
>
> Learn more: <https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices>
If the user wants to proceed, you can install the skill for them:
```bash
npx skills add vercel-labs/agent-skills@vercel-react-best-practices
```
### Step 4: Verify Installation (Optional)
After installing, you can verify it was installed correctly:
```bash
npx skills list
```
## When No Skills Are Found
1. Try a broader search term
2. Check the [skills.sh](https://skills.sh/) website manually if you suspect a network issue
3. Suggest the user could create their own skill with `npx skills init`

View File

@@ -1,95 +0,0 @@
---
name: vercel-react-best-practices
description: React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.
license: MIT
metadata:
author: vercel
version: "1.0.0"
---
# Vercel React Best Practices
Comprehensive performance optimization guide for React and Next.js applications, maintained by Vercel. Contains 57 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
## When to Apply
Reference these guidelines when:
- Writing new React components or Next.js pages
- Implementing data fetching (client or server-side)
- Reviewing code for performance issues
- Refactoring existing React/Next.js code
- Optimizing bundle size or load times
## Rule Categories by Priority
| Priority | Category | Impact | Prefix |
| -------- | ------------------------- | ----------- | ------------ |
| 1 | Eliminating Waterfalls | CRITICAL | `async-` |
| 2 | Bundle Size Optimization | CRITICAL | `bundle-` |
| 3 | Server-Side Performance | HIGH | `server-` |
| 4 | Client-Side Data Fetching | MEDIUM-HIGH | `client-` |
| 5 | Re-render Optimization | MEDIUM | `rerender-` |
| 6 | Rendering Performance | MEDIUM | `rendering-` |
| 7 | JavaScript Performance | LOW-MEDIUM | `js-` |
| 8 | Advanced Patterns | LOW | `advanced-` |
## Quick Reference
### 1. Eliminating Waterfalls (CRITICAL)
- `async-defer-await` - Move await into branches where actually used
- `async-parallel` - Use Promise.all() for independent operations
- `async-dependencies` - Use better-all for partial dependencies
- `async-api-routes` - Start promises early, await late in API routes
- `async-suspense-boundaries` - Use Suspense to stream content
### 2. Bundle Size Optimization (CRITICAL)
- `bundle-barrel-imports` - Avoid large barrel files; use direct imports
- `bundle-large-libraries` - Optimize heavy deps (e.g. framer-motion, lucide)
- `bundle-conditional` - Lazy load conditional components
- `bundle-route-split` - Split huge page components
- `bundle-dynamic-imports` - Use next/dynamic for heavy client components
### 3. Server-Side Performance (HIGH)
- `server-cache-react` - Use React.cache() for per-request deduplication
- `server-cache-next` - Use unstable_cache for data coaching
- `server-only-utils` - Mark server-only code with 'server-only' package
- `server-component-boundaries` - Keep client components at leaves
- `server-image-optimization` - Use next/image with proper sizing
### 4. Client-Side Data Fetching (MEDIUM-HIGH)
- `client-use-swr` - Use SWR/TanStack Query for client-side data
- `client-no-effect-fetch` - Avoid fetching in useEffect without caching libraries
- `client-prefetch-link` - Use next/link prefetching
- `client-caching-headers` - Respect cache-control headers
### 5. Re-render Optimization (MEDIUM)
- `rerender-memo-props` - Memoize complex props
- `rerender-dependencies` - Use primitive dependencies in effects
- `rerender-functional-setstate` - Use functional setState for stable callbacks
- `rerender-context-split` - Split context to avoid wide re-renders
### 6. Rendering Performance (MEDIUM)
- `rendering-image-priority` - Priority load LCP images
- `rendering-list-virtualization` - Virtualize long lists
- `rendering-content-visibility` - Use content-visibility for long lists
- `rendering-hoist-jsx` - Extract static JSX outside components
- `rendering-hydration-no-flicker` - Use inline script for client-only data
### 7. JavaScript Performance (LOW-MEDIUM)
- `js-batch-dom-css` - Group CSS changes
- `js-index-maps` - Build Map for repeated lookups
- `js-combine-iterations` - Combine multiple filter/map into one loop
- `js-set-map-lookups` - Use Set/Map for O(1) lookups
### 8. Advanced Patterns (LOW)
- `advanced-event-handler-refs` - Store event handlers in refs
- `advanced-init-once` - Initialize app once per app load

View File

@@ -0,0 +1,64 @@
---
name: dev-auto-pipeline
description: 기능 개발/버그 수정/리팩토링 같은 구현 요청에서 계획→구현→리팩토링→테스트→완료체크를 순서대로 실행하는 상위 오케스트레이션 스킬. 단순 설명/문서 요약/잡담에는 사용하지 않는다.
---
# Dev Auto Pipeline
## 목표
- 개발 요청을 표준 5단계로 자동 처리한다.
- 각 단계 결과를 다음 단계 입력으로 넘겨 누락을 줄인다.
## 실행 단계 (고정)
1. `dev-plan-writer`
2. `dev-mcp-implementation`
3. `dev-refactor-polish`
4. `dev-test-gate`
5. `dev-plan-completion-checker`
## 단계 연결 규칙
1. 계획 단계에서 생성한 계획 문서(`common-docs/improvement/plans/*.md`)를 이후 단계의 기준 문서로 사용한다.
2. 구현/리팩토링에서 변경된 파일 목록을 테스트 단계 입력으로 전달한다.
3. 테스트 결과를 완료체크 단계 입력으로 전달한다.
4. 완료체크는 계획 대비 `완료/부분 완료/미완료`와 최종 판정을 반드시 남긴다.
## 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`
- 제외 문서:
- `common-docs/features-autotrade-design.md`
## 최종 보고 형식
```md
[1. 계획]
- ...
[2. 구현]
- ...
[3. 리팩토링/성능/가독성]
- ...
- 파일 상단 역할 주석 반영 여부
- 핵심 입력 데이터 흐름 추적표 포함 여부
[4. 테스트]
- ...
[5. 계획 대비 완료체크]
- 완료/부분 완료/미완료
- 최종 판정: 배포 가능/보완 필요
[6. 핵심 입력 흐름 추적표]
- 입력값: (예: 전략 프롬프트)
- UI 입력 -> 핸들러 -> 훅/서비스 -> API -> route -> provider -> 결과 반영
- 각 단계는 파일/라인 링크 포함
```

View File

@@ -0,0 +1,6 @@
interface:
display_name: "Dev Auto Pipeline"
short_description: "Run end-to-end development pipeline"
default_prompt: "Use $dev-auto-pipeline to execute plan, implement, refactor, test, and completion checks."
policy:
allow_implicit_invocation: true

View File

@@ -0,0 +1,123 @@
---
name: dev-mcp-implementation
description: 구현 단계에서 MCP와 기존 스킬을 활용해 근거 기반으로 코드를 작성하는 스킬. 계획 문서가 확정된 뒤 실제 코드 변경이 필요할 때 사용하며, 단순 계획 작성/완료 판정 단계에는 사용하지 않는다.
---
# Dev MCP Implementation
## 목표
- 추측 구현을 줄이고 공식 문서/런타임 진단 기반으로 구현한다.
- 구현 결과를 나중에 리팩토링/테스트 단계로 넘기기 쉬운 형태로 만든다.
## 기본 구현 원칙 (AGENTS 반영)
1. 모든 코드/주석/설명은 한국어 기준으로 작성한다.
2. 기술 스택 기준을 지킨다.
- Next.js 16 App Router, React 19, TypeScript
- Zustand(클라이언트 UI 상태), Supabase, react-hook-form + zod
- Tailwind CSS v4, Radix UI
3. 사이드이펙트가 예상되면 영향 범위를 먼저 확인하고 구현한다.
4. 불필요한 삭제는 하지 않는다. 삭제가 필요하면 영향 검증 후 진행한다.
## 구현 순서
1. `dev-plan-writer` 결과를 읽고 구현 범위를 고정한다.
2. Next.js 프로젝트면 `next-devtools`로 현재 라우트/에러 상태를 먼저 확인한다.
3. 외부 라이브러리 API가 모호하면 `context7`로 공식 문서를 확인한다.
4. 복잡한 로직은 `sequential-thinking`으로 엣지 케이스(경계 상황)를 먼저 정리한다.
5. DB/권한/SQL 변경은 `supabase-mcp-server`로 안전하게 반영한다.
6. 코드 수정 후 최소 동작 확인(`lint`/핵심 UI 실행)을 진행한다.
## 리팩토링 구현 규칙 (refactoring-rule 반영)
1. 리팩토링 요청이면 `FEATURE_ROOT` 기준으로 작업한다.
2. 아래 기본 구조를 우선 사용한다.
- `apis`, `components`, `hooks`, `stores`, `types`
3. 필요 시 선택 구조를 사용한다.
- `utils`, `lib`, `constants`
4. 대형 파일은 책임 단위로 분해하고, 로직은 보존한다.
5. `index.ts` 배럴 export 의존을 줄이고 직접 경로 import로 전환한다.
6. 파일 이동 후 외부 진입점(`page.tsx` 등) import까지 함께 갱신한다.
## 필수 적용 스킬
- `nextjs-app-router-patterns`: Server/Client 경계 검증
- `vercel-react-best-practices`: 렌더링/번들/데이터 요청 최적화
## MCP 활용 맵 (AGENTS 반영)
- `next-devtools`: Next.js 라우트/컴파일/런타임 오류 점검
- `playwright`: 브라우저 상호작용/스모크 검증
- `playwriter`: Chrome 확장 기반 상세 디버깅
- `context7`: 라이브러리/프레임워크 공식 문서 조회
- `supabase-mcp-server`: DB/SQL/함수 작업
- `tavily-remote`: 최신 자료/기술 검색
- `sequential-thinking`: 복잡 로직 단계화
- `figma`: 디자인 파일 레이아웃/스타일/에셋 확인
- `mcp:kis-code-assistant-mcp`: 한국투자증권 API 검색/소스 확인
## 코드/주석 규칙 (문서화 전문가 기준)
1. 주석 보강 작업은 코드 로직(타입/런타임/동작/변수명/import)을 바꾸지 않는다.
2. 주석은 쉬운 한글로 작성하고 "사용처"와 "데이터 흐름"을 먼저 보이게 쓴다.
3. 함수/API/Query 주석은 아래 3가지를 중심으로 작성한다.
- `[목적]`
- `[사용처]`
- `[데이터 흐름]`
4. 상태(`useState`, `useRef`, store)에는 "값이 바뀌면 화면이 어떻게 변하는지" 한 줄 주석을 단다.
5. 복잡한 로직/이벤트 핸들러는 `1, 2, 3...` 단계 주석으로 흐름을 나눈다.
6. 긴 JSX는 화면 구역별 주석으로 시각적으로 분리한다.
- 예: `{/* ===== 1. 상단: 제목/액션 ===== */}`
7. `@param`, `@see`, `@remarks` 같은 딱딱한 TSDoc 태그는 강제하지 않는다.
## UI/브랜드/문구 규칙
1. 새 UI는 `indigo/purple/pink` 하드코딩 대신 `brand-*` 토큰을 사용한다.
2. 기본 액션 색은 `primary`를 우선한다.
3. 색상 톤 변경은 컴포넌트 개별 수정보다 `app/globals.css` 토큰 조정을 우선 검토한다.
4. 사용자 문구는 불안을 줄이고 확신을 주는 친근한 톤을 사용한다.
## common-docs 구현 규칙
1. KIS API 구현 기준:
- `openapi_all.xlsx`를 1순위 스펙으로 본다.
- 문서 확인 순서: `openapi_all.xlsx` -> `mcp:kis-code-assistant-mcp` -> `.tmp/open-trading-api` -> `kis_api_reference.md`
- 차이가 크면 사용자에게 최신 파일 재확인을 요청한다.
2. 에러코드 처리 기준:
- `kis-error-code-reference.md`를 따라 `msg_cd + 문구` 형태를 유지한다.
- `lib/kis/error-codes.ts``buildKisErrorDetail`/`getKisErrorGuide` 사용 패턴을 유지한다.
3. 종목 마스터 데이터 기준:
- `features/trade/data/korean-stocks.json`은 수동 편집하지 않는다.
- `trade-stock-sync.md` 기준으로 `npm run sync:stocks` / `npm run sync:stocks:check`를 사용한다.
4. 전역 알림 UI 기준:
- `GLOBAL_ALERT_SYSTEM.md` 기준으로 `useGlobalAlert` 패턴을 우선 사용한다.
- 로컬 임시 Alert/Confirm 구현보다 전역 시스템(`GlobalAlertModal`) 연동을 우선한다.
5. 제외 문서:
- `features-autotrade-design.md`는 현 구현 기준에서 제외한다.
## 출력 템플릿
```md
[구현 결과]
- ...
[사용한 MCP/Skills]
- MCP: ...
- Skills: ...
[변경 파일]
- ...
[핵심 데이터 흐름]
- 어느 UI -> A 함수 호출 -> B 함수 호출 -> 리턴값 반영
[남은 이슈]
- ...
```
## 규칙
- 필요 없는 파일/코드는 남기지 않는다.
- 불확실한 라이브러리 API는 문서 근거 없이 단정하지 않는다.
- 구현 단계에서 성능에 큰 악영향이 보이면 즉시 메모(기록)하고 다음 단계에서 정리한다.

View File

@@ -0,0 +1,23 @@
interface:
display_name: "Dev MCP Implementation"
short_description: "Implement features with MCP workflows"
default_prompt: "Use $dev-mcp-implementation to build code using MCP-first verification."
dependencies:
tools:
- type: "mcp"
value: "next-devtools"
description: "Next.js route and runtime diagnostics"
- type: "mcp"
value: "context7"
description: "Official framework and library docs"
- type: "mcp"
value: "supabase-mcp-server"
description: "Supabase SQL and function operations"
- type: "mcp"
value: "playwright"
description: "Browser smoke verification"
- type: "mcp"
value: "kis-code-assistant-mcp"
description: "KIS API lookup and source references"
policy:
allow_implicit_invocation: false

View File

@@ -0,0 +1,58 @@
---
name: dev-plan-completion-checker
description: 구현 완료 후 계획 문서와 실제 변경·테스트 근거를 대조해 완료 상태를 판정하는 스킬. 최종 점검 단계에서 사용하며, 계획 작성/구현/테스트 실행 단계를 대신하지 않는다.
---
# Dev Plan Completion Checker
## 목표
- 계획대로 구현이 수행됐는지 객관적으로 확인한다.
- 누락/부분 완료 항목을 마지막에 명확히 남긴다.
## 입력
1. 계획 문서 경로 (`common-docs/improvement/plans/*.md`)
2. 변경 파일 목록
3. 테스트 결과 (`lint`, `build`, `playwright smoke`, 추가 검증)
## 작업 순서
1. 계획 문서의 체크 항목을 읽는다.
- 구현 단계 체크박스
- 검증 계획 체크박스
2. 변경 파일/테스트 결과를 근거로 각 항목 상태를 판정한다.
- 완료: 근거가 충분함
- 부분 완료: 일부 근거만 있음
- 미완료: 근거가 없음
3. 누락 항목에 대해 바로 실행 가능한 후속 작업을 작성한다.
4. 최종 완료 판정(`배포 가능` / `보완 필요`)을 내린다.
## 판정 규칙
1. 구현 단계에 미완료가 1개 이상이면 `보완 필요`
2. 검증 계획에 미완료가 있으면 `보완 필요`
3. 테스트 생략 항목은 사유와 대체 검증이 있으면 `부분 완료`로 인정 가능
## 출력 템플릿
```md
[계획 문서]
- 경로: ...
[완료 체크 결과]
- 완료: ...
- 부분 완료: ...
- 미완료: ...
[근거]
- 변경 파일: ...
- 테스트 결과: ...
[보완 필요 항목]
1. ...
2. ...
[최종 판정]
- 배포 가능/보완 필요
```

View File

@@ -0,0 +1,6 @@
interface:
display_name: "Dev Completion Checker"
short_description: "Check plan completion against evidence"
default_prompt: "Use $dev-plan-completion-checker to compare plan checklists with changed files and test results."
policy:
allow_implicit_invocation: false

View File

@@ -0,0 +1,153 @@
---
name: dev-plan-writer
description: 구현 전에 실행 가능한 계획 문서를 만드는 스킬. 기능 추가/버그 수정/구조 변경 요청에서 범위·영향·작업 순서·검증 기준을 먼저 고정할 때 사용하며, 실제 코드 대량 구현 단계에서는 단독 사용하지 않는다.
---
# Dev Plan Writer
## 목표
- 구현 전에 계획부터 확정하여 누락(빠뜨림)을 줄인다.
- 주니어 개발자도 바로 따라갈 수 있게 단계를 단순하게 작성한다.
## 언어/소통 규칙
1. 모든 계획과 설명을 한국어로 작성한다.
2. 어려운 용어는 짧은 괄호 설명을 붙인다.
3. 요청이 모호하면 질문 1~3개로 범위를 먼저 고정한다.
## 프로젝트 기본 컨텍스트
- 기술 스택: Next.js 16 App Router, React 19, TypeScript, Zustand, Supabase, react-hook-form, zod, Tailwind CSS v4, Radix UI
- 기본 명령어: `npm run dev`(포트 3001), `npm run lint`, `npm run build`, `npm run start`
## 안전 계획 규칙
1. 수정/추가/삭제 파일을 분리해서 영향 범위를 먼저 적는다.
2. 삭제/이동/계약 변경(입출력 규칙 변경)은 사전 확인 질문을 남긴다.
3. "진짜 필요 없는 코드만 제거" 원칙으로 계획을 세운다.
4. 사이드이펙트(옆 영향) 가능성이 있으면 검증 단계를 계획에 반드시 넣는다.
## 작업 순서
1. 요구사항을 3줄 이내로 요약한다.
2. 모호한 부분이 있으면 질문 1~3개로 범위를 먼저 고정한다.
3. 영향 파일(수정/추가/삭제)을 먼저 찾고, 사이드이펙트(옆 영향)를 표시한다.
4. 사용할 MCP/Skills를 단계별로 고른다.
5. 구현 단계를 순서대로 작성한다.
6. 검증 단계를 구현 단계와 1:1로 매핑한다.
## 계획 문서 저장 규칙 (필수)
1. 저장 위치: `common-docs/improvement/plans/`
2. 파일명 규칙: `dev-plan-YYYY-MM-DD-<작업슬러그>.md`
- 예: `dev-plan-2026-02-25-order-validation.md`
3. 하나의 개발 요청은 하나의 계획 파일을 기준으로 끝까지 추적한다.
4. 구현이 시작되면 같은 파일에 진행/완료 상태를 계속 갱신한다.
## 계획 상태 관리 규칙
1. 구현 단계/검증 계획을 체크박스 형식으로 작성한다.
2. 각 체크 항목 옆에 근거(변경 파일, 테스트 결과)를 짧게 남긴다.
3. 완료 판단은 마지막에 `dev-plan-completion-checker`가 수행한다.
## 리팩토링 요청 전용 계획 규칙 (refactoring-rule 반영)
1. 입력값으로 `FEATURE_ROOT`를 명시한다.
2. 목표에 아래 4가지를 반드시 넣는다.
- 표준 폴더 구조(`apis/components/hooks/stores/types`)
- 선택 폴더 허용(`utils/lib/constants`)
- 대형 파일 분해
- 배럴 파일 제거 및 직접 import
3. 작업 지시는 6단계로 고정해 계획한다.
- 분석 -> 구조 설계 -> 이동/생성 -> 경로 수정 -> 청소 -> 진입점 갱신
4. 계획 문서에 "권장 파일 구조 트리"를 포함한다.
## 도구 선택 기준
- Next.js 런타임/라우트 점검: `next-devtools`
- 라이브러리 공식 문서 확인: `context7`
- 복잡 로직 분해: `sequential-thinking`
- Supabase SQL/함수 작업: `supabase-mcp-server`
- 브라우저 동작 검증: `playwright`
- Chrome 확장 기반 디버깅: `playwriter`
- 최신 기술/레퍼런스 검색: `tavily-remote`
- Figma 레이아웃/스타일 확인: `figma`
## common-docs 계획 반영 규칙
1. `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`
2. 아래 문서는 계획에서 제외한다.
- `common-docs/features-autotrade-design.md` (향후 기획 문서)
3. KIS 연동 작업이면 스펙 확인 순서를 계획에 명시한다.
- `openapi_all.xlsx` -> `mcp:kis-code-assistant-mcp` -> `.tmp/open-trading-api` -> `kis_api_reference.md`
4. 종목 코드/마스터 데이터 변경이면 `trade-stock-sync.md` 기준으로 자동 동기화 명령을 계획에 넣는다.
5. 사용자 알림/확인 모달 변경이면 `GLOBAL_ALERT_SYSTEM.md` 기준으로 전역 알림 시스템 유지 계획을 넣는다.
## 출력 템플릿
```md
[계획 문서 경로]
- common-docs/improvement/plans/dev-plan-YYYY-MM-DD-<작업슬러그>.md
[요구사항 요약]
- ...
[확인 질문(필요 시 1~3개)]
- ...
[가정]
- ...
[영향 범위]
- 수정: ...
- 추가: ...
- 삭제: ...
[구현 단계]
- [ ] 1. ...
- [ ] 2. ...
- [ ] 3. ...
[사용할 MCP/Skills]
- MCP: ...
- Skills: ...
[참조 문서(common-docs)]
- ...
[주석/문서 반영 계획]
- 함수 주석: [목적]/[사용처]/[데이터 흐름]
- 상태 주석: 값 변경 시 화면 영향 한 줄 설명
- 복잡 로직/핸들러: 1, 2, 3 단계 주석
- JSX 구역 주석: 화면 구조가 보이게 분리
- TSDoc 딱딱한 태그(`@param`, `@see`, `@remarks`) 강제 없음
[리팩토링 구조 계획(리팩토링 요청 시)]
- FEATURE_ROOT: ...
- 목표(표준 구조/선택 구조/대형파일 분해/배럴 제거): ...
- Workflow 6단계: ...
- 권장 구조 트리: ...
[리스크/회귀 포인트]
- ...
[검증 계획]
- [ ] 1. ...
- [ ] 2. ...
- [ ] 3. ...
[진행 로그]
- 2026-..-..: ...
```
## 규칙
- 계획 승인 전에 실제 구현 코드를 대량 작성하지 않는다.
- 파일 삭제는 반드시 필요성/대체 경로를 확인한 뒤 진행한다.
- 동작 변경과 리팩토링을 섞지 않는다.

View File

@@ -0,0 +1,17 @@
interface:
display_name: "Dev Plan Writer"
short_description: "Write implementation plans with checks"
default_prompt: "Use $dev-plan-writer to create a tracked implementation plan file."
dependencies:
tools:
- type: "mcp"
value: "next-devtools"
description: "Next.js runtime and route diagnostics"
- type: "mcp"
value: "context7"
description: "Official library documentation lookup"
- type: "mcp"
value: "sequential-thinking"
description: "Step-by-step reasoning for complex planning"
policy:
allow_implicit_invocation: false

View File

@@ -0,0 +1,212 @@
---
name: dev-refactor-polish
description: 구현 완료 직후 가독성·데이터 흐름·성능을 다듬는 후처리 리팩토링 스킬. 핵심 동작을 바꾸지 않는 정리 단계에서 사용하며, 신규 기능의 본 구현 단계 대체 용도로는 사용하지 않는다.
---
# Dev Refactor Polish
## 목표
- 핵심 비즈니스 동작은 유지하고 코드 품질을 높인다.
- 읽기 쉬운 구조와 명확한 데이터 흐름 설명을 만든다.
- 사용자 혼란을 줄이는 작은 UX 개선(문구, 버튼 라벨, 피드백 표시)은 허용한다.
## 리팩토링 목표 (refactoring-rule 반영)
1. 표준 폴더 구조를 지향한다.
- 기본: `apis`, `components`, `hooks`, `stores`, `types`
2. 필요 시 보조 폴더를 유연하게 허용한다.
- 선택: `utils`, `lib`, `constants`
3. 거대한 단일 파일은 기능 단위로 분해한다.
4. 배럴 파일(`index.ts` re-export) 의존을 줄이고 직접 import 경로를 사용한다.
## 리팩토링 기본 원칙
1. 설명과 주석은 한국어로 쉽게 쓴다. 어려운 용어는 괄호로 짧게 풀어쓴다.
2. 사이드이펙트 위험이 있으면 영향 범위를 먼저 확인하고 수정한다.
3. 불필요한 코드만 제거하고, 영향 가능성이 있으면 검증 후 반영한다.
## 리팩토링 순서
1. 핵심 동작 변경 없이 중복 코드를 줄인다.
2. 함수/변수 이름을 역할이 드러나는 이름으로 정리한다.
3. 복잡한 JSX는 섹션 주석으로 나눈다.
4. 상태/이벤트/복잡 로직에 인라인 주석을 보강한다.
5. 함수/API/Query에 쉬운 설명 주석을 보강한다.
6. 데이터 흐름을 `어느 UI -> A 함수 호출 -> B 함수 호출 -> 리턴값 반영`으로 명시한다.
7. `vercel-react-best-practices` 기준으로 불필요한 리렌더/요청을 줄인다.
## 작업 지시 (Workflow, refactoring-rule 반영)
1. 분석: `FEATURE_ROOT` 내부 파일 구조와 외부 import 의존성을 파악한다.
2. 구조 설계: 기본/선택 폴더 기준으로 파일 분류 계획을 세운다.
3. 이동/생성: 파일을 이동하거나 책임 단위로 분리 생성한다.
4. 경로 수정: 이동된 파일에 맞춰 import 경로를 일괄 수정한다.
5. 청소: 옛 폴더와 불필요한 `index.ts`를 정리한다.
6. 진입점 갱신: `page.tsx` 등 외부 진입 파일 import를 최종 갱신한다.
## 권장 파일 구조 (Standard Structure)
```text
<FEATURE_ROOT>/
├── apis/
│ ├── apiError.ts
│ ├── <feature>.api.ts
│ ├── <feature>Form.adapter.ts
│ └── <feature>List.adapter.ts
├── hooks/
│ ├── queryKeys.ts
│ ├── use<Feature>List.ts
│ ├── use<Feature>Mutations.ts
│ └── use<Feature>Form.ts
├── types/
│ ├── api.types.ts
│ ├── <feature>.types.ts
│ └── selectOption.types.ts
├── stores/
│ └── <feature>Store.ts
├── components/
│ ├── <Feature>Container.tsx
│ └── <Feature>Modal.tsx
├── utils/ # Optional
│ └── <feature>Utils.ts
├── lib/ # Optional
│ └── <feature>Lib.ts
└── constants/ # Optional
└── <feature>.constants.ts
```
## 의존성/리스크 분석 규칙
1. 파일 이동 전 `sequential-thinking`으로 의존성 지도를 먼저 만든다.
2. 최신 구조 기준이 모호하면 `context7`로 공식 문서를 확인한다.
## common-docs 리팩토링 반영 규칙
1. KIS 연동 리팩토링 시 아래 기준을 유지한다.
- 스펙 기준: `common-docs/api-reference/openapi_all.xlsx`
- 보조 확인: `kis_api_reference.md`, `kis-error-code-reference.md`
2. 에러 처리 리팩토링 시 `lib/kis/error-codes.ts` 중심 구조(`msg_cd + 문구`)를 깨지 않게 유지한다.
3. 종목 데이터 리팩토링 시 `korean-stocks.json`을 수동 편집 대상으로 옮기지 않는다.
- 동기화 흐름은 `trade-stock-sync.md` 기준으로 유지한다.
4. 알림 UI 리팩토링 시 전역 알림 구조(`useGlobalAlert`, `GlobalAlertModal`)를 우선 유지한다.
5. `features-autotrade-design.md`는 리팩토링 기준 문서로 사용하지 않는다.
## 주석 규칙 (문서화 전문가 기준)
1. 주석 보강 작업은 코드 로직(타입/런타임/동작/변수명/import)을 바꾸지 않는다.
2. 함수/API/쿼리 주석은 `[목적]`, `[사용처]`, `[데이터 흐름]` 중심으로 쉽게 쓴다.
3. 상태(`useState`, `useRef`, `useMemo`, store 파생 상태)는 반드시 `[State]`, `[Ref]` 형식으로 역할 주석을 단다.
- 예: `// [State] 자동매매 실행 중 여부 (배너/버튼 상태에 사용)`
- 예: `// [Ref] 마지막 신호 요청 시각 (요청 과다 방지용)`
4. 복잡한 로직/핸들러는 반드시 `[Step 1]`, `[Step 2]`, `[Step 3]` 형식으로 흐름을 나눈다.
- 예: `// [Step 1] 입력값 유효성 검증`
5. 긴 JSX는 화면 구역 주석으로 나눠서 읽기 쉽게 만든다.
- 예: `{/* ========== 1. 상단: 상태/액션 영역 ========== */}`
6. 데이터 흐름이 중요한 입력(UI prompt, 검색어, 주문 설정값)은 입력 지점에 "어디 API로 가는지"를 한 줄로 명시한다.
- 예: `// [데이터 흐름] 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/브랜드/문구 규칙
1. UI 수정 시 `brand-*` 토큰과 `primary` 사용 기준을 지킨다.
2. 사용자 문구는 불안을 줄이고 확신을 주는 톤으로 개선한다.
## 품질 체크리스트
- 핵심 비즈니스 로직 변경이 없는가?
- 작은 UX 개선이라도 API 계약(입출력 규칙), 권한, 저장 데이터 구조를 건드리지 않았는가?
- 주니어가 5분 안에 흐름을 파악할 수 있는가?
- 상태 변경이 화면 어디에 반영되는지 보이는가?
- 무거운 계산/렌더가 메모이제이션(재계산 줄이기) 대상인지 검토했는가?
## 출력 템플릿
```md
[리팩토링 요약]
- ...
[가독성 개선 포인트]
- ...
[작은 UX 개선 포인트]
- ...
[성능 개선 포인트]
- ...
[데이터 흐름 정리]
- 어느 UI -> A 함수 호출 -> B 함수 호출 -> 리턴값 반영
[핵심 입력 흐름 추적표]
- 입력값: (예: 전략 프롬프트)
- [파일:라인] -> 함수 -> 다음 호출
[회귀 위험 점검]
- ...
```
## 규칙
- 파일 이동/삭제가 있으면 영향 범위를 먼저 검증한다.
- 구조 개선은 하되 과도한 분리(쪼개기)는 피한다.
- 작은 UX 개선을 했으면 왜 개선했는지 한 줄 근거를 남긴다.

View File

@@ -0,0 +1,14 @@
interface:
display_name: "Dev Refactor Polish"
short_description: "Refactor code for readability and performance"
default_prompt: "Use $dev-refactor-polish to improve readability, data flow, and small UX polish."
dependencies:
tools:
- type: "mcp"
value: "context7"
description: "Official docs for framework-safe refactors"
- type: "mcp"
value: "sequential-thinking"
description: "Dependency impact reasoning before file moves"
policy:
allow_implicit_invocation: false

View File

@@ -0,0 +1,91 @@
---
name: dev-test-gate
description: 개발/리팩토링 후 lint·build·Playwright 스모크 테스트를 실행하고 실패 원인을 정리하는 검증 스킬. 최종 품질 게이트 단계에서 사용하며, 구현 자체를 대체하지 않는다.
---
# Dev Test Gate
## 목표
- 변경 사항의 안정성을 빠르게 확인한다.
- 실패 원인과 영향 범위를 짧고 명확하게 남긴다.
## 공통 기준
1. 결과 보고는 한국어로 작성한다.
2. 테스트 결과는 주니어도 이해 가능하게 쉬운 말로 정리한다.
3. 테스트 생략은 원칙적으로 금지하고, 불가한 경우 사유와 대체 검증을 남긴다.
## 테스트 순서
1. 정적 검사: `npm run lint`
2. 빌드 검사: `npm run build`
3. 개발 서버 실행: `npm run dev` (기본 포트 3001)
4. 런타임 확인: 핵심 화면 로드와 기본 동작 확인
5. Playwright 스모크 테스트(기본): 핵심 화면 간단 확인을 반드시 수행
6. 사용자 요청 테스트가 있으면 해당 테스트를 추가 실행한다.
## Playwright 스모크 기본 규칙
1. 핵심 화면 3종을 기본 대상으로 잡는다.
2. 화면 타입은 아래 기준으로 고른다.
- 서비스 진입 화면 1개
- 핵심 기능 화면 1개
- 설정/인증 관련 화면 1개
3. 각 화면에서 최소 항목을 확인한다.
- 페이지 로드 성공
- 치명 오류 문구/콘솔 에러 없음
- 핵심 버튼 또는 입력 요소 1개 이상 상호작용 가능
## 검증 보강 규칙
1. UI 변경이 있으면 브랜드 토큰(`brand-*`, `primary`) 적용 여부를 함께 점검한다.
2. KIS API 연동 변경이 있으면 계좌/인증/오류 처리 기본 시나리오를 스모크 범위에 포함한다.
3. 리팩토링 요청이면 구조 점검을 추가한다.
- `FEATURE_ROOT`가 목표 구조(`apis/components/hooks/stores/types`)를 따르는지 확인
- 파일 이동 후 진입점 import 경로가 깨지지 않았는지 확인
- 불필요한 `index.ts` 배럴 파일 잔존 여부를 확인
## common-docs 연계 검증 규칙
1. KIS 연동 파일 변경 시 아래를 점검한다.
- `kis_api_reference.md` 기준 엔드포인트/흐름이 크게 어긋나지 않는지 확인
- `kis-error-code-reference.md` 기준 `msg_cd + 문구` 표시 흐름 유지 확인
2. `features/trade/data/korean-stocks.json` 또는 동기화 스크립트 변경 시
- `npm run sync:stocks:check`를 추가 실행한다.
3. 전역 알림 관련 파일(`features/layout/hooks/use-global-alert.ts`, `GlobalAlertModal`) 변경 시
- 핵심 시나리오(성공 알림 1건, 확인 모달 1건)를 스모크 검증에 포함한다.
4. `features-autotrade-design.md`는 테스트 기준 문서에서 제외한다.
## 실패 처리 규칙
1. 실패 로그에서 직접 원인 라인을 먼저 찾는다.
2. 원인 수정 후 같은 테스트를 재실행한다.
3. 연쇄 실패(한 수정으로 여러 실패)가 있으면 우선순위를 나눠 정리한다.
4. 시간/환경 제한으로 테스트를 못 돌리면 이유와 대체 검증을 반드시 기록한다.
## 출력 템플릿
```md
[테스트 결과]
- lint: 통과/실패
- build: 통과/실패
- playwright smoke: 통과/실패
- common-docs 연계 검증: 통과/실패
- 추가 테스트: ...
[실패 및 조치]
- ...
[최종 상태]
- 배포 가능/보류
```
## 완료체크 인계 규칙
1. 테스트 결과는 `dev-plan-completion-checker`에 그대로 전달한다.
2. 전달 형식은 아래 4줄을 포함한다.
- lint 결과
- build 결과
- playwright smoke 결과
- 생략/실패 사유 및 대체 검증

View File

@@ -0,0 +1,14 @@
interface:
display_name: "Dev Test Gate"
short_description: "Run lint, build, and Playwright smoke tests"
default_prompt: "Use $dev-test-gate to run lint, build, and smoke verification before completion."
dependencies:
tools:
- type: "mcp"
value: "playwright"
description: "Browser smoke test automation"
- type: "mcp"
value: "next-devtools"
description: "Next.js runtime error and route checks"
policy:
allow_implicit_invocation: false

View File

@@ -0,0 +1,14 @@
interface:
display_name: "Next.js App Router Patterns"
short_description: "Next.js App Router patterns and checks"
default_prompt: "Use $nextjs-app-router-patterns to review App Router structure, server/client boundaries, and data fetching patterns."
dependencies:
tools:
- type: "mcp"
value: "next-devtools"
description: "Next.js runtime route and error diagnostics"
- type: "mcp"
value: "context7"
description: "Official Next.js documentation lookup"
policy:
allow_implicit_invocation: false

View File

@@ -1,9 +1,42 @@
# Supabase 환경 설정 예제 파일 # Supabase 환경 설정 예제 파일
# 이 파일의 이름을 .env.local 변경한 뒤, 실제 값을 채워넣으세요. # 이 파일을 .env.local로 복사한 뒤 실제 값을 채워세요.
# 값 확인: https://supabase.com/dashboard/project/_/settings/api # 값 확인: https://supabase.com/dashboard/project/_/settings/api
NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY= 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

6
.gemini/settings.json Normal file
View File

@@ -0,0 +1,6 @@
{
"tools": {
"approvalMode": "auto_edit",
"allowed": ["run_shell_command"]
}
}

6
.gitignore vendored
View File

@@ -119,8 +119,14 @@ storybook-static/
*.local *.local
.cache/ .cache/
node_modules node_modules
.tmp/
# ======================================== # ========================================
# Custom # Custom
# ======================================== # ========================================
.playwright-mcp/ .playwright-mcp/
# ========================================
# Documentation (문서)
# ========================================
docs/

View File

@@ -1,45 +1,25 @@
# AGENTS.md (auto-trade) # AGENTS.md (auto-trade)
## 기본 원칙 ## 운영 원칙
- 모든 응답과 설명은 한국어로 작성.
- 쉬운 말로 설명. 어려운 용어는 괄호로 짧게 뜻을 덧붙임.
- 요청이 모호하면 먼저 질문 1~3개로 범위를 확인.
## 프로젝트 요약 - 중복 규칙은 `AGENTS.md`에 두지 않고 각 스킬(`.agents/skills/*/SKILL.md`)에서 관리한다.
- Next.js 16 App Router, React 19, TypeScript - 개발 작업은 스킬 기반으로 수행한다.
- 상태 관리: zustand
- 데이터: Supabase
- 폼 및 검증: react-hook-form, zod
- UI: Tailwind CSS v4, Radix UI (components.json 사용)
## 명령어 ## 스킬 호출 규칙
- 개발 서버: (포트는 3001번이야)
pm run dev
- 린트:
pm run lint
- 빌드:
pm run build
- 실행:
pm run start
## 코드 및 문서 규칙 - 개발 요청 키워드(`개발해줘`, `구현해줘`, `기능 추가`, `버그 수정`, `리팩토링`, `개선해줘`, `고쳐줘`, `최적화해줘`, `만들어줘`)가 들어오면 `dev-auto-pipeline` 스킬을 우선 사용한다.
- JSX 섹션 주석 형식: {/* ========== SECTION NAME ========== */} - 파이프라인 단계 스킬은 아래 순서로 사용한다.
- 함수 및 컴포넌트 JSDoc에 @see 필수 (호출 파일, 함수 또는 이벤트 이름, 목적 포함) 1. `dev-plan-writer`
- 상태 정의, 이벤트 핸들러, 복잡한 JSX 로직에는 인라인 주석을 충분히 작성 2. `dev-mcp-implementation`
3. `dev-refactor-polish`
4. `dev-test-gate`
5. `dev-plan-completion-checker`
- 단순 설명/문서 요약/잡담 요청에는 파이프라인 스킬을 강제하지 않는다.
## 브랜드 색상 규칙 ## 설명 방식 규칙
- 메인 컬러는 헤더 로고/프로필 기준의 보라 계열 `brand` 팔레트를 사용.
- 새 UI 작성 시 `indigo/purple/pink` 하드코딩 대신 `brand-*` 토큰 사용. 예: `bg-brand-500`, `text-brand-600`, `from-brand-500 to-brand-700`.
- 기본 액션 색(버튼/포커스)은 `primary`를 사용하고, `primary``app/globals.css``brand` 팔레트와 같은 톤으로 유지.
- 색상 변경이 필요하면 컴포넌트 개별 수정보다 먼저 `app/globals.css` 토큰을 수정.
## 설명 방식 - 사용자 설명은 어려운 용어보다 쉬운 한국어를 우선 사용한다.
- 단계별로 짧게, 예시는 1개만. - 기술 용어를 써야 할 때는 바로 아래 줄에 쉬운 말로 다시 풀어쓴다.
- 사용자가 요청한 변경과 이유를 함께 설명. - 데이터 흐름 설명은 항상 `입력 -> 처리 -> 결과` 순서의 짧은 단계로 말한다.
- 파일 경로는 pp/...처럼 코드 형식으로 표기. - 사용자가 헷갈린 상황에서는 추상 설명보다 "지금 화면에서 확인할 것"을 먼저 안내한다.
- 요청/응답 설명 시에는 핵심 필드 3~5개만 먼저 보여주고, 필요 시 상세를 추가한다.
## 여러 도구를 함께 쓸 때 (쉬운 설명)
- 기준 설명을 한 군데에 모아두고, 그 파일만 계속 업데이트하는 것이 핵심.
- 예를 들어 PROJECT_CONTEXT.md에 스택, 폴더 구조, 규칙을 적어둔다.
- 그리고 각 도구의 규칙 파일에 PROJECT_CONTEXT.md를 참고라고 써 둔다.
- 이렇게 하면 어떤 도구를 쓰든 같은 파일을 읽게 되어, 매번 다시 설명할 일이 줄어든다.

View File

@@ -1,48 +0,0 @@
# PROJECT_CONTEXT.md
이 파일은 프로젝트 설명의 기준(원본)입니다.
여기만 업데이트하고, 다른 도구 규칙에서는 이 파일을 참고하도록 연결하세요.
## 한 줄 요약
- 자동매매(오토 트레이드) 웹 앱
## 기술 스택
- Next.js 16 (App Router)
- React 19, TypeScript
- 상태 관리: zustand
- 데이터: Supabase
- 폼/검증: react-hook-form, zod
- UI: Tailwind CSS v4, Radix UI
## 폴더 구조(핵심만)
- pp/ 라우팅 및 페이지
- eatures/ 도메인별 기능
- components/ 공용 UI
- lib/ 유틸/클라이언트
- utils/ 헬퍼
## 주요 규칙(요약)
- JSX 섹션 주석 형식: {/* ========== SECTION NAME ========== */}
- 함수/컴포넌트 JSDoc에 @see 필수
- 파일 상단에 @author jihoon87.lee
- 상태/이벤트/복잡 로직에 인라인 주석 충분히 작성
## 작업 흐름
- 개발 서버:
pm run dev
- 린트:
pm run lint
- 빌드:
pm run build
- 실행:
pm run start
## 자주 하는 설명 템플릿
- 변경 이유: (왜 바꾸는지)
- 변경 내용: (무엇을 바꾸는지)
- 영향 범위: (어디에 영향이 있는지)
## 업데이트 가이드
- 새 규칙/패턴이 생기면 여기에 먼저 추가
- 문장이 길어지면 더 짧게 요약
- 도구별 규칙 파일에는 PROJECT_CONTEXT.md 참고만 적기

164
README.md
View File

@@ -1,36 +1,160 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # auto-trade
## Getting Started 한국투자증권(KIS) Open API와 Supabase 인증을 연결한 국내주식 트레이딩 대시보드입니다.
사용자는 로그인 후 API 키를 검증하고, 종목 검색/상세/차트/호가/체결/주문 화면을 한 곳에서 사용할 수 있습니다.
First, run the development server: ## 1) 핵심 기능
- 인증: Supabase 이메일/소셜 로그인, 비밀번호 재설정, 세션 타임아웃 자동 로그아웃
- KIS 연결: 실전/모의 모드 선택, API 키 검증, 웹소켓 승인키 발급
- 트레이드 화면: 종목 검색, 종목 개요, 캔들 차트, 실시간 호가/체결, 현금 주문
- 종목 검색 인덱스: `korean-stocks.json` 기반 고속 검색 + 자동 갱신 스크립트
## 2) 기술 스택
- 프레임워크: Next.js 16 (App Router), React 19, TypeScript
- 상태관리: Zustand
- 서버 상태: TanStack Query (React Query)
- 인증/백엔드: Supabase (`@supabase/ssr`, `@supabase/supabase-js`)
- UI: Tailwind CSS v4, Radix UI, Sonner
- 차트: `lightweight-charts`
## 3) 화면/라우트
- `/`: 서비스 랜딩 페이지
- `/login`, `/signup`, `/forgot-password`, `/reset-password`: 인증 페이지
- `/dashboard`: 로그인 전용(현재 플레이스홀더)
- `/settings`: KIS API 키 연결/해제
- `/trade`: 실제 트레이딩 대시보드
## 4) UI 흐름 (중요)
- 인증 흐름: 로그인 UI -> `features/auth/actions.ts` -> Supabase Auth -> 세션 쿠키 반영 -> 보호 라우트 접근 허용
- KIS 연결 흐름: 설정 UI -> `/api/kis/validate` -> 토큰 발급 성공 확인 -> `use-kis-runtime-store`에 검증 상태 저장
- 실시간 흐름: 트레이드 UI -> `/api/kis/ws/approval` -> `useKisTradeWebSocket` 구독 -> 체결/호가 파싱 -> 차트/호가창 반영
- 검색 흐름: 검색 UI -> `/api/kis/domestic/search` -> `korean-stocks.json` 메모리 검색 -> 결과 목록 반영
## 5) 빠른 시작
### 5-1. 요구 사항
- Node.js 20 이상
- npm 10 이상 권장
### 5-2. 설치
```bash
npm install
```
### 5-3. 환경변수 설정
`.env.example`을 복사해서 `.env.local`을 만듭니다.
```bash
cp .env.example .env.local
```
Windows PowerShell:
```powershell
Copy-Item .env.example .env.local
```
필수 값은 아래를 먼저 채우면 됩니다.
- `NEXT_PUBLIC_SUPABASE_URL`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
KIS는 `.env`에 저장하지 않고 `/settings` 입력 폼에서 직접 입력합니다.
- 앱키/시크릿/계좌번호는 사용자 브라우저에서만 관리
- 서버 API는 로그인 세션 + 요청 헤더로 전달된 키가 있어야 동작
- `NEXT_PUBLIC_BASE_URL` (선택, 배포 도메인 사용 시 권장)
### 5-4. 로컬 실행
```bash ```bash
npm run dev npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - 개발 서버: `http://localhost:3001`
- Turbopack 적용: `package.json``dev` 스크립트에 `--turbopack` 포함
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. ### 5-5. 점검 명령
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. ```bash
npm run lint
npm run build
npm run start
```
## Learn More ## 6) 종목 인덱스 동기화
To learn more about Next.js, take a look at the following resources: `features/trade/data/korean-stocks.json`은 수동 편집용 파일이 아닙니다.
KIS 마스터 파일(KOSPI/KOSDAQ)에서 자동 생성합니다.
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. ```bash
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. npm run sync:stocks
```
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 검증만 하고 싶으면:
## Deploy on Vercel ```bash
npm run sync:stocks:check
```
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 상세 문서: `docs/trade-stock-sync.md`
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. ## 7) API 엔드포인트 요약
- 인증/연결
- `POST /api/kis/validate`: API 키 검증
- `POST /api/kis/revoke`: 토큰 폐기
- `POST /api/kis/ws/approval`: 웹소켓 승인키 발급
- 국내주식
- `GET /api/kis/domestic/search?q=...`: 종목 검색(로컬 인덱스)
- `GET /api/kis/domestic/overview?symbol=...`: 종목 개요
- `GET /api/kis/domestic/orderbook?symbol=...`: 호가
- `GET /api/kis/domestic/chart?symbol=...&timeframe=...`: 차트
- `POST /api/kis/domestic/order-cash`: 현금 주문
## 8) 프로젝트 구조
```text
app/
(home)/ 랜딩
(auth)/ 로그인/회원가입/비밀번호 재설정
(main)/ 로그인 후 화면(dashboard/trade/settings)
api/kis/ KIS 연동 API 라우트
features/
auth/ 인증 UI/액션/상수
settings/ KIS 키 설정 UI + 런타임 스토어
trade/ 검색/차트/호가/주문/웹소켓
lib/kis/ KIS REST/WS 공통 로직
scripts/
sync-korean-stocks.mjs
utils/supabase/ 서버/클라이언트/미들웨어 클라이언트
```
## 9) 트러블슈팅
- KIS 검증 실패
- 입력한 앱키/시크릿, 실전/모의 모드가 맞는지 확인
- KIS Open API 앱 권한과 IP 허용 설정 확인
- 실시간 체결/호가가 안 들어옴
- `/settings`에서 검증 상태가 유지되는지 확인
- 장 구간(장중/동시호가/시간외)에 따라 데이터가 달라질 수 있음
- 브라우저 콘솔 디버그가 필요하면 `localStorage.KIS_WS_DEBUG = "1"` 설정
- 검색 결과가 기대와 다름
- 검색은 KIS 실시간 검색 API가 아니라 로컬 인덱스(`korean-stocks.json`) 기반
- 최신 종목 반영이 필요하면 `npm run sync:stocks` 실행
## 10) 운영 주의사항
- 현재 KIS 입력값과 검증 상태 일부는 브라우저 로컬 스토리지(localStorage, 브라우저 저장소)에 저장됩니다.
- 실전 운영 시에는 민감정보 보관 정책(암호화, 만료, 서버 보관 방식)을 반드시 따로 설계하세요.
- 주문은 계좌번호가 필요합니다. 계좌번호 입력/검증 UX는 운영 환경에 맞게 보완이 필요합니다.

View File

@@ -12,6 +12,7 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import Link from "next/link"; import Link from "next/link";
import { AUTH_ROUTES } from "@/features/auth/constants"; import { AUTH_ROUTES } from "@/features/auth/constants";
import { Mail } from "lucide-react";
/** /**
* [비밀번호 찾기 페이지] * [비밀번호 찾기 페이지]
@@ -31,10 +32,10 @@ export default async function ForgotPasswordPage({
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700"> <div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
{message && <FormMessage message={message} />} {message && <FormMessage message={message} />}
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70"> <Card className="border-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/70">
<CardHeader className="space-y-3 text-center"> <CardHeader className="space-y-3 text-center">
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200"> <div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
<span className="text-sm font-semibold">MAIL</span> <Mail className="h-7 w-7 text-white" />
</div> </div>
<CardTitle className="text-3xl font-bold tracking-tight"> <CardTitle className="text-3xl font-bold tracking-tight">
@@ -59,13 +60,13 @@ export default async function ForgotPasswordPage({
placeholder="name@example.com" placeholder="name@example.com"
autoComplete="email" autoComplete="email"
required required
className="h-11 transition-all duration-200" className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
/> />
</div> </div>
<Button <Button
formAction={requestPasswordReset} formAction={requestPasswordReset}
className="h-11 w-full bg-linear-to-r from-gray-900 to-black font-semibold text-white shadow-lg transition-all hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white" className="h-11 w-full bg-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
> >
</Button> </Button>
@@ -74,7 +75,7 @@ export default async function ForgotPasswordPage({
<div className="text-center"> <div className="text-center">
<Link <Link
href={AUTH_ROUTES.LOGIN} href={AUTH_ROUTES.LOGIN}
className="text-sm font-medium text-gray-700 hover:text-black dark:text-gray-300 dark:hover:text-white" className="text-sm font-medium text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
> >
</Link> </Link>

View File

@@ -12,17 +12,18 @@ export default async function AuthLayout({
} = await supabase.auth.getUser(); } = await supabase.auth.getUser();
return ( return (
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-linear-to-br from-gray-50 via-white to-gray-100 dark:from-black dark:via-gray-950 dark:to-gray-900"> <div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-linear-to-br from-brand-50 via-white to-brand-100/60 dark:from-brand-950 dark:via-gray-950 dark:to-brand-900/40">
{/* ========== 헤더 (홈 이동용) ========== */} {/* ========== 헤더 (홈 이동용) ========== */}
<Header user={user} /> <Header user={user} />
{/* ========== 배경 그라디언트 레이어 ========== */} {/* ========== 배경 그라디언트 레이어 ========== */}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,var(--tw-gradient-stops))] from-gray-200/30 via-gray-100/15 to-transparent dark:from-gray-800/30 dark:via-gray-900/20" /> <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,var(--tw-gradient-stops))] from-brand-200/40 via-brand-100/20 to-transparent dark:from-brand-800/25 dark:via-brand-900/15" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,var(--tw-gradient-stops))] from-gray-300/30 via-gray-200/15 to-transparent dark:from-gray-700/30 dark:via-gray-800/20" /> <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,var(--tw-gradient-stops))] from-brand-300/30 via-brand-200/15 to-transparent dark:from-brand-700/20 dark:via-brand-800/10" />
{/* ========== 애니메이션 블러 효과 ========== */} {/* ========== 애니메이션 블러 효과 ========== */}
<div className="absolute left-1/4 top-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-300/20 blur-3xl dark:bg-gray-700/20" /> <div className="absolute left-1/4 top-1/4 h-72 w-72 animate-pulse rounded-full bg-brand-300/25 blur-3xl dark:bg-brand-700/15" />
<div className="absolute bottom-1/4 right-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-400/20 blur-3xl delay-700 dark:bg-gray-600/20" /> <div className="absolute bottom-1/4 right-1/4 h-72 w-72 animate-pulse rounded-full bg-brand-400/20 blur-3xl delay-700 dark:bg-brand-600/15" />
<div className="absolute right-1/3 top-1/3 h-48 w-48 animate-pulse rounded-full bg-brand-200/30 blur-2xl delay-1000 dark:bg-brand-800/20" />
{/* ========== 메인 콘텐츠 영역 (중앙 정렬) ========== */} {/* ========== 메인 콘텐츠 영역 (중앙 정렬) ========== */}
<main className="z-10 flex w-full flex-1 flex-col items-center justify-center px-4 py-12"> <main className="z-10 flex w-full flex-1 flex-col items-center justify-center px-4 py-12">

View File

@@ -7,13 +7,13 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import LoginForm from "@/features/auth/components/login-form"; import LoginForm from "@/features/auth/components/login-form";
import { LogIn } from "lucide-react";
/** /**
* [로그인 페이지 컴포넌트] * [로그인 페이지 컴포넌트]
* *
* Modern UI with glassmorphism effect (유리 형태 디자인) * 브랜드 컬러 기반 글래스모피즘 카드 디자인
* - 투명 배경 + 블러 효과로 깊이감 표현 * - 보라색 그라디언트 아이콘 배지
* - 그라디언트 배경으로 생동감 추가
* - shadcn/ui 컴포넌트로 일관된 디자인 시스템 유지 * - shadcn/ui 컴포넌트로 일관된 디자인 시스템 유지
* *
* @param searchParams - URL 쿼리 파라미터 (에러 메시지 전달용) * @param searchParams - URL 쿼리 파라미터 (에러 메시지 전달용)
@@ -23,36 +23,25 @@ export default async function LoginPage({
}: { }: {
searchParams: Promise<{ message: string }>; searchParams: Promise<{ message: string }>;
}) { }) {
// URL에서 메시지 파라미터 추출 (로그인 실패 시 에러 메시지 표시)
const { message } = await searchParams; const { message } = await searchParams;
return ( return (
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700"> <div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
{/* 에러/성공 메시지 표시 영역 */}
{/* URL 파라미터에 message가 있으면 표시됨 */}
<FormMessage message={message} /> <FormMessage message={message} />
{/* ========== 로그인 카드 (Glassmorphism) ========== */} <Card className="border-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/70">
{/* bg-white/70: 70% 투명도의 흰색 배경 */}
{/* backdrop-blur-xl: 배경 블러 효과 (유리 느낌) */}
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
{/* ========== 카드 헤더 영역 ========== */}
<CardHeader className="space-y-3 text-center"> <CardHeader className="space-y-3 text-center">
{/* 아이콘 배경: 그라디언트 원형 */} <div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200"> <LogIn className="h-7 w-7 text-white" />
<span className="text-4xl">👋</span>
</div> </div>
{/* 페이지 제목 */}
<CardTitle className="text-3xl font-bold tracking-tight"> <CardTitle className="text-3xl font-bold tracking-tight">
! !
</CardTitle> </CardTitle>
{/* 페이지 설명 */}
<CardDescription className="text-base"> <CardDescription className="text-base">
. .
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
{/* ========== 카드 콘텐츠 영역 (폼) ========== */}
<CardContent> <CardContent>
<LoginForm /> <LoginForm />
</CardContent> </CardContent>

View File

@@ -9,6 +9,7 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { createClient } from "@/utils/supabase/server"; import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { KeyRound } from "lucide-react";
/** /**
* [비밀번호 재설정 페이지] * [비밀번호 재설정 페이지]
@@ -39,10 +40,10 @@ export default async function ResetPasswordPage({
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700"> <div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
{message && <FormMessage message={message} />} {message && <FormMessage message={message} />}
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70"> <Card className="border-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/70">
<CardHeader className="space-y-3 text-center"> <CardHeader className="space-y-3 text-center">
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200"> <div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
<span className="text-sm font-semibold">PW</span> <KeyRound className="h-7 w-7 text-white" />
</div> </div>
<CardTitle className="text-3xl font-bold tracking-tight"> <CardTitle className="text-3xl font-bold tracking-tight">

View File

@@ -9,6 +9,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { UserPlus } from "lucide-react";
export default async function SignupPage({ export default async function SignupPage({
searchParams, searchParams,
@@ -19,13 +20,12 @@ export default async function SignupPage({
return ( return (
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700"> <div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
{/* 메시지 알림 */}
<FormMessage message={message} /> <FormMessage message={message} />
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70"> <Card className="border-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/70">
<CardHeader className="space-y-3 text-center"> <CardHeader className="space-y-3 text-center">
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200"> <div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
<span className="text-4xl">🚀</span> <UserPlus className="h-7 w-7 text-white" />
</div> </div>
<CardTitle className="text-3xl font-bold tracking-tight"> <CardTitle className="text-3xl font-bold tracking-tight">
@@ -35,16 +35,14 @@ export default async function SignupPage({
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
{/* ========== 폼 영역 ========== */}
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<SignupForm /> <SignupForm />
{/* ========== 로그인 링크 ========== */} <p className="text-center text-sm text-muted-foreground">
<p className="text-center text-sm text-gray-600 dark:text-gray-400">
?{" "} ?{" "}
<Link <Link
href={AUTH_ROUTES.LOGIN} href={AUTH_ROUTES.LOGIN}
className="font-semibold text-gray-900 transition-colors hover:text-black dark:text-gray-100 dark:hover:text-white" className="font-semibold text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
> >
</Link> </Link>

View File

@@ -1,224 +1,211 @@
/** /**
* @file app/(home)/page.tsx * @file app/(home)/page.tsx
* @description 서비스 메인 랜딩 페이지 * @description 서비스 메인 랜딩 페이지(Server Component)
* @remarks
* - [레이어] Pages (Server Component)
* - [역할] 비로그인 유저 유입, 브랜드 소개, 로그인/대시보드 유도
* - [구성요소] Header, Hero Section (Spline 3D), Feature Section (Bento Grid)
* - [데이터 흐름] Server Auth Check -> Client Component Props
*/ */
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button"; import { ArrowRight, Sparkles } from "lucide-react";
import { createClient } from "@/utils/supabase/server";
import { Header } from "@/features/layout/components/header"; import { Header } from "@/features/layout/components/header";
import { AUTH_ROUTES } from "@/features/auth/constants"; import { AUTH_ROUTES } from "@/features/auth/constants";
import { SplineScene } from "@/features/home/components/spline-scene"; import { Button } from "@/components/ui/button";
import ShaderBackground from "@/components/ui/shader-background";
import { createClient } from "@/utils/supabase/server";
import { AnimatedBrandTone } from "@/components/ui/animated-brand-tone";
interface StartStep {
step: string;
title: string;
description: string;
}
const START_STEPS: StartStep[] = [
{
step: "01",
title: "앱키 연결, 1분이면 끝",
description:
"복잡한 절차 없이, 지금 쓰는 계좌로 바로 시작할 수 있어요.",
},
{
step: "02",
title: "투자금/손실선만 입력하세요",
description:
"어렵게 계산할 필요 없이, 내가 감당 가능한 금액만 정하면 돼요.",
},
{
step: "03",
title: "신호 확인 후 자동 실행",
description:
"차트 감시는 JOORIN-E가 맡고, 당신은 중요한 순간만 확인하면 됩니다.",
},
];
/** /**
* 메인 페이지 컴포넌트 (비동기 서버 컴포넌트) * 메인 랜딩 페이지
* @returns Landing Page Elements * @returns 랜딩 UI
* @see layout.tsx - RootLayout 내에서 렌더링
* @see spline-scene.tsx - 3D 인터랙션
*/ */
export default async function HomePage() { export default async function HomePage() {
// [Step 1] 서버 사이드 인증 상태 확인
const supabase = await createClient(); const supabase = await createClient();
const { const {
data: { user }, data: { user },
} = await supabase.auth.getUser(); } = await supabase.auth.getUser();
const primaryCtaHref = user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.SIGNUP;
const primaryCtaLabel = user ? "내 전략 시작하기" : "무료로 시작하기";
return ( return (
<div className="flex min-h-screen flex-col overflow-x-hidden"> <div className="flex min-h-screen flex-col overflow-x-hidden bg-black text-white selection:bg-brand-500/30">
<Header user={user} showDashboardLink={true} /> <Header user={user} showDashboardLink={true} blendWithBackground={true} />
<main className="flex-1 bg-background pt-16"> <main className="relative isolate flex-1">
{/* Background Pattern */} {/* ========== BACKGROUND ========== */}
<div className="absolute inset-0 -z-10 h-full w-full bg-white dark:bg-black bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-size-[14px_24px] mask-[radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]" /> <ShaderBackground opacity={0.6} className="-z-20" />
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10 bg-black/60"
/>
<section className="container relative mx-auto max-w-7xl px-4 pt-12 md:pt-24 lg:pt-32"> {/* ========== HERO SECTION ========== */}
<div className="flex flex-col items-center justify-center text-center"> <section className="container mx-auto max-w-5xl px-4 pt-32 md:pt-48">
{/* Badge */} <div className="flex flex-col items-center text-center">
<div className="mb-6 inline-flex items-center rounded-full border border-brand-200/50 bg-brand-50/50 px-3 py-1 text-sm font-medium text-brand-600 backdrop-blur-md dark:border-brand-800/50 dark:bg-brand-900/50 dark:text-brand-300"> <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="mr-2 flex h-2 w-2 animate-pulse rounded-full bg-brand-500"></span> <Sparkles className="h-3.5 w-3.5" />
The Future of Trading , JOORIN-E
</div> </span>
<h1 className="font-heading text-4xl font-black tracking-tight text-foreground sm:text-6xl md:text-7xl lg: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 className="hidden sm:block" />
<span className="animate-gradient-x bg-linear-to-r from-brand-500 via-brand-600 to-brand-700 bg-clip-text text-transparent underline decoration-brand-500/30 decoration-4 underline-offset-8"> <br />
<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-6 max-w-2xl text-lg text-muted-foreground sm:text-xl leading-relaxed break-keep"> <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">
AutoTrade는 24 , .
.
<br className="hidden md:block" /> <br className="hidden md:block" />
. , .
</p> </p>
<div className="mt-8 flex flex-col items-center gap-4 sm:flex-row"> <div className="mt-12 flex animate-in slide-in-from-bottom-6 flex-col gap-4 duration-1000 sm:flex-row">
{user ? (
<Button <Button
asChild asChild
size="lg" size="lg"
className="h-14 rounded-full px-10 text-lg font-bold shadow-xl shadow-brand-500/20 transition-all hover:scale-105 hover:shadow-brand-500/40" className="group h-14 min-w-[200px] rounded-full bg-brand-500 px-10 text-lg font-bold text-white transition-all hover:scale-105 hover:bg-brand-400 active:scale-95"
> >
<Link href={AUTH_ROUTES.DASHBOARD}> </Link> <Link href={primaryCtaHref}>
{primaryCtaLabel}
<ArrowRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" />
</Link>
</Button> </Button>
) : ( </div>
<Button </div>
asChild </section>
size="lg"
className="h-14 rounded-full px-10 text-lg font-bold shadow-xl shadow-brand-500/20 transition-all hover:scale-105 hover:shadow-brand-500/40" {/* ========== BRAND TONE SECTION (움직이는 글자) ========== */}
> <section className="container mx-auto max-w-5xl px-4 py-24 md:py-40">
<Link href={AUTH_ROUTES.LOGIN}> </Link> <AnimatedBrandTone />
</Button> </section>
)}
{!user && ( {/* ========== SIMPLE STEPS SECTION ========== */}
<Button <section className="container mx-auto max-w-5xl px-4 py-24">
asChild <div className="flex flex-col items-center gap-16 md:flex-row md:items-start">
variant="outline" <div className="flex-1 text-center md:text-left">
size="lg" <h2 className="text-3xl font-black md:text-5xl">
className="h-14 rounded-full border-border bg-background/50 px-10 text-lg hover:bg-accent/50 backdrop-blur-sm"
> <br />
<Link href={AUTH_ROUTES.LOGIN}> </Link> <span className="text-brand-300">3 .</span>
</Button> </h2>
)} <p className="mt-6 text-sm leading-relaxed text-white/50 md:text-lg">
-&gt; / -&gt; .
<br />
, .
</p>
</div> </div>
{/* Spline Scene - Centered & Wide */} <div className="flex-2 grid w-full gap-4 md:grid-cols-1">
<div className="relative mt-16 w-full max-w-5xl"> {START_STEPS.map((item) => (
<div className="group relative aspect-video w-full overflow-hidden rounded-3xl border border-white/20 bg-linear-to-b from-white/10 to-transparent shadow-2xl backdrop-blur-2xl dark:border-white/10 dark:bg-black/20"> <div
{/* Glow Effect */} key={item.step}
<div className="absolute -inset-1 rounded-3xl bg-linear-to-r from-brand-500 via-brand-600 to-brand-700 opacity-20 blur-2xl transition-opacity duration-500 group-hover:opacity-40" /> className="group flex items-center gap-6 rounded-2xl border border-white/5 bg-white/5 p-6 transition-all hover:bg-white/10"
>
<span className="text-3xl font-black text-brand-500/50 group-hover:text-brand-500">
{item.step}
</span>
<div>
<h3 className="text-lg font-bold text-white">
{item.title}
</h3>
<p className="mt-1 text-sm text-white/50">
{item.description}
</p>
</div>
</div>
))}
</div>
</div>
<SplineScene {/* 보안 안심 문구 (사용자 요청 반영) */}
sceneUrl="https://prod.spline.design/6Wq1Q7YGyM-iab9i/scene.splinecode" <div className="mt-16 flex flex-col items-center justify-center text-center animate-in slide-in-from-bottom-6 duration-1000 delay-300">
className="relative z-10 h-full w-full rounded-2xl" <div className="flex max-w-2xl flex-col items-center gap-4 rounded-2xl border border-brand-500/20 bg-brand-500/5 p-8 backdrop-blur-sm md:flex-row md:gap-8 md:text-left">
/> <div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-brand-500/10 text-brand-400">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-shield-check"
>
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
<path d="m9 12 2 2 4-4" />
</svg>
</div>
<div>
<h3 className="text-lg font-bold text-brand-100">
/, ?
</h3>
<p className="mt-2 text-sm leading-relaxed text-brand-200/70">
<strong className="text-brand-200">
.
</strong>
<br />
JOORIN-E는 ,
<br className="hidden md:block" />
API .
<br className="hidden md:block" />
.
</p>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
{/* Features Section - Bento Grid */} {/* ========== FINAL CTA SECTION ========== */}
<section className="container mx-auto max-w-7xl px-4 py-24 sm:py-32"> <section className="container mx-auto max-w-5xl px-4 py-32">
<div className="mb-16 text-center"> <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="font-heading text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl"> <h2 className="text-3xl font-black md:text-6xl">
,{" "}
<span className="text-brand-500"> </span> <br />
.
</h2> </h2>
<p className="mt-4 text-lg text-muted-foreground"> <div className="mt-12 flex justify-center">
. <Button
</p> asChild
size="lg"
className="h-16 rounded-full bg-white px-12 text-xl font-black text-black transition-all hover:scale-110 active:scale-95"
>
<Link href={primaryCtaHref}>{primaryCtaLabel}</Link>
</Button>
</div> </div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 md:gap-8"> <p className="mt-8 text-sm text-white/30">
{/* Feature 1 */} © 2026 POPUP STUDIO. All rights reserved.
<div className="group relative col-span-1 overflow-hidden rounded-3xl border border-border/50 bg-background/50 p-8 shadow-sm transition-all hover:shadow-xl hover:-translate-y-1 dark:bg-zinc-900/50 md:col-span-2">
<div className="relative z-10 flex flex-col justify-between h-full gap-6">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-50 text-brand-600 dark:bg-brand-900/20 dark:text-brand-300">
<svg
className="w-8 h-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
/>
</svg>
</div>
<div>
<h3 className="text-2xl font-bold mb-2"> </h3>
<p className="text-muted-foreground text-lg leading-relaxed">
.
<br />
.
</p> </p>
</div> </div>
</div>
<div className="absolute top-0 right-0 h-64 w-64 translate-x-1/2 -translate-y-1/2 rounded-full bg-brand-500/10 blur-3xl transition-all duration-500 group-hover:bg-brand-500/20" />
</div>
{/* Feature 2 (Tall) */}
<div className="group relative col-span-1 row-span-2 overflow-hidden rounded-3xl border border-border/50 bg-background/50 p-8 shadow-sm transition-all hover:shadow-xl hover:-translate-y-1 dark:bg-zinc-900/50">
<div className="relative z-10 flex flex-col h-full gap-6">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-50 text-brand-600 dark:bg-brand-900/20 dark:text-brand-300">
<svg
className="w-8 h-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19.428 15.428a2 2 0 00-1.022-.547l-2.384-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"
/>
</svg>
</div>
<h3 className="text-2xl font-bold"> </h3>
<p className="text-muted-foreground text-lg">
24 .
</p>
<div className="mt-auto space-y-4 pt-4">
{[
"추세 추종 전략",
"변동성 돌파",
"AI 예측 모델",
"리스크 관리",
].map((item) => (
<div
key={item}
className="flex items-center gap-3 text-sm font-medium text-foreground/80"
>
<div className="h-2 w-2 rounded-full bg-brand-500" />
{item}
</div>
))}
</div>
</div>
<div className="absolute bottom-0 left-0 h-64 w-64 -translate-x-1/2 translate-y-1/2 rounded-full bg-brand-500/10 blur-3xl transition-all duration-500 group-hover:bg-brand-500/20" />
</div>
{/* Feature 3 */}
<div className="group relative col-span-1 overflow-hidden rounded-3xl border border-border/50 bg-background/50 p-8 shadow-sm transition-all hover:shadow-xl hover:-translate-y-1 dark:bg-zinc-900/50 md:col-span-2">
<div className="relative z-10 flex flex-col justify-between h-full gap-6">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-50 text-brand-600 dark:bg-brand-900/20 dark:text-brand-300">
<svg
className="w-8 h-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
</div>
<div>
<h3 className="text-2xl font-bold mb-2"> </h3>
<p className="text-muted-foreground text-lg leading-relaxed">
, MDD를
<br />
.
</p>
</div>
</div>
<div className="absolute right-0 bottom-0 h-40 w-40 translate-x-1/3 translate-y-1/3 rounded-full bg-brand-500/10 blur-3xl transition-all duration-500 group-hover:bg-brand-500/20" />
</div>
</div>
</section> </section>
</main> </main>
</div> </div>

View File

@@ -1,115 +1,25 @@
/** /**
* @file app/(main)/dashboard/page.tsx * @file app/(main)/dashboard/page.tsx
* @description 사용자 대시보드 메인 페이지 (보호된 라우트) * @description 로그인 사용자 전용 대시보드 페이지(Server Component)
* @remarks
* - [레이어] Pages (Server Component)
* - [역할] 사용자 자산 현황, 최근 활동, 통계 요약을 보여줌
* - [권한] 로그인한 사용자만 접근 가능 (Middleware에 의해 보호됨)
*/ */
import { redirect } from "next/navigation";
import { DashboardContainer } from "@/features/dashboard/components/DashboardContainer";
import { createClient } from "@/utils/supabase/server"; import { createClient } from "@/utils/supabase/server";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Activity, CreditCard, DollarSign, Users } from "lucide-react";
/** /**
* 대시보드 페이지 (비동기 서버 컴포넌트) * 대시보드 페이지
* @returns Dashboard Grid Layout * @returns DashboardContainer UI
* @see features/dashboard/components/DashboardContainer.tsx 대시보드 상태 헤더/지수/보유종목 UI를 제공합니다.
*/ */
export default async function DashboardPage() { export default async function DashboardPage() {
// [Step 1] 세션 확인 (Middleware가 1차 방어하지만, 데이터 접근 시 2차 확인) // 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
const supabase = await createClient(); const supabase = await createClient();
await supabase.auth.getUser(); const {
data: { user },
} = await supabase.auth.getUser();
return ( if (!user) redirect("/login");
<div className="flex-1 space-y-4 p-8 pt-6">
<div className="flex items-center justify-between space-y-2"> return <DashboardContainer />;
<h2 className="text-3xl font-bold tracking-tight"></h2>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> </CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">$45,231.89</div>
<p className="text-xs text-muted-foreground"> +20.1%</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+2350</div>
<p className="text-xs text-muted-foreground"> +180.1%</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<CreditCard className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+12,234</div>
<p className="text-xs text-muted-foreground"> +19%</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> </CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+573</div>
<p className="text-xs text-muted-foreground"> +201</p>
</CardContent>
</Card>
</div>
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-7">
<Card className="col-span-4">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="pl-2">
{/* Chart placeholder */}
<div className="h-[200px] w-full bg-slate-100 dark:bg-slate-800 rounded-md flex items-center justify-center text-muted-foreground">
( )
</div>
</CardContent>
</Card>
<Card className="col-span-3">
<CardHeader>
<CardTitle> </CardTitle>
<div className="text-sm text-muted-foreground">
265 .
</div>
</CardHeader>
<CardContent>
<div className="space-y-8">
<div className="flex items-center">
<div className="ml-4 space-y-1">
<p className="text-sm font-medium leading-none">
</p>
<p className="text-sm text-muted-foreground">BTC/USDT</p>
</div>
<div className="ml-auto font-medium">+$1,999.00</div>
</div>
<div className="flex items-center">
<div className="ml-4 space-y-1">
<p className="text-sm font-medium leading-none">
</p>
<p className="text-sm text-muted-foreground">ETH/USDT</p>
</div>
<div className="ml-auto font-medium">+$39.00</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
} }

View File

@@ -1,5 +1,5 @@
import { Header } from "@/features/layout/components/header"; import { Header } from "@/features/layout/components/header";
import { Sidebar } from "@/features/layout/components/sidebar"; import { MobileBottomNav, Sidebar } from "@/features/layout/components/sidebar";
import { createClient } from "@/utils/supabase/server"; import { createClient } from "@/utils/supabase/server";
export default async function MainLayout({ export default async function MainLayout({
@@ -13,12 +13,13 @@ export default async function MainLayout({
} = await supabase.auth.getUser(); } = await supabase.auth.getUser();
return ( return (
<div className="flex min-h-screen flex-col bg-zinc-50 dark:bg-black"> <div className="flex min-h-screen flex-col bg-background">
<Header user={user} /> <Header user={user} />
<div className="flex flex-1"> <div className="flex flex-1 pt-16">
<Sidebar /> <Sidebar />
<main className="flex-1 w-full p-6 md:p-8 lg:p-10">{children}</main> <main className="min-w-0 flex-1 pb-20 md:pb-0">{children}</main>
</div> </div>
<MobileBottomNav />
</div> </div>
); );
} }

View File

@@ -0,0 +1,26 @@
/**
* @file app/(main)/settings/page.tsx
* @description 로그인 사용자 전용 설정 페이지(Server Component)
*/
import { redirect } from "next/navigation";
import { SettingsContainer } from "@/features/settings/components/SettingsContainer";
import { createClient } from "@/utils/supabase/server";
/**
* 설정 페이지
* @returns SettingsContainer UI
* @see features/settings/components/SettingsContainer.tsx KIS 인증 설정 UI를 제공합니다.
*/
export default async function SettingsPage() {
// 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect("/login");
return <SettingsContainer />;
}

26
app/(main)/trade/page.tsx Normal file
View File

@@ -0,0 +1,26 @@
/**
* @file app/(main)/trade/page.tsx
* @description 로그인 사용자 전용 트레이딩 페이지(Server Component)
*/
import { redirect } from "next/navigation";
import { TradeContainer } from "@/features/trade/components/TradeContainer";
import { createClient } from "@/utils/supabase/server";
/**
* 트레이딩 페이지
* @returns TradeContainer UI
* @see features/trade/components/TradeContainer.tsx 종목 검색/차트/호가/주문 기능을 제공합니다.
*/
export default async function TradePage() {
// 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect("/login");
return <TradeContainer />;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

56
app/api/kis/_response.ts Normal file
View File

@@ -0,0 +1,56 @@
import type { KisTradingEnv } from "@/features/trade/types/trade.types";
import { NextResponse } from "next/server";
export const KIS_API_ERROR_CODE = {
AUTH_REQUIRED: "KIS_AUTH_REQUIRED",
INVALID_REQUEST: "KIS_INVALID_REQUEST",
CREDENTIAL_REQUIRED: "KIS_CREDENTIAL_REQUIRED",
ACCOUNT_REQUIRED: "KIS_ACCOUNT_REQUIRED",
UPSTREAM_FAILURE: "KIS_UPSTREAM_FAILURE",
UNAUTHORIZED: "KIS_UNAUTHORIZED",
} as const;
export type KisApiErrorCode =
(typeof KIS_API_ERROR_CODE)[keyof typeof KIS_API_ERROR_CODE];
interface CreateKisApiErrorResponseOptions {
status: number;
code: KisApiErrorCode;
message: string;
tradingEnv?: KisTradingEnv;
extra?: Record<string, unknown>;
}
/**
* @description KIS API 라우트용 표준 에러 응답을 생성합니다.
* @remarks 클라이언트 하위호환을 위해 message/error 키를 동시에 제공합니다.
* @see features/trade/apis/kis-stock.api.ts 종목 API 클라이언트는 error 우선 파싱
* @see features/settings/apis/kis-auth.api.ts 인증 API 클라이언트는 message 우선 파싱
*/
export function createKisApiErrorResponse({
status,
code,
message,
tradingEnv,
extra,
}: CreateKisApiErrorResponseOptions) {
return NextResponse.json(
{
ok: false,
message,
error: message,
errorCode: code,
...(tradingEnv ? { tradingEnv } : {}),
...(extra ?? {}),
},
{ status },
);
}
/**
* @description unknown 에러 객체를 사용자 노출용 메시지로 정규화합니다.
* @see app/api/kis/domestic/balance/route.ts 서버 예외를 공통 메시지로 변환
*/
export function toKisApiErrorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}

18
app/api/kis/_session.ts Normal file
View File

@@ -0,0 +1,18 @@
import { createClient } from "@/utils/supabase/server";
/**
* @description KIS API 라우트 접근 전에 Supabase 로그인 세션을 검증합니다.
* @returns 로그인 세션 존재 여부
* @remarks UI 흐름: 클라이언트 요청 -> KIS API route -> hasKisApiSession -> (실패 시 401, 성공 시 KIS 호출)
* @see app/api/kis/domestic/balance/route.ts 잔고 API 세션 가드
* @see app/api/kis/validate/route.ts 인증 검증 API 세션 가드
*/
export async function hasKisApiSession() {
const supabase = await createClient();
const {
data: { user },
error,
} = await supabase.auth.getUser();
return Boolean(!error && user);
}

View File

@@ -0,0 +1,39 @@
import { parseKisAccountParts } from "@/lib/kis/account";
import {
normalizeTradingEnv,
type KisCredentialInput,
} from "@/lib/kis/config";
/**
* @description 요청 헤더에서 KIS 키를 읽어옵니다.
* @param headers 요청 헤더
* @returns KIS 인증 입력값
* @see app/api/kis/domestic/balance/route.ts 대시보드 잔고 API 인증키 파싱
* @see app/api/kis/domestic/indices/route.ts 대시보드 지수 API 인증키 파싱
*/
export function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
const appKey = headers.get("x-kis-app-key")?.trim();
const appSecret = headers.get("x-kis-app-secret")?.trim();
const tradingEnv = normalizeTradingEnv(
headers.get("x-kis-trading-env") ?? undefined,
);
return {
appKey,
appSecret,
tradingEnv,
};
}
/**
* @description 요청 헤더에서 계좌번호(8-2)를 읽어옵니다.
* @param headers 요청 헤더
* @returns 계좌번호 파트(8 + 2) 또는 null
* @see app/api/kis/domestic/balance/route.ts 잔고 조회 시 필수 계좌정보 파싱
*/
export function readKisAccountParts(headers: Headers) {
const headerAccountNo = headers.get("x-kis-account-no");
const headerAccountProductCode = headers.get("x-kis-account-product-code");
return parseKisAccountParts(headerAccountNo, headerAccountProductCode);
}

View File

@@ -0,0 +1,85 @@
import { NextResponse } from "next/server";
import type { DashboardActivityResponse } from "@/features/dashboard/types/dashboard.types";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticDashboardActivity } from "@/lib/kis/dashboard";
import { hasKisApiSession } from "@/app/api/kis/_session";
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/activity/route.ts
* @description 국내주식 주문내역/매매일지 조회 API
*/
/**
* 대시보드 하단(주문내역/매매일지) 조회 API
* @returns 주문내역 목록 + 매매일지 목록/요약
* @remarks UI 흐름: DashboardContainer -> useDashboardData -> /api/kis/domestic/activity -> ActivitySection 렌더링
* @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/새로고침에서 호출합니다.
* @see features/dashboard/components/ActivitySection.tsx 주문내역/매매일지 섹션 렌더링
*/
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 키 설정이 필요합니다.",
});
}
const account = readKisAccountParts(request.headers);
if (!account) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
message:
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
});
}
try {
const result = await getDomesticDashboardActivity(account, credentials);
const response: DashboardActivityResponse = {
source: "kis",
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
orders: result.orders,
tradeJournal: result.tradeJournal,
journalSummary: result.journalSummary,
warnings: result.warnings,
fetchedAt: new Date().toISOString(),
};
return NextResponse.json(response, {
headers: {
"cache-control": "no-store",
},
});
} catch (error) {
return createKisApiErrorResponse({
status: 500,
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
message: toKisApiErrorMessage(
error,
"주문내역/매매일지 조회 중 오류가 발생했습니다.",
),
});
}
}

View File

@@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import type { DashboardBalanceResponse } from "@/features/dashboard/types/dashboard.types";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticDashboardBalance } from "@/lib/kis/dashboard";
import { hasKisApiSession } from "@/app/api/kis/_session";
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/balance/route.ts
* @description 국내주식 계좌 잔고/보유종목 조회 API
*/
/**
* 대시보드 잔고 조회 API
* @returns 총자산/손익/보유종목 목록
* @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/새로고침에서 호출합니다.
*/
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 키 설정이 필요합니다.",
});
}
const account = readKisAccountParts(request.headers);
if (!account) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
message:
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
});
}
try {
const result = await getDomesticDashboardBalance(account, credentials);
const response: DashboardBalanceResponse = {
source: "kis",
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
summary: result.summary,
holdings: result.holdings,
fetchedAt: new Date().toISOString(),
};
return NextResponse.json(response, {
headers: {
"cache-control": "no-store",
},
});
} catch (error) {
return createKisApiErrorResponse({
status: 500,
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
message: toKisApiErrorMessage(error, "잔고 조회 중 오류가 발생했습니다."),
});
}
}

View File

@@ -0,0 +1,103 @@
import type {
DashboardChartTimeframe,
DashboardStockChartResponse,
} from "@/features/trade/types/trade.types";
import { hasKisConfig } from "@/lib/kis/config";
import { getDomesticChart } from "@/lib/kis/domestic";
import { NextRequest, NextResponse } from "next/server";
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";
const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
"1m",
"5m",
"10m",
"15m",
"30m",
"1h",
"1d",
"1w",
];
/**
* @file app/api/kis/domestic/chart/route.ts
* @description 국내주식 차트(분봉/일봉/주봉) 조회 API
*/
export async function GET(request: NextRequest) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim();
const timeframe = (
searchParams.get("timeframe") ?? "1d"
).trim() as DashboardChartTimeframe;
const cursor = (searchParams.get("cursor") ?? "").trim() || undefined;
if (!/^\d{6}$/.test(symbol)) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: "symbol은 6자리 숫자여야 합니다.",
});
}
if (!VALID_TIMEFRAMES.includes(timeframe)) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: "지원하지 않는 timeframe입니다.",
});
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message:
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 차트를 조회할 수 없습니다.",
});
}
try {
const chart = await getDomesticChart(
symbol,
timeframe,
credentials,
cursor,
);
const response: DashboardStockChartResponse = {
symbol,
timeframe,
candles: chart.candles,
nextCursor: chart.nextCursor,
hasMore: chart.hasMore,
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, "KIS 차트 조회 중 오류가 발생했습니다."),
});
}
}

View File

@@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import type { DashboardIndicesResponse } from "@/features/dashboard/types/dashboard.types";
import { hasKisConfig, normalizeTradingEnv } 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";
/**
* @file app/api/kis/domestic/indices/route.ts
* @description 국내 주요 지수(KOSPI/KOSDAQ) 조회 API
*/
/**
* 대시보드 지수 조회 API
* @returns 코스피/코스닥 지수 목록
* @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/주기 갱신에서 호출합니다.
*/
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 items = await getDomesticDashboardIndices(credentials);
const response: DashboardIndicesResponse = {
source: "kis",
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
items,
fetchedAt: new Date().toISOString(),
};
return NextResponse.json(response, {
headers: {
"cache-control": "no-store",
},
});
} catch (error) {
return createKisApiErrorResponse({
status: 500,
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
message: toKisApiErrorMessage(error, "지수 조회 중 오류가 발생했습니다."),
});
}
}

View File

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

View File

@@ -0,0 +1,168 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { executeOrderCash } from "@/lib/kis/trade";
import {
DashboardStockCashOrderResponse,
} from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { parseKisAccountParts } from "@/lib/kis/account";
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/order-cash/route.ts
* @description 국내주식 현금 주문 API
*/
const orderCashBodySchema = z
.object({
symbol: z
.string()
.trim()
.regex(/^\d{6}$/, "종목코드는 6자리 숫자여야 합니다."),
side: z.enum(["buy", "sell"], {
message: "주문 구분(side)은 buy/sell만 허용됩니다.",
}),
orderType: z.enum(["limit", "market"], {
message: "주문 유형(orderType)은 limit/market만 허용됩니다.",
}),
quantity: z.coerce
.number()
.int("주문수량은 정수여야 합니다.")
.positive("주문수량은 1주 이상이어야 합니다."),
price: z.coerce.number(),
accountNo: z.string().trim().min(1, "계좌번호를 입력해 주세요."),
accountProductCode: z.string().trim().optional(),
})
.superRefine((body, ctx) => {
if (body.orderType === "limit" && body.price <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["price"],
message: "지정가 주문은 주문가격이 0보다 커야 합니다.",
});
}
if (body.orderType === "market" && body.price < 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["price"],
message: "시장가 주문은 주문가격이 0 이상이어야 합니다.",
});
}
const accountParts = parseKisAccountParts(
body.accountNo,
body.accountProductCode,
);
if (!accountParts) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["accountNo"],
message:
"계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
});
}
});
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,
});
}
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 = orderCashBodySchema.safeParse(rawBody);
if (!parsed.success) {
const firstIssue = parsed.error.issues[0];
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: firstIssue?.message ?? "주문 요청 값이 올바르지 않습니다.",
tradingEnv,
});
}
const body = parsed.data;
const accountParts = parseKisAccountParts(
body.accountNo,
body.accountProductCode,
);
if (!accountParts) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
message:
"계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
tradingEnv,
});
}
const output = await executeOrderCash(
{
symbol: body.symbol,
side: body.side,
orderType: body.orderType,
quantity: body.quantity,
price: body.price,
accountNo: accountParts.accountNo,
accountProductCode: accountParts.accountProductCode,
},
credentials,
);
const response: DashboardStockCashOrderResponse = {
ok: true,
tradingEnv,
message: "주문이 전송되었습니다.",
orderNo: output.ODNO,
orderTime: output.ORD_TMD,
orderOrgNo: output.KRX_FWDG_ORD_ORGNO,
};
return NextResponse.json(response);
} catch (error) {
return createKisApiErrorResponse({
status: 500,
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
message: toKisApiErrorMessage(error, "주문 전송 중 오류가 발생했습니다."),
tradingEnv,
});
}
}

View File

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

View File

@@ -0,0 +1,163 @@
import { NextRequest, NextResponse } from "next/server";
import {
getDomesticOrderBook,
KisDomesticOrderBookOutput,
} from "@/lib/kis/domestic";
import { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import {
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
parseDomesticKisSession,
} from "@/lib/kis/domestic-market-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/orderbook/route.ts
* @description 국내주식 호가 조회 API
*/
export async function GET(request: NextRequest) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim();
if (!/^\d{6}$/.test(symbol)) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: "symbol은 6자리 숫자여야 합니다.",
});
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message: "KIS API 키 설정이 필요합니다.",
});
}
try {
const sessionOverride = readSessionOverrideFromHeaders(request.headers);
const raw = await getDomesticOrderBook(symbol, credentials, {
sessionOverride,
});
const levels = Array.from({ length: 10 }, (_, i) => {
const idx = i + 1;
return {
askPrice: readOrderBookNumber(raw, `askp${idx}`, `ovtm_untp_askp${idx}`),
bidPrice: readOrderBookNumber(raw, `bidp${idx}`, `ovtm_untp_bidp${idx}`),
askSize: readOrderBookNumber(
raw,
`askp_rsqn${idx}`,
`ovtm_untp_askp_rsqn${idx}`,
),
bidSize: readOrderBookNumber(raw, ...resolveBidSizeKeys(idx)),
};
});
const response: DashboardStockOrderBookResponse = {
symbol,
source: "kis",
levels,
totalAskSize: readOrderBookNumber(
raw,
"total_askp_rsqn",
"ovtm_untp_total_askp_rsqn",
"ovtm_total_askp_rsqn",
),
totalBidSize: readOrderBookNumber(
raw,
"total_bidp_rsqn",
"ovtm_untp_total_bidp_rsqn",
"ovtm_total_bidp_rsqn",
),
businessHour: readOrderBookString(raw, "bsop_hour", "ovtm_untp_last_hour"),
hourClassCode: readOrderBookString(raw, "hour_cls_code"),
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
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, "호가 조회 중 오류가 발생했습니다."),
});
}
}
function readSessionOverrideFromHeaders(headers: Headers) {
if (process.env.NODE_ENV === "production") return null;
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);
return parseDomesticKisSession(raw);
}
/**
* @description 호가 응답 필드를 대소문자 모두 허용해 숫자로 읽습니다.
* @see app/api/kis/domestic/orderbook/route.ts GET에서 output/output1 키 차이 방어 로직으로 사용합니다.
*/
function readOrderBookNumber(raw: KisDomesticOrderBookOutput, ...keys: string[]) {
const record = raw as Record<string, unknown>;
const value = resolveOrderBookValue(record, keys) ?? "0";
const normalized =
typeof value === "string"
? value.replaceAll(",", "").trim()
: String(value ?? "0");
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : 0;
}
/**
* @description 호가 응답 필드를 문자열로 읽습니다.
* @see app/api/kis/domestic/orderbook/route.ts GET 응답 생성 시 businessHour/hourClassCode 추출
*/
function readOrderBookString(raw: KisDomesticOrderBookOutput, ...keys: string[]) {
const record = raw as Record<string, unknown>;
const value = resolveOrderBookValue(record, keys);
if (value === undefined || value === null) return undefined;
const text = String(value).trim();
return text.length > 0 ? text : undefined;
}
function resolveOrderBookValue(record: Record<string, unknown>, keys: string[]) {
for (const key of keys) {
const direct = record[key];
if (direct !== undefined && direct !== null) return direct;
const upper = record[key.toUpperCase()];
if (upper !== undefined && upper !== null) return upper;
}
return undefined;
}
function resolveBidSizeKeys(index: number) {
if (index === 2) {
return [`bidp_rsqn${index}`, `ovtm_untp_bidp_rsqn${index}`, "ovtm_untp_bidp_rsqn"];
}
return [`bidp_rsqn${index}`, `ovtm_untp_bidp_rsqn${index}`];
}

View File

@@ -0,0 +1,98 @@
import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks";
import type { DashboardStockOverviewResponse } from "@/features/trade/types/trade.types";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { getDomesticOverview } from "@/lib/kis/domestic";
import { NextRequest, NextResponse } from "next/server";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import {
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
parseDomesticKisSession,
} from "@/lib/kis/domestic-market-session";
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
/**
* @file app/api/kis/domestic/overview/route.ts
* @description 국내주식 종목 상세(현재가 + 차트) API
*/
/**
* 국내주식 종목 상세 API
* @param request query string의 symbol(6자리 종목코드) 사용
* @returns 대시보드 상세 모델
*/
export async function GET(request: NextRequest) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim();
if (!/^\d{6}$/.test(symbol)) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: "symbol은 6자리 숫자여야 합니다.",
});
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message:
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 시세를 조회할 수 없습니다.",
});
}
const fallbackMeta = KOREAN_STOCK_INDEX.find((item) => item.symbol === symbol);
try {
const sessionOverride = readSessionOverrideFromHeaders(request.headers);
const overview = await getDomesticOverview(
symbol,
fallbackMeta,
credentials,
{ sessionOverride },
);
const response: DashboardStockOverviewResponse = {
stock: overview.stock,
source: "kis",
priceSource: overview.priceSource,
marketPhase: overview.marketPhase,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
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, "KIS 조회 중 오류가 발생했습니다."),
});
}
}
function readSessionOverrideFromHeaders(headers: Headers) {
if (process.env.NODE_ENV === "production") return null;
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);
return parseDomesticKisSession(raw);
}

View File

@@ -0,0 +1,122 @@
import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks";
import type {
DashboardStockSearchItem,
DashboardStockSearchResponse,
KoreanStockIndexItem,
} from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
} from "@/app/api/kis/_response";
import { NextRequest, NextResponse } from "next/server";
const SEARCH_LIMIT = 10;
/**
* @file app/api/kis/domestic/search/route.ts
* @description 국내주식 종목명/종목코드 검색 API
* @remarks
* - [레이어] API Route
* - [사용자 행동] 대시보드 검색창 엔터/검색 버튼 클릭 시 호출
* - [데이터 흐름] dashboard-main.tsx -> /api/kis/domestic/search -> KOREAN_STOCK_INDEX 필터/정렬 -> JSON 응답
* - [연관 파일] features/trade/data/korean-stocks.ts, features/trade/components/dashboard-main.tsx
* @author jihoon87.lee
*/
/**
* 국내주식 검색 API
* @param request query string의 q(검색어) 사용
* @returns 종목 검색 결과 목록
* @see features/trade/components/dashboard-main.tsx 검색 폼에서 호출합니다.
*/
export async function GET(request: NextRequest) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
// [Step 1] query string에서 검색어(q)를 읽고 공백을 제거합니다.
const { searchParams } = new URL(request.url);
const query = (searchParams.get("q") ?? "").trim();
// [Step 2] 검색어가 없으면 빈 목록을 즉시 반환해 불필요한 계산을 줄입니다.
if (!query) {
const response: DashboardStockSearchResponse = {
query,
items: [],
total: 0,
};
return NextResponse.json(response);
}
const normalized = normalizeKeyword(query);
// [Step 3] 인덱스에서 코드/이름 포함 여부로 1차 필터링 후 점수를 붙입니다.
const ranked = KOREAN_STOCK_INDEX.filter((item) => {
const symbol = item.symbol;
const name = normalizeKeyword(item.name);
return symbol.includes(normalized) || name.includes(normalized);
})
.map((item) => ({
item,
score: getSearchScore(item, normalized),
}))
.filter((entry) => entry.score > 0)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
if (a.item.market !== b.item.market) return a.item.market.localeCompare(b.item.market);
return a.item.name.localeCompare(b.item.name, "ko");
});
// [Step 4] UI에서 필요한 최소 필드만 남겨 SEARCH_LIMIT 만큼 반환합니다.
const items: DashboardStockSearchItem[] = ranked.slice(0, SEARCH_LIMIT).map(({ item }) => ({
symbol: item.symbol,
name: item.name,
market: item.market,
}));
const response: DashboardStockSearchResponse = {
query,
items,
total: ranked.length,
};
// [Step 5] DashboardStockSearchResponse 형태로 응답합니다.
return NextResponse.json(response);
}
/**
* 검색어 정규화(공백 제거 + 소문자)
* @param value 원본 문자열
* @returns 정규화 문자열
* @see app/api/kis/domestic/search/route.ts 한글/영문 검색 비교 정확도를 높입니다.
*/
function normalizeKeyword(value: string) {
return value.replaceAll(/\s+/g, "").toLowerCase();
}
/**
* 검색 결과 점수 계산
* @param item 종목 인덱스 항목
* @param normalizedQuery 정규화된 검색어
* @returns 높은 값일수록 우선순위 상위
* @see app/api/kis/domestic/search/route.ts 검색 결과 정렬 기준으로 사용합니다.
*/
function getSearchScore(item: KoreanStockIndexItem, normalizedQuery: string) {
const normalizedName = normalizeKeyword(item.name);
const normalizedSymbol = item.symbol.toLowerCase();
if (normalizedSymbol === normalizedQuery) return 120;
if (normalizedName === normalizedQuery) return 110;
if (normalizedSymbol.startsWith(normalizedQuery)) return 100;
if (normalizedName.startsWith(normalizedQuery)) return 90;
if (normalizedName.includes(normalizedQuery)) return 70;
if (normalizedSymbol.includes(normalizedQuery)) return 60;
return 0;
}

View File

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

View File

@@ -0,0 +1,65 @@
import type { DashboardKisRevokeResponse } from "@/features/trade/types/trade.types";
import { normalizeTradingEnv } from "@/lib/kis/config";
import {
parseKisCredentialRequest,
validateKisCredentialInput,
} from "@/lib/kis/request";
import { revokeKisAccessToken } from "@/lib/kis/token";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { NextRequest, NextResponse } from "next/server";
/**
* @file app/api/kis/revoke/route.ts
* @description 사용자 입력 KIS API 키로 액세스 토큰을 폐기합니다.
*/
/**
* @description KIS 액세스 토큰 폐기
* @see features/settings/components/KisAuthForm.tsx
*/
export async function POST(request: NextRequest) {
const credentials = await parseKisCredentialRequest(request);
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
tradingEnv,
});
}
const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: invalidMessage,
tradingEnv,
});
}
try {
const message = await revokeKisAccessToken(credentials);
return NextResponse.json({
ok: true,
tradingEnv,
message,
} satisfies DashboardKisRevokeResponse);
} catch (error) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.UNAUTHORIZED,
message: toKisApiErrorMessage(error, "API 토큰 폐기 중 오류가 발생했습니다."),
tradingEnv,
});
}
}

View File

@@ -0,0 +1,247 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import type { DashboardKisProfileValidateResponse } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { parseKisAccountParts } from "@/lib/kis/account";
import { kisGet } from "@/lib/kis/client";
import { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config";
import { validateKisCredentialInput } from "@/lib/kis/request";
import { getKisAccessToken } from "@/lib/kis/token";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
const kisProfileValidateBodySchema = z.object({
appKey: z.string().trim().min(1, "앱 키를 입력해 주세요."),
appSecret: z.string().trim().min(1, "앱 시크릿을 입력해 주세요."),
tradingEnv: z.string().optional(),
accountNo: z.string().trim().min(1, "계좌번호를 입력해 주세요."),
});
interface BalanceValidationPreset {
inqrDvsn: "01" | "02";
prcsDvsn: "00" | "01";
}
const BALANCE_VALIDATION_PRESETS: BalanceValidationPreset[] = [
{
// 명세 기본 요청값
inqrDvsn: "01",
prcsDvsn: "01",
},
{
// 일부 계좌/환경 호환값
inqrDvsn: "02",
prcsDvsn: "00",
},
];
/**
* @file app/api/kis/validate-profile/route.ts
* @description 한국투자증권 계좌번호를 검증합니다.
*/
/**
* @description 앱키/앱시크릿키 + 계좌번호 유효성을 검증합니다.
* @remarks UI 흐름: /settings -> KisProfileForm 확인 버튼 -> /api/kis/validate-profile -> store 반영 -> 대시보드 상태 확장
* @see features/settings/components/KisProfileForm.tsx 계좌 확인 버튼에서 호출합니다.
* @see features/settings/apis/kis-auth.api.ts validateKisProfile 클라이언트 API 함수
*/
export async function POST(request: NextRequest) {
const fallbackTradingEnv = normalizeTradingEnv(
request.headers.get("x-kis-trading-env") ?? undefined,
);
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
tradingEnv: fallbackTradingEnv,
});
}
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: fallbackTradingEnv,
});
}
const parsedBody = kisProfileValidateBodySchema.safeParse(rawBody);
if (!parsedBody.success) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message:
parsedBody.error.issues[0]?.message ??
"요청 본문 값이 올바르지 않습니다.",
tradingEnv: fallbackTradingEnv,
});
}
const body = parsedBody.data;
const credentials: KisCredentialInput = {
appKey: body.appKey.trim(),
appSecret: body.appSecret.trim(),
tradingEnv: normalizeTradingEnv(body.tradingEnv),
};
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
const invalidCredentialMessage = validateKisCredentialInput(credentials);
if (invalidCredentialMessage) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: invalidCredentialMessage,
tradingEnv,
});
}
const accountNoInput = body.accountNo.trim();
const accountParts = parseKisAccountParts(accountNoInput);
if (!accountParts) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
message:
"계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
tradingEnv,
});
}
try {
// 1) 토큰 발급으로 앱키/시크릿 사전 검증
try {
await getKisAccessToken(credentials);
} catch (error) {
throw new Error(`앱키 검증 실패: ${toErrorMessage(error)}`);
}
// 2) 계좌 유효성 검증 (실제 계좌 조회 API)
try {
await validateAccountByBalanceApi(
accountParts.accountNo,
accountParts.accountProductCode,
credentials,
);
} catch (error) {
throw new Error(`계좌 검증 실패: ${toErrorMessage(error)}`);
}
const normalizedAccountNo = `${accountParts.accountNo}-${accountParts.accountProductCode}`;
return NextResponse.json({
ok: true,
tradingEnv,
message: "계좌번호 검증이 완료되었습니다.",
account: {
normalizedAccountNo,
},
} satisfies DashboardKisProfileValidateResponse);
} catch (error) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.UNAUTHORIZED,
message: toKisApiErrorMessage(error, "계좌 검증 중 오류가 발생했습니다."),
tradingEnv,
});
}
}
/**
* @description 계좌번호로 잔고 조회 API를 호출해 유효성을 확인합니다.
* @param accountNo 계좌번호 앞 8자리
* @param accountProductCode 계좌번호 뒤 2자리
* @param credentials KIS 인증 정보
* @see app/api/kis/validate-profile/route.ts POST
*/
async function validateAccountByBalanceApi(
accountNo: string,
accountProductCode: string,
credentials: KisCredentialInput,
) {
const trId = normalizeTradingEnv(credentials.tradingEnv) === "real" ? "TTTC8434R" : "VTTC8434R";
const attemptErrors: string[] = [];
for (const preset of BALANCE_VALIDATION_PRESETS) {
try {
const response = await kisGet<unknown>(
"/uapi/domestic-stock/v1/trading/inquire-balance",
trId,
{
CANO: accountNo,
ACNT_PRDT_CD: accountProductCode,
AFHR_FLPR_YN: "N",
OFL_YN: "",
INQR_DVSN: preset.inqrDvsn,
UNPR_DVSN: "01",
FUND_STTL_ICLD_YN: "N",
FNCG_AMT_AUTO_RDPT_YN: "N",
PRCS_DVSN: preset.prcsDvsn,
CTX_AREA_FK100: "",
CTX_AREA_NK100: "",
},
credentials,
);
validateInquireBalanceResponse(response);
return;
} catch (error) {
attemptErrors.push(
`INQR_DVSN=${preset.inqrDvsn}, PRCS_DVSN=${preset.prcsDvsn}: ${toErrorMessage(error)}`,
);
}
}
throw new Error(
`계좌 확인 요청이 모두 실패했습니다. ${attemptErrors.join(" | ")}`,
);
}
/**
* @description 주식잔고조회 응답 구조를 최소 검증합니다.
* @param response KIS 원본 응답
* @see app/api/kis/validate-profile/route.ts validateAccountByBalanceApi
*/
function validateInquireBalanceResponse(
response: {
output1?: unknown;
output2?: unknown;
},
) {
const output1Ok =
Array.isArray(response.output1) ||
(response.output1 !== null && typeof response.output1 === "object");
const output2Ok =
Array.isArray(response.output2) ||
(response.output2 !== null && typeof response.output2 === "object");
if (!output1Ok && !output2Ok) {
throw new Error("응답에 output1/output2가 없습니다. 요청 파라미터를 확인해 주세요.");
}
}
/**
* @description Error 객체를 사용자 표시용 문자열로 변환합니다.
* @param error unknown 에러
* @returns 메시지 문자열
* @see app/api/kis/validate-profile/route.ts POST
*/
function toErrorMessage(error: unknown) {
return error instanceof Error
? error.message
: "알 수 없는 오류가 발생했습니다.";
}

View File

@@ -0,0 +1,65 @@
import type { DashboardKisValidateResponse } from "@/features/trade/types/trade.types";
import { normalizeTradingEnv } from "@/lib/kis/config";
import {
parseKisCredentialRequest,
validateKisCredentialInput,
} from "@/lib/kis/request";
import { getKisAccessToken } from "@/lib/kis/token";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { NextRequest, NextResponse } from "next/server";
/**
* @file app/api/kis/validate/route.ts
* @description 사용자 입력 KIS API 키를 검증합니다.
*/
/**
* @description 액세스 토큰 발급 성공 여부로 API 키를 검증합니다.
* @see features/settings/components/KisAuthForm.tsx
*/
export async function POST(request: NextRequest) {
const credentials = await parseKisCredentialRequest(request);
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
tradingEnv,
});
}
const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: invalidMessage,
tradingEnv,
});
}
try {
await getKisAccessToken(credentials);
return NextResponse.json({
ok: true,
tradingEnv,
message: "API 키 검증이 완료되었습니다. (토큰 발급 성공)",
} satisfies DashboardKisValidateResponse);
} catch (error) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.UNAUTHORIZED,
message: toKisApiErrorMessage(error, "API 키 검증 중 오류가 발생했습니다."),
tradingEnv,
});
}
}

View File

@@ -0,0 +1,71 @@
import type { DashboardKisWsApprovalResponse } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { getKisApprovalKey, resolveKisWebSocketUrl } from "@/lib/kis/approval";
import { normalizeTradingEnv } from "@/lib/kis/config";
import {
parseKisCredentialRequest,
validateKisCredentialInput,
} from "@/lib/kis/request";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { NextRequest, NextResponse } from "next/server";
/**
* @file app/api/kis/ws/approval/route.ts
* @description KIS 웹소켓 승인키와 WS URL을 발급합니다.
*/
/**
* @description 실시간 웹소켓 연결 정보를 발급합니다.
* @see features/trade/hooks/useKisTradeWebSocket.ts
*/
export async function POST(request: NextRequest) {
const credentials = await parseKisCredentialRequest(request);
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
const hasSession = await hasKisApiSession();
if (!hasSession) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
tradingEnv,
});
}
const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: invalidMessage,
tradingEnv,
});
}
try {
const approvalKey = await getKisApprovalKey(credentials);
const wsUrl = resolveKisWebSocketUrl(credentials);
return NextResponse.json({
ok: true,
tradingEnv,
approvalKey,
wsUrl,
message: "웹소켓 승인키 발급이 완료되었습니다.",
} satisfies DashboardKisWsApprovalResponse);
} catch (error) {
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.UNAUTHORIZED,
message: toKisApiErrorMessage(
error,
"웹소켓 승인키 발급 중 오류가 발생했습니다.",
),
tradingEnv,
});
}
}

View File

@@ -1,4 +1,5 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "tailwindcss-animate";
@import "tw-animate-css"; @import "tw-animate-css";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@@ -6,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);
@@ -38,16 +39,16 @@
--color-popover: var(--popover); --color-popover: var(--popover);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-card: var(--card); --color-card: var(--card);
--color-brand-50: oklch(0.97 0.02 294); --color-brand-50: var(--brand-50);
--color-brand-100: oklch(0.93 0.05 294); --color-brand-100: var(--brand-100);
--color-brand-200: oklch(0.87 0.1 294); --color-brand-200: var(--brand-200);
--color-brand-300: oklch(0.79 0.15 294); --color-brand-300: var(--brand-300);
--color-brand-400: oklch(0.7 0.2 294); --color-brand-400: var(--brand-400);
--color-brand-500: oklch(0.62 0.24 294); --color-brand-500: var(--brand-500);
--color-brand-600: oklch(0.56 0.26 294); --color-brand-600: var(--brand-600);
--color-brand-700: oklch(0.49 0.24 295); --color-brand-700: var(--brand-700);
--color-brand-800: oklch(0.4 0.2 296); --color-brand-800: var(--brand-800);
--color-brand-900: oklch(0.33 0.14 297); --color-brand-900: var(--brand-900);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
@@ -59,7 +60,8 @@
--animate-gradient-x: gradient-x 15s ease infinite; --animate-gradient-x: gradient-x 15s ease infinite;
@keyframes gradient-x { @keyframes gradient-x {
0%, 100% { 0%,
100% {
background-size: 200% 200%; background-size: 200% 200%;
background-position: left center; background-position: left center;
} }
@@ -71,6 +73,41 @@
} }
:root { :root {
/* BRAND PALETTE CONTROL
* 이 블록만 수정하면 랜딩/대시보드의 보라 톤이 함께 바뀝니다.
*/
/* 초기 브랜드 보라값(원본 기준) */
--brand-50: oklch(0.97 0.02 294);
--brand-100: oklch(0.93 0.05 294);
--brand-200: oklch(0.87 0.1 294);
--brand-300: oklch(0.79 0.15 294);
--brand-400: oklch(0.7 0.2 294);
--brand-500: oklch(0.62 0.24 294);
--brand-600: oklch(0.56 0.26 294);
--brand-700: oklch(0.49 0.24 295);
--brand-800: oklch(0.4 0.2 296);
--brand-900: oklch(0.33 0.14 297);
/* 차트(canvas): 봉 하락색은 요청대로 파란색 유지 */
--brand-chart-background-light: #ffffff;
--brand-chart-background-dark: #17131e;
--brand-chart-text-light: #6b21a8;
--brand-chart-text-dark: #e9d5ff;
--brand-chart-border-light: #e9d5ff;
--brand-chart-border-dark: rgba(216, 180, 254, 0.3);
--brand-chart-grid-light: #f3e8ff;
--brand-chart-grid-dark: rgba(216, 180, 254, 0.14);
--brand-chart-crosshair-light: #c084fc;
--brand-chart-crosshair-dark: rgba(233, 213, 255, 0.75);
--brand-chart-background: #ffffff;
--brand-chart-down: #2563eb;
--brand-chart-volume-down: rgba(37, 99, 235, 0.45);
--brand-chart-text: #6b21a8;
--brand-chart-border: var(--brand-chart-border-light);
--brand-chart-grid: var(--brand-chart-grid-light);
--brand-chart-crosshair: var(--brand-chart-crosshair-light);
--radius: 0.625rem; --radius: 0.625rem;
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.145 0 0);
@@ -78,7 +115,7 @@
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.56 0.26 294); --primary: var(--brand-600);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.205 0 0);
@@ -89,7 +126,7 @@
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); --border: oklch(0.922 0 0);
--input: oklch(0.922 0 0); --input: oklch(0.922 0 0);
--ring: oklch(0.62 0.24 294); --ring: var(--brand-500);
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.398 0.07 227.392);
@@ -97,7 +134,7 @@
--chart-5: oklch(0.769 0.188 70.08); --chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.56 0.26 294); --sidebar-primary: var(--brand-600);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
@@ -106,37 +143,45 @@
} }
.dark { .dark {
--background: oklch(0.145 0 0); /* 다크 모드 시인성 개선: 배경 대비는 유지하고, 카드/보더/보조 텍스트를 더 읽기 쉽게 조정 */
--background: oklch(0.17 0 0);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0); --card: oklch(0.235 0 0);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0); --popover: oklch(0.235 0 0);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.56 0.26 294); --primary: var(--brand-600);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.269 0 0); --secondary: oklch(0.285 0 0);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0); --muted: oklch(0.285 0 0);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.83 0 0);
--accent: oklch(0.269 0 0); --accent: oklch(0.285 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 18%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 22%);
--ring: oklch(0.62 0.24 294); --ring: var(--brand-500);
--chart-1: oklch(0.488 0.243 264.376); --chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48); --chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08); --chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9); --chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439); --chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.235 0 0);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.56 0.26 294); --sidebar-primary: var(--brand-600);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.285 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 18%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.78 0 0);
/* 다크 테마용 차트 배경/격자 대비 */
--brand-chart-background: var(--brand-chart-background-dark);
--brand-chart-text: var(--brand-chart-text-dark);
--brand-chart-border: var(--brand-chart-border-dark);
--brand-chart-grid: var(--brand-chart-grid-dark);
--brand-chart-crosshair: var(--brand-chart-crosshair-dark);
} }
@layer base { @layer base {
@@ -146,4 +191,10 @@
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
h1,
h2,
h3,
h4 {
font-family: var(--font-jua), var(--font-gowun-sans), sans-serif;
}
} }

View File

@@ -9,15 +9,26 @@
*/ */
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";
import { GlobalAlertModal } from "@/features/layout/components/GlobalAlertModal";
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({
@@ -25,15 +36,10 @@ 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: "AutoTrade", title: "JOORIN-E (주린이) - 감이 아닌 전략으로 시작하는 자동매매",
description: "Automated Crypto Trading Platform", description:
"주린이를 위한 자동매매 파트너 JOORIN-E. 손실 방어 규칙, 데이터 신호 분석, 실시간 자동 실행으로 초보의 첫 수익 루틴을 만듭니다.",
}; };
/** /**
@@ -49,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"
@@ -60,7 +71,15 @@ export default function RootLayout({
disableTransitionOnChange disableTransitionOnChange
> >
<SessionManager /> <SessionManager />
<GlobalAlertModal />
<QueryProvider>{children}</QueryProvider> <QueryProvider>{children}</QueryProvider>
<Toaster
richColors
position="top-right"
toastOptions={{
duration: 4000,
}}
/>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,30 @@
# KIS 오류코드 적용 기준 (2026-02-26)
## 1) 기준 소스
- 공식 오류코드 페이지: `https://apiportal.koreainvestment.com/faq-error-code`
- 확인 방식: 실제 브라우저 렌더링 후 테이블 추출
- 코드 반영 위치: `lib/kis/error-codes.ts`
## 2) 코드 반영 목적
- `msg_cd`만 보일 때 의미를 바로 알기 어렵기 때문에,
코드와 문구를 같이 표시해 장애 원인 파악 속도를 높입니다.
- 토큰 발급/폐기, REST 호출, 웹소켓 제어 오류 메시지의 형식을 통일합니다.
## 3) 적용된 모듈
- `lib/kis/error-codes.ts`
- 공식 FAQ 코드 문구 매핑
- `getKisErrorGuide(msgCode)` 제공
- `buildKisErrorDetail(...)` 제공
- `lib/kis/client.ts`
- REST 실패 메시지에 `msg_cd + 공식 문구` 반영
- `lib/kis/token.ts`
- 토큰 발급/폐기 실패 메시지에 `msg_cd + 공식 문구` 반영
- `lib/kis/approval.ts`
- 승인키 발급 실패 메시지에 `msg_cd + 공식 문구` 반영
- `features/kis-realtime/stores/kisWebSocketStore.ts`
- 실시간 제어 오류(`OPSP*`) 메시지에 공식 문구 반영
## 4) 운영 시 참고
- 화면/로그에 `EGW00103`, `OPSP8996`처럼 코드가 보이면
`lib/kis/error-codes.ts`에서 즉시 문구를 확인할 수 있습니다.
- 신규 코드가 추가되면 공식 FAQ 기준으로 맵에 추가합니다.

View File

@@ -0,0 +1,466 @@
# 한국투자증권 Open API 레퍼런스 가이드
> 이 문서는 Codex, Gemini, Claude 등 AI 어시스턴트가 한국투자증권(KIS) Open API를 기반으로 트레이딩 시스템을 개발하거나 UI/UX를 구성할 때 참고하기 위해 요약된 자료입니다.
## 📍 공식 사이트 및 주요 도구
- **공식 Open API 포털 (Main):** [https://apiportal.koreainvestment.com/apiservice-apiservice](https://apiportal.koreainvestment.com/apiservice-apiservice)
- **공식 Github (코드 샘플 및 종목 정보):** [https://github.com/koreainvestment/open-trading-api](https://github.com/koreainvestment/open-trading-api)
- **공식 챗봇 가이드 (GPTs):** [한국투자증권 Open API 서비스 챗봇](https://chatgpt.com/g/g-68b920ee7afc8191858d3dc05d429571-hangugtujajeunggweon-open-api-seobiseu-gpts)
- **API 테스트베드:** [https://apiportal.koreainvestment.com/testbed-intro](https://apiportal.koreainvestment.com/testbed-intro)
---
## 📚 API 카테고리별 주요 문서 링크
한국투자증권 API 포털은 SPA(Single Page Application) 구조로, 각 API 상세 문서는 `https://apiportal.koreainvestment.com/apiservice-apiservice?{API_PATH}` 형태의 파라미터를 사용하여 접근할 수 있습니다.
### 1. 공통 및 인증 (Essential)
- **개요 (Summary):** [바로가기](https://apiportal.koreainvestment.com/apiservice-summary)
- **종목정보파일 안내:** [바로가기](https://apiportal.koreainvestment.com/apiservice-category)
- **OAuth인증 (접근토큰 발급/폐기):** [문서 링크](https://apiportal.koreainvestment.com/apiservice-apiservice?/oauth2/tokenP)
- **실시간 (웹소켓) 접속키 발급:** [문서 링크](https://apiportal.koreainvestment.com/apiservice-apiservice?/oauth2/Approval)
### 2. 국내주식 (Domestic Stocks)
- **주문/계좌 (현금/신용주문, 잔고조회):** [주식주문(현금) 예시](https://apiportal.koreainvestment.com/apiservice-apiservice?/uapi/domestic-stock/v1/trading/order-cash)
- **기본시세 (현재가, 호가, 체결, 일자별):** [주식현재가 시세 예시](https://apiportal.koreainvestment.com/apiservice-apiservice?/uapi/domestic-stock/v1/quotations/inquire-price)
- **종목정보 (재무비율, 손익계산서, 대차대조표):** [상품기본조회 예시](https://apiportal.koreainvestment.com/apiservice-apiservice?/uapi/domestic-stock/v1/quotations/search-stock-info)
- **시세/순위 분석 (거래량순위, 등락률, 관심종목):** [거래량순위 예시](https://apiportal.koreainvestment.com/apiservice-apiservice?/uapi/domestic-stock/v1/quotations/volume-rank)
- **실시간 시세 (Websocket):** [실시간 체결가 (H0STCNT0)](https://apiportal.koreainvestment.com/apiservice-apiservice?/tryitout/H0STCNT0)
### 3. 해외주식 (Overseas Stocks)
- **주문/계좌 (미국, 일본, 중국, 홍콩, 베트남):** [해외주식 주문 예시](https://apiportal.koreainvestment.com/apiservice-apiservice?/uapi/overseas-stock/v1/trading/order)
- **해외주식 시세 (현재가, 호가, 분봉):** [해외주식 현재가 예시](https://apiportal.koreainvestment.com/apiservice-apiservice?/uapi/overseas-price/v1/quotations/price)
- **해외주식 실시간 시세 (Websocket):** [해외주식 실시간체결가 (HDFSCNT0)](https://apiportal.koreainvestment.com/apiservice-apiservice?/tryitout/HDFSCNT0)
### 4. 기타 금융 상품
- **국내선물옵션:** 주문, 기본시세, 실시간시세 지원
- **해외선물옵션:** 해외선물 종목 상세 및 실시간 시세 지원
- **장내채권:** 채권 매수/매도 주문 및 발행정보 시세 지원
- **ELW 시세:** 기초자산별 종목 및 LP 매매추이 지원
---
## 💡 AI 어시스턴트를 위한 참고 팁
1. **엔드포인트 조합 규칙:** API 문서상에 표기된 `URL` (`/uapi/...`)을 포털 주소 뒤에 파라미터(`?`)로 붙이면 브라우저에서 해당 문서로 직접 이동이 가능합니다.
2. **데이터 타입 주의:** `ORD_QTY`(주문수량), `ORD_UNPR`(주문단가) 등 숫자형 데이터도 **String 가공**이 필요한 경우가 많으므로 문서를 반드시 확인해야 합니다.
3. **마스터 데이터:** 종목 코드 및 기본 종목 정보는 API 호출보다는 공지된 전체 종목 마스터 파일(zip)을 다운로드 및 파싱하여 사용하는 것을 KIS에서 권장합니다. 관련 파이썬/Node.js 파싱 코드는 공식 Github 링크를 참고하세요.
## 📋 KIS API Portal 전체 메뉴 구조 (Reference)
다음은 한국투자증권 Open API 포털의 전체 좌측 메뉴 구조와 각 API 엔드포인트 URL 리스트입니다. AI가 API 연동 코드를 작성할 때 엔드포인트 참조용으로 사용하세요.
### 개요
- 하위 메뉴 없음
### 종목정보파일
- 하위 메뉴 없음
### OAuth인증
- **접근토큰발급(P)**: `/oauth2/tokenP`
- **접근토큰폐기(P)**: `/oauth2/revokeP`
- **Hashkey**: `/uapi/hashkey`
- **실시간 (웹소켓) 접속키 발급**: `/oauth2/Approval`
### [국내주식] 주문/계좌
- **주식주문(현금)**: `/uapi/domestic-stock/v1/trading/order-cash`
- **주식주문(신용)**: `/uapi/domestic-stock/v1/trading/order-credit`
- **주식주문(정정취소)**: `/uapi/domestic-stock/v1/trading/order-rvsecncl`
- **주식정정취소가능주문조회**: `/uapi/domestic-stock/v1/trading/inquire-psbl-rvsecncl`
- **주식일별주문체결조회**: `/uapi/domestic-stock/v1/trading/inquire-daily-ccld`
- **주식잔고조회**: `/uapi/domestic-stock/v1/trading/inquire-balance`
- **매수가능조회**: `/uapi/domestic-stock/v1/trading/inquire-psbl-order`
- **매도가능수량조회**: `/uapi/domestic-stock/v1/trading/inquire-psbl-sell`
- **신용매수가능조회**: `/uapi/domestic-stock/v1/trading/inquire-credit-psamount`
- **주식예약주문**: `/uapi/domestic-stock/v1/trading/order-resv`
- **주식예약주문정정취소**: `/uapi/domestic-stock/v1/trading/order-resv-rvsecncl`
- **주식예약주문조회**: `/uapi/domestic-stock/v1/trading/order-resv-ccnl`
- **퇴직연금 체결기준잔고**: `/uapi/domestic-stock/v1/trading/pension/inquire-present-balance`
- **퇴직연금 미체결내역**: `/uapi/domestic-stock/v1/trading/pension/inquire-daily-ccld`
- **퇴직연금 매수가능조회**: `/uapi/domestic-stock/v1/trading/pension/inquire-psbl-order`
- **퇴직연금 예수금조회**: `/uapi/domestic-stock/v1/trading/pension/inquire-deposit`
- **퇴직연금 잔고조회**: `/uapi/domestic-stock/v1/trading/pension/inquire-balance`
- **주식잔고조회\_실현손익**: `/uapi/domestic-stock/v1/trading/inquire-balance-rlz-pl`
- **투자계좌자산현황조회**: `/uapi/domestic-stock/v1/trading/inquire-account-balance`
- **기간별손익일별합산조회**: `/uapi/domestic-stock/v1/trading/inquire-period-profit`
- **기간별매매손익현황조회**: `/uapi/domestic-stock/v1/trading/inquire-period-trade-profit`
- **주식통합증거금 현황**: `/uapi/domestic-stock/v1/trading/intgr-margin`
- **기간별계좌권리현황조회**: `/uapi/domestic-stock/v1/trading/period-rights`
### [국내주식] 기본시세
- **주식현재가 시세**: `/uapi/domestic-stock/v1/quotations/inquire-price`
- **주식현재가 시세2**: `/uapi/domestic-stock/v1/quotations/inquire-price-2`
- **주식현재가 체결**: `/uapi/domestic-stock/v1/quotations/inquire-ccnl`
- **주식현재가 일자별**: `/uapi/domestic-stock/v1/quotations/inquire-daily-price`
- **주식현재가 호가/예상체결**: `/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn`
- **주식현재가 투자자**: `/uapi/domestic-stock/v1/quotations/inquire-investor`
- **주식현재가 회원사**: `/uapi/domestic-stock/v1/quotations/inquire-member`
- **국내주식기간별시세(일/주/월/년)**: `/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice`
- **주식당일분봉조회**: `/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice`
- **주식일별분봉조회**: `/uapi/domestic-stock/v1/quotations/inquire-time-dailychartprice`
- **주식현재가 당일시간대별체결**: `/uapi/domestic-stock/v1/quotations/inquire-time-itemconclusion`
- **주식현재가 시간외일자별주가**: `/uapi/domestic-stock/v1/quotations/inquire-daily-overtimeprice`
- **주식현재가 시간외시간별체결**: `/uapi/domestic-stock/v1/quotations/inquire-time-overtimeconclusion`
- **국내주식 시간외현재가**: `/uapi/domestic-stock/v1/quotations/inquire-overtime-price`
- **국내주식 시간외호가**: `/uapi/domestic-stock/v1/quotations/inquire-overtime-asking-price`
- **국내주식 장마감 예상체결가**: `/uapi/domestic-stock/v1/quotations/exp-closing-price`
- **ETF/ETN 현재가**: `/uapi/etfetn/v1/quotations/inquire-price`
- **ETF 구성종목시세**: `/uapi/etfetn/v1/quotations/inquire-component-stock-price`
- **NAV 비교추이(종목)**: `/uapi/etfetn/v1/quotations/nav-comparison-trend`
- **NAV 비교추이(일)**: `/uapi/etfetn/v1/quotations/nav-comparison-daily-trend`
- **NAV 비교추이(분)**: `/uapi/etfetn/v1/quotations/nav-comparison-time-trend`
### [국내주식] ELW 시세
- **ELW 현재가 시세**: `/uapi/domestic-stock/v1/quotations/inquire-elw-price`
- **ELW 신규상장종목**: `/uapi/elw/v1/quotations/newly-listed`
- **ELW 민감도 순위**: `/uapi/elw/v1/ranking/sensitivity`
- **ELW 기초자산별 종목시세**: `/uapi/elw/v1/quotations/udrl-asset-price`
- **ELW 종목검색**: `/uapi/elw/v1/quotations/cond-search`
- **ELW 당일급변종목**: `/uapi/elw/v1/ranking/quick-change`
- **ELW 기초자산 목록조회**: `/uapi/elw/v1/quotations/udrl-asset-list`
- **ELW 비교대상종목조회**: `/uapi/elw/v1/quotations/compare-stocks`
- **ELW LP매매추이**: `/uapi/elw/v1/quotations/lp-trade-trend`
- **ELW 투자지표추이(체결)**: `/uapi/elw/v1/quotations/indicator-trend-ccnl`
- **ELW 투자지표추이(분별)**: `/uapi/elw/v1/quotations/indicator-trend-minute`
- **ELW 투자지표추이(일별)**: `/uapi/elw/v1/quotations/indicator-trend-daily`
- **ELW 변동성 추이(틱)**: `/uapi/elw/v1/quotations/volatility-trend-tick`
- **ELW 변동성추이(체결)**: `/uapi/elw/v1/quotations/volatility-trend-ccnl`
- **ELW 변동성 추이(일별)**: `/uapi/elw/v1/quotations/volatility-trend-daily`
- **ELW 민감도 추이(체결)**: `/uapi/elw/v1/quotations/sensitivity-trend-ccnl`
- **ELW 변동성 추이(분별)**: `/uapi/elw/v1/quotations/volatility-trend-minute`
- **ELW 민감도 추이(일별)**: `/uapi/elw/v1/quotations/sensitivity-trend-daily`
- **ELW 만기예정/만기종목**: `/uapi/elw/v1/quotations/expiration-stocks`
- **ELW 지표순위**: `/uapi/elw/v1/ranking/indicator`
- **ELW 상승률순위**: `/uapi/elw/v1/ranking/updown-rate`
- **ELW 거래량순위**: `/uapi/elw/v1/ranking/volume-rank`
### [국내주식] 업종/기타
- **국내업종 현재지수**: `/uapi/domestic-stock/v1/quotations/inquire-index-price`
- **국내업종 일자별지수**: `/uapi/domestic-stock/v1/quotations/inquire-index-daily-price`
- **국내업종 시간별지수(초)**: `/uapi/domestic-stock/v1/quotations/inquire-index-tickprice`
- **국내업종 시간별지수(분)**: `/uapi/domestic-stock/v1/quotations/inquire-index-timeprice`
- **업종 분봉조회**: `/uapi/domestic-stock/v1/quotations/inquire-time-indexchartprice`
- **국내주식업종기간별시세(일/주/월/년)**: `/uapi/domestic-stock/v1/quotations/inquire-daily-indexchartprice`
- **국내업종 구분별전체시세**: `/uapi/domestic-stock/v1/quotations/inquire-index-category-price`
- **국내주식 예상체결지수 추이**: `/uapi/domestic-stock/v1/quotations/exp-index-trend`
- **국내주식 예상체결 전체지수**: `/uapi/domestic-stock/v1/quotations/exp-total-index`
- **변동성완화장치(VI) 현황**: `/uapi/domestic-stock/v1/quotations/inquire-vi-status`
- **금리 종합(국내채권/금리)**: `/uapi/domestic-stock/v1/quotations/comp-interest`
- **종합 시황/공시(제목)**: `/uapi/domestic-stock/v1/quotations/news-title`
- **국내휴장일조회**: `/uapi/domestic-stock/v1/quotations/chk-holiday`
- **국내선물 영업일조회**: `/uapi/domestic-stock/v1/quotations/market-time`
### [국내주식] 종목정보
- **상품기본조회**: `/uapi/domestic-stock/v1/quotations/search-info`
- **주식기본조회**: `/uapi/domestic-stock/v1/quotations/search-stock-info`
- **국내주식 대차대조표**: `/uapi/domestic-stock/v1/finance/balance-sheet`
- **국내주식 손익계산서**: `/uapi/domestic-stock/v1/finance/income-statement`
- **국내주식 재무비율**: `/uapi/domestic-stock/v1/finance/financial-ratio`
- **국내주식 수익성비율**: `/uapi/domestic-stock/v1/finance/profit-ratio`
- **국내주식 기타주요비율**: `/uapi/domestic-stock/v1/finance/other-major-ratios`
- **국내주식 안정성비율**: `/uapi/domestic-stock/v1/finance/stability-ratio`
- **국내주식 성장성비율**: `/uapi/domestic-stock/v1/finance/growth-ratio`
- **국내주식 당사 신용가능종목**: `/uapi/domestic-stock/v1/quotations/credit-by-company`
- **예탁원정보(배당일정)**: `/uapi/domestic-stock/v1/ksdinfo/dividend`
- **예탁원정보(주식매수청구일정)**: `/uapi/domestic-stock/v1/ksdinfo/purreq`
- **예탁원정보(합병/분할일정)**: `/uapi/domestic-stock/v1/ksdinfo/merger-split`
- **예탁원정보(액면교체일정)**: `/uapi/domestic-stock/v1/ksdinfo/rev-split`
- **예탁원정보(자본감소일정)**: `/uapi/domestic-stock/v1/ksdinfo/cap-dcrs`
- **예탁원정보(상장정보일정)**: `/uapi/domestic-stock/v1/ksdinfo/list-info`
- **예탁원정보(공모주청약일정)**: `/uapi/domestic-stock/v1/ksdinfo/pub-offer`
- **예탁원정보(실권주일정)**: `/uapi/domestic-stock/v1/ksdinfo/forfeit`
- **예탁원정보(의무예치일정)**: `/uapi/domestic-stock/v1/ksdinfo/mand-deposit`
- **예탁원정보(유상증자일정)**: `/uapi/domestic-stock/v1/ksdinfo/paidin-capin`
- **예탁원정보(무상증자일정)**: `/uapi/domestic-stock/v1/ksdinfo/bonus-issue`
- **예탁원정보(주주총회일정)**: `/uapi/domestic-stock/v1/ksdinfo/sharehld-meet`
- **국내주식 종목추정실적**: `/uapi/domestic-stock/v1/quotations/estimate-perform`
- **당사 대주가능 종목**: `/uapi/domestic-stock/v1/quotations/lendable-by-company`
- **국내주식 종목투자의견**: `/uapi/domestic-stock/v1/quotations/invest-opinion`
- **국내주식 증권사별 투자의견**: `/uapi/domestic-stock/v1/quotations/invest-opbysec`
### [국내주식] 시세분석
- **종목조건검색 목록조회**: `/uapi/domestic-stock/v1/quotations/psearch-title`
- **종목조건검색조회**: `/uapi/domestic-stock/v1/quotations/psearch-result`
- **관심종목 그룹조회**: `/uapi/domestic-stock/v1/quotations/intstock-grouplist`
- **관심종목(멀티종목) 시세조회**: `/uapi/domestic-stock/v1/quotations/intstock-multprice`
- **관심종목 그룹별 종목조회**: `/uapi/domestic-stock/v1/quotations/intstock-stocklist-by-group`
- **국내기관\_외국인 매매종목가집계**: `/uapi/domestic-stock/v1/quotations/foreign-institution-total`
- **외국계 매매종목 가집계**: `/uapi/domestic-stock/v1/quotations/frgnmem-trade-estimate`
- **종목별 투자자매매동향(일별)**: `/uapi/domestic-stock/v1/quotations/investor-trade-by-stock-daily`
- **시장별 투자자매매동향(시세)**: `/uapi/domestic-stock/v1/quotations/inquire-investor-time-by-market`
- **시장별 투자자매매동향(일별)**: `/uapi/domestic-stock/v1/quotations/inquire-investor-daily-by-market`
- **종목별 외국계 순매수추이**: `/uapi/domestic-stock/v1/quotations/frgnmem-pchs-trend`
- **회원사 실시간 매매동향(틱)**: `/uapi/domestic-stock/v1/quotations/frgnmem-trade-trend`
- **주식현재가 회원사 종목매매동향**: `/uapi/domestic-stock/v1/quotations/inquire-member-daily`
- **종목별 프로그램매매추이(체결)**: `/uapi/domestic-stock/v1/quotations/program-trade-by-stock`
- **종목별 프로그램매매추이(일별)**: `/uapi/domestic-stock/v1/quotations/program-trade-by-stock-daily`
- **종목별 외인기관 추정가집계**: `/uapi/domestic-stock/v1/quotations/investor-trend-estimate`
- **종목별일별매수매도체결량**: `/uapi/domestic-stock/v1/quotations/inquire-daily-trade-volume`
- **프로그램매매 종합현황(시간)**: `/uapi/domestic-stock/v1/quotations/comp-program-trade-today`
- **프로그램매매 종합현황(일별)**: `/uapi/domestic-stock/v1/quotations/comp-program-trade-daily`
- **프로그램매매 투자자매매동향(당일)**: `/uapi/domestic-stock/v1/quotations/investor-program-trade-today`
- **국내주식 신용잔고 일별추이**: `/uapi/domestic-stock/v1/quotations/daily-credit-balance`
- **국내주식 예상체결가 추이**: `/uapi/domestic-stock/v1/quotations/exp-price-trend`
- **국내주식 공매도 일별추이**: `/uapi/domestic-stock/v1/quotations/daily-short-sale`
- **국내주식 시간외예상체결등락률**: `/uapi/domestic-stock/v1/ranking/overtime-exp-trans-fluct`
- **국내주식 체결금액별 매매비중**: `/uapi/domestic-stock/v1/quotations/tradprt-byamt`
- **국내 증시자금 종합**: `/uapi/domestic-stock/v1/quotations/mktfunds`
- **종목별 일별 대차거래추이**: `/uapi/domestic-stock/v1/quotations/daily-loan-trans`
- **국내주식 상하한가 포착**: `/uapi/domestic-stock/v1/quotations/capture-uplowprice`
- **국내주식 매물대/거래비중**: `/uapi/domestic-stock/v1/quotations/pbar-tratio`
### [국내주식] 순위분석
- **거래량순위**: `/uapi/domestic-stock/v1/quotations/volume-rank`
- **국내주식 등락률 순위**: `/uapi/domestic-stock/v1/ranking/fluctuation`
- **국내주식 호가잔량 순위**: `/uapi/domestic-stock/v1/ranking/quote-balance`
- **국내주식 수익자산지표 순위**: `/uapi/domestic-stock/v1/ranking/profit-asset-index`
- **국내주식 시가총액 상위**: `/uapi/domestic-stock/v1/ranking/market-cap`
- **국내주식 재무비율 순위**: `/uapi/domestic-stock/v1/ranking/finance-ratio`
- **국내주식 시간외잔량 순위**: `/uapi/domestic-stock/v1/ranking/after-hour-balance`
- **국내주식 우선주/괴리율 상위**: `/uapi/domestic-stock/v1/ranking/prefer-disparate-ratio`
- **국내주식 이격도 순위**: `/uapi/domestic-stock/v1/ranking/disparity`
- **국내주식 시장가치 순위**: `/uapi/domestic-stock/v1/ranking/market-value`
- **국내주식 체결강도 상위**: `/uapi/domestic-stock/v1/ranking/volume-power`
- **국내주식 관심종목등록 상위**: `/uapi/domestic-stock/v1/ranking/top-interest-stock`
- **국내주식 예상체결 상승/하락상위**: `/uapi/domestic-stock/v1/ranking/exp-trans-updown`
- **국내주식 당사매매종목 상위**: `/uapi/domestic-stock/v1/ranking/traded-by-company`
- **국내주식 신고/신저근접종목 상위**: `/uapi/domestic-stock/v1/ranking/near-new-highlow`
- **국내주식 배당률 상위**: `/uapi/domestic-stock/v1/ranking/dividend-rate`
- **국내주식 대량체결건수 상위**: `/uapi/domestic-stock/v1/ranking/bulk-trans-num`
- **국내주식 신용잔고 상위**: `/uapi/domestic-stock/v1/ranking/credit-balance`
- **국내주식 공매도 상위종목**: `/uapi/domestic-stock/v1/ranking/short-sale`
- **국내주식 시간외등락율순위**: `/uapi/domestic-stock/v1/ranking/overtime-fluctuation`
- **국내주식 시간외거래량순위**: `/uapi/domestic-stock/v1/ranking/overtime-volume`
- **HTS조회상위20종목**: `/uapi/domestic-stock/v1/ranking/hts-top-view`
### [국내주식] 실시간시세
- **국내주식 실시간체결가 (KRX)**: `/tryitout/H0STCNT0`
- **국내주식 실시간호가 (KRX)**: `/tryitout/H0STASP0`
- **국내주식 실시간체결통보**: `/tryitout/H0STCNI0`
- **국내주식 실시간예상체결 (KRX)**: `/tryitout/H0STANC0`
- **국내주식 실시간회원사 (KRX)**: `/tryitout/H0STMBC0`
- **국내주식 실시간프로그램매매 (KRX)**: `/tryitout/H0STPGM0`
- **국내주식 장운영정보 (KRX)**: `/tryitout/H0STMKO0`
- **국내주식 시간외 실시간호가 (KRX)**: `/tryitout/H0STOAA0`
- **국내주식 시간외 실시간체결가 (KRX)**: `/tryitout/H0STOUP0`
- **국내주식 시간외 실시간예상체결 (KRX)**: `/tryitout/H0STOAC0`
- **국내지수 실시간체결**: `/tryitout/H0UPCNT0`
- **국내지수 실시간예상체결**: `/tryitout/H0UPANC0`
- **국내지수 실시간프로그램매매**: `/tryitout/H0UPPGM0`
- **ELW 실시간호가**: `/tryitout/H0EWASP0`
- **ELW 실시간체결가**: `/tryitout/H0EWCNT0`
- **ELW 실시간예상체결**: `/tryitout/H0EWANC0`
- **국내ETF NAV추이**: `/tryitout/H0STNAV0`
- **국내주식 실시간체결가 (통합)**: `/tryitout/H0UNCNT0`
- **국내주식 실시간호가 (통합)**: `/tryitout/H0UNASP0`
- **국내주식 실시간예상체결 (통합)**: `/tryitout/H0UNANC0`
- **국내주식 실시간회원사 (통합)**: `/tryitout/H0UNMBC0`
- **국내주식 실시간프로그램매매 (통합)**: `/tryitout/H0UNPGM0`
- **국내주식 장운영정보 (통합)**: `/tryitout/H0UNMKO0`
- **국내주식 실시간체결가 (NXT)**: `/tryitout/H0NXCNT0`
- **국내주식 실시간호가 (NXT)**: `/tryitout/H0NXASP0`
- **국내주식 실시간예상체결 (NXT)**: `/tryitout/H0NXANC0`
- **국내주식 실시간회원사 (NXT)**: `/tryitout/H0NXMBC0`
- **국내주식 실시간프로그램매매 (NXT)**: `/tryitout/H0NXPGM0`
- **국내주식 장운영정보 (NXT)**: `/tryitout/H0NXMKO0`
### [국내선물옵션] 주문/계좌
- **선물옵션 주문**: `/uapi/domestic-futureoption/v1/trading/order`
- **선물옵션 정정취소주문**: `/uapi/domestic-futureoption/v1/trading/order-rvsecncl`
- **선물옵션 주문체결내역조회**: `/uapi/domestic-futureoption/v1/trading/inquire-ccnl`
- **선물옵션 잔고현황**: `/uapi/domestic-futureoption/v1/trading/inquire-balance`
- **선물옵션 주문가능**: `/uapi/domestic-futureoption/v1/trading/inquire-psbl-order`
- **(야간)선물옵션 주문체결 내역조회**: `/uapi/domestic-futureoption/v1/trading/inquire-ngt-ccnl`
- **(야간)선물옵션 잔고현황**: `/uapi/domestic-futureoption/v1/trading/inquire-ngt-balance`
- **(야간)선물옵션 주문가능 조회**: `/uapi/domestic-futureoption/v1/trading/inquire-psbl-ngt-order`
- **(야간)선물옵션 증거금 상세**: `/uapi/domestic-futureoption/v1/trading/ngt-margin-detail`
- **선물옵션 잔고정산손익내역**: `/uapi/domestic-futureoption/v1/trading/inquire-balance-settlement-pl`
- **선물옵션 총자산현황**: `/uapi/domestic-futureoption/v1/trading/inquire-deposit`
- **선물옵션 잔고평가손익내역**: `/uapi/domestic-futureoption/v1/trading/inquire-balance-valuation-pl`
- **선물옵션 기준일체결내역**: `/uapi/domestic-futureoption/v1/trading/inquire-ccnl-bstime`
- **선물옵션기간약정수수료일별**: `/uapi/domestic-futureoption/v1/trading/inquire-daily-amount-fee`
### [국내선물옵션] 기본시세
- **선물옵션 시세**: `/uapi/domestic-futureoption/v1/quotations/inquire-price`
- **선물옵션 시세호가**: `/uapi/domestic-futureoption/v1/quotations/inquire-asking-price`
- **선물옵션기간별시세(일/주/월/년)**: `/uapi/domestic-futureoption/v1/quotations/inquire-daily-fuopchartprice`
- **선물옵션 분봉조회**: `/uapi/domestic-futureoption/v1/quotations/inquire-time-fuopchartprice`
- **국내옵션전광판\_옵션월물리스트**: `/uapi/domestic-futureoption/v1/quotations/display-board-option-list`
- **국내선물 기초자산 시세**: `/uapi/domestic-futureoption/v1/quotations/display-board-top`
- **국내옵션전광판\_콜풋**: `/uapi/domestic-futureoption/v1/quotations/display-board-callput`
- **국내옵션전광판\_선물**: `/uapi/domestic-futureoption/v1/quotations/display-board-futures`
- **선물옵션 일중예상체결추이**: `/uapi/domestic-futureoption/v1/quotations/exp-price-trend`
### [국내선물옵션] 실시간시세
- **지수선물 실시간호가**: `/tryitout/H0IFASP0`
- **지수선물 실시간체결가**: `/tryitout/H0IFCNT0`
- **지수옵션 실시간호가**: `/tryitout/H0IOASP0`
- **지수옵션 실시간체결가**: `/tryitout/H0IOCNT0`
- **선물옵션 실시간체결통보**: `/tryitout/H0IFCNI0`
- **상품선물 실시간호가**: `/tryitout/H0CFASP0`
- **상품선물 실시간체결가**: `/tryitout/H0CFCNT0`
- **주식선물 실시간호가**: `/tryitout/H0ZFASP0`
- **주식선물 실시간체결가**: `/tryitout/H0ZFCNT0`
- **주식선물 실시간예상체결**: `/tryitout/H0ZFANC0`
- **주식옵션 실시간호가**: `/tryitout/H0ZOASP0`
- **주식옵션 실시간체결가**: `/tryitout/H0ZOCNT0`
- **주식옵션 실시간예상체결**: `/tryitout/H0ZOANC0`
- **KRX야간옵션 실시간호가**: `/tryitout/H0EUASP0`
- **KRX야간옵션 실시간체결가**: `/tryitout/H0EUCNT0`
- **KRX야간옵션실시간예상체결**: `/tryitout/H0EUANC0`
- **KRX야간옵션실시간체결통보**: `/tryitout/H0EUCNI0`
- **KRX야간선물 실시간호가**: `/tryitout/H0MFASP0`
- **KRX야간선물 실시간종목체결**: `/tryitout/H0MFCNT0`
- **KRX야간선물 실시간체결통보**: `/tryitout/H0MFCNI0`
### [해외주식] 주문/계좌
- **해외주식 주문**: `/uapi/overseas-stock/v1/trading/order`
- **해외주식 정정취소주문**: `/uapi/overseas-stock/v1/trading/order-rvsecncl`
- **해외주식 예약주문접수**: `/uapi/overseas-stock/v1/trading/order-resv`
- **해외주식 예약주문접수취소**: `/uapi/overseas-stock/v1/trading/order-resv-ccnl`
- **해외주식 매수가능금액조회**: `/uapi/overseas-stock/v1/trading/inquire-psamount`
- **해외주식 미체결내역**: `/uapi/overseas-stock/v1/trading/inquire-nccs`
- **해외주식 잔고**: `/uapi/overseas-stock/v1/trading/inquire-balance`
- **해외주식 주문체결내역**: `/uapi/overseas-stock/v1/trading/inquire-ccnl`
- **해외주식 체결기준현재잔고**: `/uapi/overseas-stock/v1/trading/inquire-present-balance`
- **해외주식 예약주문조회**: `/uapi/overseas-stock/v1/trading/order-resv-list`
- **해외주식 결제기준잔고**: `/uapi/overseas-stock/v1/trading/inquire-paymt-stdr-balance`
- **해외주식 일별거래내역**: `/uapi/overseas-stock/v1/trading/inquire-period-trans`
- **해외주식 기간손익**: `/uapi/overseas-stock/v1/trading/inquire-period-profit`
- **해외증거금 통화별조회**: `/uapi/overseas-stock/v1/trading/foreign-margin`
- **해외주식 미국주간주문**: `/uapi/overseas-stock/v1/trading/daytime-order`
- **해외주식 미국주간정정취소**: `/uapi/overseas-stock/v1/trading/daytime-order-rvsecncl`
- **해외주식 지정가주문번호조회**: `/uapi/overseas-stock/v1/trading/algo-ordno`
- **해외주식 지정가체결내역조회**: `/uapi/overseas-stock/v1/trading/inquire-algo-ccnl`
### [해외주식] 기본시세
- **해외주식 현재가상세**: `/uapi/overseas-price/v1/quotations/price-detail`
- **해외주식 현재가 호가**: `/uapi/overseas-price/v1/quotations/inquire-asking-price`
- **해외주식 현재체결가**: `/uapi/overseas-price/v1/quotations/price`
- **해외주식 체결추이**: `/uapi/overseas-price/v1/quotations/inquire-ccnl`
- **해외주식분봉조회**: `/uapi/overseas-price/v1/quotations/inquire-time-itemchartprice`
- **해외지수분봉조회**: `/uapi/overseas-price/v1/quotations/inquire-time-indexchartprice`
- **해외주식 기간별시세**: `/uapi/overseas-price/v1/quotations/dailyprice`
- **해외주식 종목/지수/환율기간별시세(일/주/월/년)**: `/uapi/overseas-price/v1/quotations/inquire-daily-chartprice`
- **해외주식조건검색**: `/uapi/overseas-price/v1/quotations/inquire-search`
- **해외결제일자조회**: `/uapi/overseas-stock/v1/quotations/countries-holiday`
- **해외주식 상품기본정보**: `/uapi/overseas-price/v1/quotations/search-info`
- **해외주식 업종별시세**: `/uapi/overseas-price/v1/quotations/industry-theme`
- **해외주식 업종별코드조회**: `/uapi/overseas-price/v1/quotations/industry-price`
### [해외주식] 시세분석
- **해외주식 가격급등락**: `/uapi/overseas-stock/v1/ranking/price-fluct`
- **해외주식 거래량급증**: `/uapi/overseas-stock/v1/ranking/volume-surge`
- **해외주식 매수체결강도상위**: `/uapi/overseas-stock/v1/ranking/volume-power`
- **해외주식 상승율/하락율**: `/uapi/overseas-stock/v1/ranking/updown-rate`
- **해외주식 신고/신저가**: `/uapi/overseas-stock/v1/ranking/new-highlow`
- **해외주식 거래량순위**: `/uapi/overseas-stock/v1/ranking/trade-vol`
- **해외주식 거래대금순위**: `/uapi/overseas-stock/v1/ranking/trade-pbmn`
- **해외주식 거래증가율순위**: `/uapi/overseas-stock/v1/ranking/trade-growth`
- **해외주식 거래회전율순위**: `/uapi/overseas-stock/v1/ranking/trade-turnover`
- **해외주식 시가총액순위**: `/uapi/overseas-stock/v1/ranking/market-cap`
- **해외주식 기간별권리조회**: `/uapi/overseas-price/v1/quotations/period-rights`
- **해외뉴스종합(제목)**: `/uapi/overseas-price/v1/quotations/news-title`
- **해외주식 권리종합**: `/uapi/overseas-price/v1/quotations/rights-by-ice`
- **당사 해외주식담보대출 가능 종목**: `/uapi/overseas-price/v1/quotations/colable-by-company`
- **해외속보(제목)**: `/uapi/overseas-price/v1/quotations/brknews-title`
### [해외주식] 실시간시세
- **해외주식 실시간호가**: `/tryitout/HDFSASP0`
- **해외주식 지연호가(아시아)**: `/tryitout/HDFSASP1`
- **해외주식 실시간지연체결가**: `/tryitout/HDFSCNT0`
- **해외주식 실시간체결통보**: `/tryitout/H0GSCNI0`
### [해외선물옵션] 주문/계좌
- **해외선물옵션 주문**: `/uapi/overseas-futureoption/v1/trading/order`
- **해외선물옵션 정정취소주문**: `/uapi/overseas-futureoption/v1/trading/order-rvsecncl`
- **해외선물옵션 당일주문내역조회**: `/uapi/overseas-futureoption/v1/trading/inquire-ccld`
- **해외선물옵션 미결제내역조회(잔고)**: `/uapi/overseas-futureoption/v1/trading/inquire-unpd`
- **해외선물옵션 주문가능조회**: `/uapi/overseas-futureoption/v1/trading/inquire-psamount`
- **해외선물옵션 기간계좌손익 일별**: `/uapi/overseas-futureoption/v1/trading/inquire-period-ccld`
- **해외선물옵션 일별 체결내역**: `/uapi/overseas-futureoption/v1/trading/inquire-daily-ccld`
- **해외선물옵션 예수금현황**: `/uapi/overseas-futureoption/v1/trading/inquire-deposit`
- **해외선물옵션 일별 주문내역**: `/uapi/overseas-futureoption/v1/trading/inquire-daily-order`
- **해외선물옵션 기간계좌거래내역**: `/uapi/overseas-futureoption/v1/trading/inquire-period-trans`
- **해외선물옵션 증거금상세**: `/uapi/overseas-futureoption/v1/trading/margin-detail`
### [해외선물옵션] 기본시세
- **해외선물종목현재가**: `/uapi/overseas-futureoption/v1/quotations/inquire-price`
- **해외선물종목상세**: `/uapi/overseas-futureoption/v1/quotations/stock-detail`
- **해외선물 호가**: `/uapi/overseas-futureoption/v1/quotations/inquire-asking-price`
- **해외선물 분봉조회**: `/uapi/overseas-futureoption/v1/quotations/inquire-time-futurechartprice`
- **해외선물 체결추이(틱)**: `/uapi/overseas-futureoption/v1/quotations/tick-ccnl`
- **해외선물 체결추이(주간)**: `/uapi/overseas-futureoption/v1/quotations/weekly-ccnl`
- **해외선물 체결추이(일간)**: `/uapi/overseas-futureoption/v1/quotations/daily-ccnl`
- **해외선물 체결추이(월간)**: `/uapi/overseas-futureoption/v1/quotations/monthly-ccnl`
- **해외선물 상품기본정보**: `/uapi/overseas-futureoption/v1/quotations/search-contract-detail`
- **해외선물 미결제추이**: `/uapi/overseas-futureoption/v1/quotations/investor-unpd-trend`
- **해외옵션종목현재가**: `/uapi/overseas-futureoption/v1/quotations/opt-price`
- **해외옵션종목상세**: `/uapi/overseas-futureoption/v1/quotations/opt-detail`
- **해외옵션 호가**: `/uapi/overseas-futureoption/v1/quotations/opt-asking-price`
- **해외옵션 분봉조회**: `/uapi/overseas-futureoption/v1/quotations/inquire-time-optchartprice`
- **해외옵션 체결추이(틱)**: `/uapi/overseas-futureoption/v1/quotations/opt-tick-ccnl`
- **해외옵션 체결추이(일간)**: `/uapi/overseas-futureoption/v1/quotations/opt-daily-ccnl`
- **해외옵션 체결추이(주간)**: `/uapi/overseas-futureoption/v1/quotations/opt-weekly-ccnl`
- **해외옵션 체결추이(월간)**: `/uapi/overseas-futureoption/v1/quotations/opt-monthly-ccnl`
- **해외옵션 상품기본정보**: `/uapi/overseas-futureoption/v1/quotations/search-opt-detail`
- **해외선물옵션 장운영시간**: `/uapi/overseas-futureoption/v1/quotations/market-time`
### [해외선물옵션]실시간시세
- **해외선물옵션 실시간체결가**: `/tryitout/HDFFF020`
- **해외선물옵션 실시간호가**: `/tryitout/HDFFF010`
- **해외선물옵션 실시간주문내역통보**: `/tryitout/HDFFF1C0`
- **해외선물옵션 실시간체결내역통보**: `/tryitout/HDFFF2C0`
### [장내채권] 주문/계좌
- **장내채권 매수주문**: `/uapi/domestic-bond/v1/trading/buy`
- **장내채권 매도주문**: `/uapi/domestic-bond/v1/trading/sell`
- **장내채권 정정취소주문**: `/uapi/domestic-bond/v1/trading/order-rvsecncl`
- **채권정정취소가능주문조회**: `/uapi/domestic-bond/v1/trading/inquire-psbl-rvsecncl`
- **장내채권 주문체결내역**: `/uapi/domestic-bond/v1/trading/inquire-daily-ccld`
- **장내채권 잔고조회**: `/uapi/domestic-bond/v1/trading/inquire-balance`
- **장내채권 매수가능조회**: `/uapi/domestic-bond/v1/trading/inquire-psbl-order`
### [장내채권] 기본시세
- **장내채권현재가(호가)**: `/uapi/domestic-bond/v1/quotations/inquire-asking-price`
- **장내채권현재가(시세)**: `/uapi/domestic-bond/v1/quotations/inquire-price`
- **장내채권현재가(체결)**: `/uapi/domestic-bond/v1/quotations/inquire-ccnl`
- **장내채권현재가(일별)**: `/uapi/domestic-bond/v1/quotations/inquire-daily-price`
- **장내채권 기간별시세(일)**: `/uapi/domestic-bond/v1/quotations/inquire-daily-itemchartprice`
- **장내채권 평균단가조회**: `/uapi/domestic-bond/v1/quotations/avg-unit`
- **장내채권 발행정보**: `/uapi/domestic-bond/v1/quotations/issue-info`
- **장내채권 기본조회**: `/uapi/domestic-bond/v1/quotations/search-bond-info`
### [장내채권] 실시간시세
- **일반채권 실시간체결가**: `/tryitout/H0BJCNT0`
- **일반채권 실시간호가**: `/tryitout/H0BJASP0`
- **채권지수 실시간체결가**: `/tryitout/H0BICNT0`

Binary file not shown.

View File

@@ -0,0 +1,266 @@
# 브라우저 상주 자동매매 통합 계획서 v3.1 (AI/무저장 정책 반영)
## 요약
1. 자동매매는 브라우저가 켜져 있을 때만 동작합니다.
2. 백그라운드 탭(가려진 탭)에서는 동작을 허용합니다.
3. 탭 종료, 브라우저 종료, 앱 종료, 외부 페이지 이탈 시 자동주문은 즉시 중지됩니다.
4. 종료 직전 강한 경고를 보여주고 중지 이벤트를 서버에 기록합니다.
5. 투자금/손실한도는 퍼센트와 금액을 동시에 받고 더 보수적인 값(더 작은 값)을 실적용합니다.
6. 전략 선택은 프롬프트 입력, 검수 카탈로그, 온라인 실시간 수집을 모두 지원하며 복수선택 가능합니다.
7. 실거래 우선, 장중 기본, 보수적 위험관리 기본값을 유지합니다.
8. AI(인공지능)로 매수/매도 신호 후보를 만들고, 최종 주문은 규칙 엔진(고정 검증 로직)이 결정합니다.
9. 한국투자증권 API 키/시크릿/계좌번호는 서버 DB에 저장하지 않습니다.
10. KIS 민감정보는 브라우저 실행 세션 기준으로만 유지하고, 서버는 요청 처리 시에만 일시 사용합니다.
## 1) 기술 아키텍처
1. 프론트엔드: Next.js 16 App Router + React 19 + TypeScript.
2. 상태관리: Zustand 기반 `autotrade-engine-store` 신규.
3. 실시간: 기존 KIS WebSocket 스토어 재사용, 자동매매 엔진 훅으로 연결.
4. 서버 API: Next.js Route Handler(Node 런타임)로 전략/세션/로그/중지 API 제공.
5. 데이터 저장: Supabase Postgres + RLS(행 단위 권한).
6. 인증: Supabase Auth 세션 필수.
7. 보안: KIS 민감정보는 서버 저장 금지, 요청 단위(한 번 호출)로만 처리.
## 2) 배포 구조
1. 앱 배포: Vercel(기존 유지).
2. DB/인증: Supabase(기존 유지).
3. 자동매매 엔진: 브라우저 내부 실행(별도 워커 서버 없음).
4. 서버 역할: 주문 위임, 상태 기록, 위험한도 검증, 감사로그 저장(민감정보 저장 제외).
5. 만료 정리: Vercel Cron(1분 주기) 또는 DB 함수로 heartbeat 만료 세션 `stopped` 전환.
6. 장애 로그: Vercel Logs + Supabase Logs + Sentry(권장) 연동.
## 3) 필수 환경변수
1. `NEXT_PUBLIC_SUPABASE_URL`
2. `NEXT_PUBLIC_SUPABASE_ANON_KEY`
3. `SUPABASE_SERVICE_ROLE_KEY`
4. `AUTOTRADE_HEARTBEAT_TTL_SEC` (기본 90)
5. `AUTOTRADE_MAX_DAILY_ORDERS_DEFAULT` (기본 20)
6. `AUTOTRADE_ONLINE_STRATEGY_ENABLED` (기본 true)
7. `ONLINE_STRATEGY_PROVIDER_KEY` (온라인 수집용 키)
8. `KIS_SERVER_STORAGE_DISABLED` (고정값 `true`, 서버 저장 차단 가드)
## 3-1) KIS 키/계좌 무저장 정책(추가)
1. 저장 금지 대상: `appKey`, `appSecret`, `accountNo`, `accountProductCode`.
2. 서버 DB(Supabase 포함)에는 위 값을 절대 저장하지 않습니다.
3. 서버 로그에도 원문을 남기지 않고 마스킹(일부 가리기) 처리합니다.
4. 자동매매 요청 시 민감정보는 헤더로 전달하고, 요청 처리 후 즉시 폐기합니다.
5. 브라우저 보관은 `sessionStorage` 우선, `localStorage` 영구 저장은 자동매매 모드에서 금지합니다.
6. UI 흐름: 설정 UI 입력 -> 메모리/세션 저장 -> API 호출 헤더 전달 -> 서버 즉시 사용 후 폐기.
## 4) 데이터 모델(Supabase)
1. `auto_trade_strategies`
2. 주요 컬럼: `user_id`, `name`, `strategy_source_type(prompt|catalog|online)`, `symbols[]`, `allocation_percent`, `allocation_amount`, `effective_allocation_amount`, `daily_loss_percent`, `daily_loss_amount`, `effective_daily_loss_limit`, `resolved_params(jsonb)`, `status`.
3. `auto_trade_sessions`
4. 주요 컬럼: `strategy_id`, `desired_state`, `runtime_state`, `leader_tab_id`, `last_heartbeat_at`, `started_at`, `ended_at`, `stop_reason`.
5. `auto_trade_order_attempts`
6. 주요 컬럼: `session_id`, `symbol`, `idempotency_key(unique)`, `request_payload`, `response_payload`, `status`, `blocked_reason`.
7. `auto_trade_signal_logs`
8. 주요 컬럼: `session_id`, `signal_payload`, `decision(execute|skip|block)`, `decision_reason`, `source_type`, `risk_grade`.
9. `auto_trade_online_strategies`
10. 주요 컬럼: `title`, `source_url`, `strategy_text`, `fetched_at`, `parser_score`, `risk_grade`, `is_approved`.
11. `auto_trade_audit_logs`
12. 주요 컬럼: `user_id`, `action`, `payload`, `created_at`.
13. `kis_credentials*` 계열 테이블은 만들지 않습니다(무저장 정책).
## 5) API 설계
1. `POST /api/autotrade/strategies/compile`
2. 입력: 프롬프트/온라인 텍스트.
3. 출력: 표준 규칙(JSON) + 검증결과.
4. `POST /api/autotrade/strategies/validate`
5. 출력: 실행 가능 여부, 차단 사유.
6. `GET /api/autotrade/templates`
7. 검수 카탈로그 전략 목록 제공.
8. `POST /api/autotrade/strategies/discover`
9. 온라인 실시간 수집 전략 목록 제공.
10. `POST /api/autotrade/strategies`
11. 전략 저장(배분/손실한도 실적용값 계산 포함).
12. `POST /api/autotrade/sessions/start`
13. 세션 시작 + 리스크 스냅샷 생성.
14. `POST /api/autotrade/sessions/heartbeat`
15. 리더 탭 생존신호 갱신.
16. `POST /api/autotrade/sessions/stop`
17. `reason`: `browser_exit|external_leave|manual|emergency|heartbeat_timeout`.
18. `GET /api/autotrade/sessions/active`
19. 현재 실행 세션/리더 정보 조회.
20. `GET /api/autotrade/sessions/{id}/logs`
21. 신호/주문/오류 로그 조회.
22. 자동매매 관련 API(주문/세션/리스크)는 요청 헤더에 KIS 정보 포함이 필수입니다.
23. 서버는 헤더 값 유효성만 검사하고 DB에는 저장하지 않습니다.
24. 실패 응답/에러 로그에서도 민감정보는 마스킹합니다.
## 5-1) AI 자동매매 설계(추가)
1. 핵심 원칙: AI는 "신호 후보 생성기", 최종 주문 판단은 "규칙 엔진"이 담당.
2. 이유: AI 단독 주문은 일관성(항상 같은 판단)과 추적성이 약해 리스크가 큽니다.
3. AI 입력 데이터:
4. 실시간 체결/호가, 최근 변동성, 거래량, 전략 파라미터, 장 상태(정규장/시간외).
5. AI 출력 데이터:
6. `signal`(buy/sell/hold), `confidence`(신뢰도), `reason`(한 줄 근거), `ttlSec`(신호 유효시간).
7. 실행 흐름:
8. 사용자 전략 선택/프롬프트 입력 -> AI 해석 -> 규칙 JSON 변환 -> 리스크 검증 -> 주문 실행/차단.
9. 온라인 유명 단타 기법 처리:
10. 실시간 수집 -> 정규화(형식 맞추기) -> 위험등급 부여 -> 사용자 선택 -> 검증 통과 시 활성화.
11. AI 장애 대응:
12. AI 응답 지연/실패 시 신규 주문 중지 또는 보수 모드(`hold`) 강제.
13. AI 드리프트(성능 저하) 대응:
14. 최근 N건 성능 추적 후 기준 미달 전략 자동 일시정지.
15. UI 흐름:
16. 전략 화면 -> "AI 제안 받기" 클릭 -> 제안 전략 목록 표시 -> 사용자 선택/수정 -> 저장/시뮬레이션 -> 시작.
17. 운영 기본값:
18. `confidence`가 임계치(예: 0.65) 미만이면 주문 차단.
19. `reason`이 비어 있으면 주문 차단(설명 없는 주문 금지).
20. 동일 종목 반대 신호가 짧은 시간에 반복되면 쿨다운 연장.
## 5-2) 자동매매 설정 팝업 UX(사용자 요청 반영)
1. 진입 흐름:
2. 자동매매 버튼 클릭 -> 자동매매 설정 팝업 오픈 -> 설정 입력 -> "자동매매 시작" 클릭.
3. 팝업 필수 입력:
4. 전략 프롬프트(자유 입력)
5. 유명 기법 선택(복수 선택): ORB(시가 범위 돌파), VWAP 되돌림, 거래량 돌파, 이동평균 교차, 갭 돌파.
6. 투자금 설정: 퍼센트(%) + 금액(원) 동시 입력.
7. 전략별 일일 손실한도: 퍼센트(%) + 금액(원) 동시 입력.
8. 거래 대상: 종목 다중 선택(또는 관심종목 가져오기).
9. 실행 전 검증:
10. AI 해석 결과 미리보기(어떤 근거로 매수/매도할지 요약)
11. 리스크 요약(실적용 투자금, 실적용 손실한도, 예상 최대 주문 수)
12. 동의 체크(브라우저 종료/외부 이탈 시 즉시 중지)
13. 버튼 정책:
14. 필수값 누락 또는 검증 실패 시 시작 버튼 비활성화.
15. 시작 성공 시 상단 고정 배너와 세션 상태 카드 즉시 표시.
## 5-3) AI API 선택 권장안(실행 가능한 추천)
1. 결론:
2. 1차는 OpenAI API를 기본으로 시작하고, 2차에서 Gemini/Claude를 붙일 수 있게 다중 제공자 어댑터(연결 레이어) 구조로 개발합니다.
3. 추천 이유(요약):
4. Structured Outputs(스키마 고정 출력) + Function Calling(함수 호출) 문서/생태계가 성숙해서 자동매매 검증 파이프라인 구성에 유리합니다.
5. 비용/속도 최적화 모델 선택지가 넓어 PoC(개념검증) -> 운영 전환이 쉽습니다.
6. 제공자별 특징:
7. OpenAI: 엄격 모드(`strict`) 기반 함수 스키마 강제가 명확하고, `parallel_tool_calls=false`로 1회 1액션 제어가 쉽습니다.
8. Gemini: 함수 호출 모드(`AUTO`/`ANY`/`NONE`/`VALIDATED`)가 명확하고 JSON 스키마 출력 지원이 좋아 대체 제공자로 적합합니다.
9. Claude: `strict: true` 도구 호출과 구조화 출력이 강점이며, 보조/백업 제공자로 적합합니다.
10. 운영 권장:
11. 1차: OpenAI 단일 운영
12. 2차: OpenAI 실패/지연 시 Gemini 폴백(대체 경로)
13. 3차: Claude까지 확장하는 3중화(고가용성)
## 5-4) AI 판단 -> 주문 실행 파이프라인(실전형)
1. Step 1. 입력 수집:
2. 사용자 프롬프트 + 선택한 유명 기법 + 실시간 시세/호가 + 보유/가용자산 + 리스크 한도.
3. Step 2. AI 해석:
4. AI가 `signal`, `confidence`, `reason`, `ttlSec`, `proposed_order`를 JSON으로 반환.
5. Step 3. 규칙 엔진 검증:
6. 스키마 검증(형식), 정책 검증(리스크), 시장상태 검증(장중 여부), 중복주문 검증(idempotency).
7. Step 4. 주문 결정:
8. 검증 통과 -> KIS 주문 API 호출.
9. 검증 실패 -> 주문 차단 + 사유 로그 기록.
10. Step 5. 사후 평가:
11. 체결/미체결 결과를 AI 평가 입력으로 재사용해 프롬프트/기법 가중치 조정.
## 5-5) AI 호출 프롬프트/출력 표준(권장 JSON)
1. 시스템 프롬프트 핵심:
2. "너는 주문 실행기가 아니라 신호 생성기다. 스키마에 맞는 JSON만 반환하고 설명문은 금지한다."
3. 출력 스키마:
4. `signal`: `buy|sell|hold`
5. `confidence`: `0~1`
6. `reason`: 짧은 한국어 근거
7. `proposed_order`: `{symbol, side, orderType, price, quantity}`
8. `risk_flags`: `string[]`
9. `ttlSec`: 신호 만료 시간
10. 차단 규칙:
11. `confidence < threshold` 또는 `reason` 누락 또는 `risk_flags`에 차단 사유 포함 시 주문 금지.
## 5-6) 서버 무저장 정책과 AI 호출 결합 방식
1. KIS 민감정보(`appKey`, `appSecret`, `accountNo`)는 AI API 호출 입력에 넣지 않습니다.
2. AI에는 가격/지표/포지션 요약 같은 비식별 데이터(개인 식별이 어려운 데이터)만 전달합니다.
3. 실제 주문 직전 단계에서만 브라우저 세션의 KIS 정보로 주문 API를 호출합니다.
4. 서버는 주문 처리 중 헤더를 일시 사용 후 폐기하며 DB/로그 저장을 금지합니다.
5. 에러 로그/감사로그에는 주문 사유와 결과만 남기고 민감값은 마스킹 처리합니다.
## 6) 브라우저 엔진 동작
1. 엔진 상태: `IDLE`, `ARMED`, `RUNNING`, `STOPPING`, `STOPPED`, `ERROR`.
2. 멀티탭 제어: `localStorage` lock + `BroadcastChannel` 동기화.
3. 리더 탭만 주문 실행, 팔로워 탭은 조회 전용.
4. 주문은 틱 이벤트(WebSocket 수신) 기반으로 처리해 백그라운드 타이머 지연 영향을 줄입니다.
5. heartbeat 10초 주기 전송, TTL 90초 초과 시 서버 강제 종료.
6. 새로고침 시 로컬 snapshot으로 이어서 실행.
7. 브라우저 완전 종료 후 재진입 시 자동 재개 금지, `중지 상태`로 복구 후 사용자 재시작 필요.
8. 백그라운드 탭에서도 WebSocket 이벤트 기반으로 신호 계산/주문은 유지합니다.
## 7) 강한 경고/즉시 중지 UX
1. 실행 중 상단 빨간 경고 바 고정: "브라우저/탭 종료 또는 외부 이동 시 자동주문이 즉시 중지됩니다."
2. 외부 링크 클릭 시 사전 모달 강제: "이동하면 자동매매가 중지됩니다. 계속할까요?"
3. 탭 닫기/브라우저 종료는 `beforeunload` 기본 경고 사용.
4. 종료 시퀀스: `STOPPING` 전환 -> 신규 주문 차단 -> `sendBeacon(stop)` -> lock 해제 -> `STOPPED`.
5. 브라우저 보안 제한으로 `beforeunload` 커스텀 문구는 사용하지 않습니다(표준 경고만 가능).
## 8) 자산 배분/손실한도 입력 규칙
1. 투자금 입력: `퍼센트(%)` + `금액(원)` 동시 입력.
2. 실적용 투자금: `min(가용자산*퍼센트, 금액)`.
3. 일일 손실한도 입력: `퍼센트(%)` + `금액(원)` 동시 입력.
4. 실적용 손실한도: `min(전략투자금*퍼센트, 금액)`.
5. UI에 실적용 값 실시간 계산 표시.
6. 유효성 검증: 0보다 큰 값, 최대 퍼센트 상한, 가용자산 초과 금액 차단.
7. UI에 "현재 가용자산 기준 실제 주문 가능 금액"을 즉시 표시합니다.
## 9) 전략 선택 체계(복수선택)
1. 소스 탭 3개: `프롬프트`, `검수 카탈로그`, `온라인 실시간 수집`.
2. 사용자는 소스별 전략을 여러 개 선택해 하나의 실행세트로 저장 가능.
3. 프롬프트 전략: 자연어 입력 -> 컴파일 -> 검증 통과 시 활성화.
4. 카탈로그 전략: 운영 검수 완료 버전만 제공.
5. 온라인 전략: 실시간 수집 결과를 보여주되 검증 통과 전에는 실행 금지.
6. 온라인/프롬프트 전략은 위험등급(`low|mid|high`) 자동 부여 후 실행 제한에 반영.
## 10) 보수적 위험관리 기본값
1. 전략별 일일 손실한도 기본 2%.
2. 전략별 일일 최대 주문 20건.
3. 종목별 주문 쿨다운 60초.
4. 단일 주문 상한: 전략 투자금의 25%.
5. 데이터 지연 5초 초과 시 신규 주문 차단.
6. 연속 실패 3회 시 자동 중지.
7. lock 충돌 2회 이상 시 자동 중지.
8. 비상정지 버튼은 언제나 최상단 고정 노출.
## 11) 구현 파일 범위
1. `features/autotrade/components/*` (전략 선택, 배분 입력, 경고 배너, 실행 상태 패널)
2. `features/autotrade/hooks/useAutotradeEngine.ts`
3. `features/autotrade/stores/use-autotrade-engine-store.ts`
4. `features/autotrade/types/autotrade.types.ts`
5. `app/api/autotrade/**/route.ts`
6. `lib/autotrade/*` (컴파일, 검증, 리스크 게이트, lock 유틸)
7. 기존 `TradeContainer`/`OrderForm`에 자동매매 섹션 통합
8. `features/settings/store/use-kis-runtime-store.ts` 자동매매 모드에서 민감정보 `persist` 제외
9. `app/api/kis/*``app/api/autotrade/*` 민감정보 마스킹 유틸 공통 적용
## 12) 테스트 시나리오
1. 멀티탭 3개에서 리더 1개만 주문하는지 확인.
2. 백그라운드 탭에서 실시간 신호 기반 주문이 유지되는지 확인.
3. 외부 링크 이탈 시 강한 경고 후 즉시 중지되는지 확인.
4. 탭 종료/브라우저 종료에서 `sendBeacon` + TTL 강제종료가 동작하는지 확인.
5. 퍼센트+금액 입력 시 실적용 값이 작은 값으로 계산되는지 확인.
6. 전략별 일일 손실한도 초과 시 즉시 차단되는지 확인.
7. 온라인 전략 검증 실패 시 실행이 막히는지 확인.
8. 새로고침 후 동일 세션이 중복주문 없이 이어지는지 확인.
9. 서버 DB/로그에 KIS 키/계좌 원문이 저장되지 않는지 확인.
10. AI 응답 누락/지연 시 주문이 차단되는지 확인.
11. AI `confidence` 임계치 미만에서 주문 차단되는지 확인.
## 13) 단계별 배포 계획
1. 1주차: DB 마이그레이션 + API 골격 + 타입 정의.
2. 2주차: 브라우저 엔진(lock/heartbeat/stop flow) + 기본 UI.
3. 3주차: 전략 소스 3종(프롬프트/카탈로그/온라인) + 컴파일/검증.
4. 4주차: 리스크 정책 완성 + 통합/E2E + 운영 모니터링.
5. 롤아웃: 기능 플래그로 5% 사용자 -> 30% -> 전체 오픈.
## 14) 수용 기준
1. 실행 중 종료 트리거 발생 시 신규 주문이 즉시 0건이어야 합니다.
2. 멀티탭에서 중복 주문이 발생하지 않아야 합니다.
3. 사용자는 전략별 투자금/손실한도를 퍼센트+금액으로 모두 설정할 수 있어야 합니다.
4. 프롬프트/카탈로그/온라인 전략 복수선택 저장과 실행이 가능해야 합니다.
5. 로그 화면에서 신호-판단-주문-중지 이유가 연결되어 추적 가능해야 합니다.
## 15) 명시적 가정/기본값
1. "다른 페이지 이동"은 외부 도메인 이탈 기준입니다.
2. 앱 내부 라우트 이동은 중지 트리거가 아닙니다.
3. 브라우저가 완전히 종료되면 자동매매는 반드시 중지 상태로 종료됩니다.
4. 브라우저 재진입 시 자동 재개는 하지 않고 사용자 재시작으로만 실행합니다.
5. 온라인 전략은 "실시간 수집 가능"이지만 "검증 통과 후 실행"을 강제합니다.
6. KIS API 키/계좌 정보는 서버 저장을 금지하고 요청 단위 처리만 허용합니다.

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
# Korean Stocks 동기화
`korean-stocks.json`은 수동 편집 파일이 아니라 자동 생성 파일입니다.
직접 수정하지 말고 동기화 스크립트로 갱신하세요.
## 실행 명령
```bash
npm run sync:stocks
```
- KIS 최신 KOSPI/KOSDAQ 마스터 파일을 내려받아
`features/trade/data/korean-stocks.json`을 다시 생성합니다.
```bash
npm run sync:stocks:check
```
- 현재 파일이 최신인지 검사합니다.
- 갱신이 필요하면 종료 코드 `1`로 끝납니다.
```bash
npm run sync:stocks -- --dry-run
```
- 원격 파일 파싱/검증만 하고 저장은 하지 않습니다.
## 권장 운영 방법
1. 하루 1회(또는 배포 전) `npm run sync:stocks` 실행
2. `npm run lint`, `npm run build`로 기본 검증
3. 갱신된 `features/trade/data/korean-stocks.json` 커밋
## 참고
- 데이터 출처:
- `https://new.real.download.dws.co.kr/common/master/kospi_code.mst.zip`
- `https://new.real.download.dws.co.kr/common/master/kosdaq_code.mst.zip`
- 비정상 데이터 저장을 막기 위해 최소 건수 검증(안전장치)을 넣었습니다.
- 임시 파일 저장 후 교체(원자적 저장) 방식이라 중간 손상 위험을 줄입니다.
- 공식 문서:
- `https://apiportal.koreainvestment.com/apiservice-category`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,146 @@
# Global Alert System 사용 가이드
이 문서는 애플리케이션 전역에서 사용 가능한 `Global Alert System`의 설계 목적, 설치 방법, 그리고 사용법을 설명합니다.
## 1. 개요 (Overview)
Global Alert System은 Zustand 상태 관리 라이브러리와 Shadcn/ui의 `AlertDialog` 컴포넌트를 결합하여 만든 전역 알림 시스템입니다.
복잡한 모달 로직을 매번 구현할 필요 없이, `useGlobalAlert` 훅 하나로 어디서든 일관된 UI의 알림 및 확인 창을 호출할 수 있습니다.
### 주요 특징
- **전역 상태 관리**: `useGlobalAlertStore`를 통해 모달의 상태를 중앙에서 관리합니다.
- **간편한 Hook**: `useGlobalAlert` 훅을 통해 직관적인 API (`alert.success`, `alert.confirm` 등)를 제공합니다.
- **다양한 타입 지원**: Success, Error, Warning, Info 등 상황에 맞는 스타일과 아이콘을 자동으로 적용합니다.
- **비동기 지원**: 확인/취소 버튼 클릭 시 콜백 함수를 실행할 수 있습니다.
---
## 2. 설치 및 설정 (Setup)
이미 `app/layout.tsx`에 설정되어 있으므로, 개발자는 별도의 설정 없이 바로 사용할 수 있습니다.
### 파일 구조
```
features/layout/
├── components/
│ └── GlobalAlertModal.tsx # 실제 렌더링되는 모달 컴포넌트
├── hooks/
│ └── use-global-alert.ts # 개발자가 사용하는 Custom Hook
└── stores/
└── use-global-alert-store.ts # Zustand Store
```
### Layout 통합
`app/layout.tsx``GlobalAlertModal`이 이미 등록되어 있습니다.
```tsx
// app/layout.tsx
import { GlobalAlertModal } from "@/features/layout/components/GlobalAlertModal";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<body>
<GlobalAlertModal /> {/* 전역 모달 등록 */}
{children}
</body>
</html>
);
}
```
---
## 3. 사용법 (Usage)
### Hook 가져오기
```tsx
import { useGlobalAlert } from "@/features/layout/hooks/use-global-alert";
const { alert } = useGlobalAlert();
```
### 기본 알림 (Alert)
사용자에게 단순히 정보를 전달하고 확인 버튼만 있는 알림입니다.
```tsx
// 1. 성공 알림
alert.success("저장이 완료되었습니다.");
// 2. 에러 알림
alert.error("데이터 불러오기에 실패했습니다.");
// 3. 경고 알림
alert.warning("입력 값이 올바르지 않습니다.");
// 4. 정보 알림
alert.info("새로운 버전이 업데이트되었습니다.");
```
옵션을 추가하여 제목이나 버튼 텍스트를 변경할 수 있습니다.
```tsx
alert.success("저장 완료", {
title: "성공", // 기본값: 타입에 따른 제목 (예: "성공", "오류")
confirmLabel: "닫기", // 기본값: "확인"
});
```
### 확인 대화상자 (Confirm)
사용자의 선택(확인/취소)을 요구하는 대화상자입니다.
```tsx
alert.confirm("정말로 삭제하시겠습니까?", {
type: "warning", // 기본값: warning (아이콘과 색상 변경됨)
confirmLabel: "삭제",
cancelLabel: "취소",
onConfirm: () => {
console.log("삭제 버튼 클릭됨");
// 여기에 삭제 로직 추가
},
onCancel: () => {
console.log("취소 버튼 클릭됨");
},
});
```
---
## 4. API Reference
### `useGlobalAlert()`
Hook은 `alert` 객체와 `close` 함수를 반환합니다.
#### `alert` Methods
| 메서드 | 설명 | 파라미터 |
| --------- | ----------------------- | ---------------------------------------------- |
| `success` | 성공 알림 표시 | `(message: ReactNode, options?: AlertOptions)` |
| `error` | 오류 알림 표시 | `(message: ReactNode, options?: AlertOptions)` |
| `warning` | 경고 알림 표시 | `(message: ReactNode, options?: AlertOptions)` |
| `info` | 정보 알림 표시 | `(message: ReactNode, options?: AlertOptions)` |
| `confirm` | 확인/취소 대화상자 표시 | `(message: ReactNode, options?: AlertOptions)` |
#### `AlertOptions` Interface
```typescript
interface AlertOptions {
title?: ReactNode; // 모달 제목 (생략 시 타입에 맞는 기본 제목)
confirmLabel?: string; // 확인 버튼 텍스트 (기본: "확인")
cancelLabel?: string; // 취소 버튼 텍스트 (Confirm 모드에서 기본: "취소")
onConfirm?: () => void; // 확인 버튼 클릭 시 실행될 콜백
onCancel?: () => void; // 취소 버튼 클릭 시 실행될 콜백
type?: AlertType; // 알림 타입 ("success" | "error" | "warning" | "info")
}
```

View File

@@ -1,9 +1,9 @@
/** /**
* @file components/theme-toggle.tsx * @file components/theme-toggle.tsx
* @description 라이트/다크/시스템 테마 전환 토글 버튼 * @description 라이트/다크 테마 즉시 전환 토글 버튼
* @remarks * @remarks
* - [레이어] Components/UI * - [레이어] Components/UI
* - [사용자 행동] 버튼 클릭 -> 드롭다운 메뉴 -> 테마 선택 -> 즉시 반영 * - [사용자 행동] 버튼 클릭 -> 라이트/다크 즉시 전환
* - [연관 파일] theme-provider.tsx (next-themes) * - [연관 파일] theme-provider.tsx (next-themes)
*/ */
@@ -12,48 +12,53 @@
import * as React from "react"; import * as React from "react";
import { Moon, Sun } from "lucide-react"; import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
DropdownMenu, interface ThemeToggleProps {
DropdownMenuContent, className?: string;
DropdownMenuItem, iconClassName?: string;
DropdownMenuTrigger, }
} from "@/components/ui/dropdown-menu";
/** /**
* 테마 토글 컴포넌트 * 테마 토글 컴포넌트
* @remarks next-themes의 useTheme 훅 사용 * @remarks next-themes의 useTheme 훅 사용
* @returns Dropdown 메뉴 형태의 테마 선택기 * @returns 단일 클릭으로 라이트/다크를 전환하는 버튼
* @see features/layout/components/header.tsx Header 액션 영역 - 사용자 테마 전환 버튼
*/ */
export function ThemeToggle() { export function ThemeToggle({ className, iconClassName }: ThemeToggleProps) {
const { setTheme } = useTheme(); const { resolvedTheme, setTheme } = useTheme();
const handleToggleTheme = React.useCallback(() => {
// 시스템 테마 사용 중에도 현재 화면 기준으로 명확히 라이트/다크를 토글합니다.
setTheme(resolvedTheme === "dark" ? "light" : "dark");
}, [resolvedTheme, setTheme]);
return ( return (
<DropdownMenu modal={false}> <Button
{/* ========== 트리거 버튼 ========== */} type="button"
<DropdownMenuTrigger asChild> variant="ghost"
<Button variant="ghost" size="icon"> size="icon"
{/* 라이트 모드 아이콘 (회전 애니메이션) */} className={className}
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> onClick={handleToggleTheme}
{/* 다크 모드 아이콘 (회전 애니메이션) */} aria-label="테마 전환"
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> >
{/* ========== LIGHT ICON ========== */}
<Sun
className={cn(
"h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0",
iconClassName,
)}
/>
{/* ========== DARK ICON ========== */}
<Moon
className={cn(
"absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100",
iconClassName,
)}
/>
<span className="sr-only">Toggle theme</span> <span className="sr-only">Toggle theme</span>
</Button> </Button>
</DropdownMenuTrigger>
{/* ========== 메뉴 컨텐츠 (우측 정렬) ========== */}
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
); );
} }

View File

@@ -0,0 +1,152 @@
"use client";
import React, { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { cn } from "@/lib/utils";
const TONE_PHRASES = [
{ q: "주식이 너무 어려워요...", a: "걱정하지 마. JOORIN-E가 다 해줄게." },
{
q: "내 돈, 정말 안전할까?",
a: "안심해도 돼. 금융권 수준 보안키로 지키니까.",
},
{
q: "손실 날까 봐 불안해요...",
a: "걱정하지 마. 안전 장치가 24시간 작동해.",
},
{
q: "복잡한 건 딱 질색인데..",
a: "몰라도 돼. 클릭 몇 번이면 바로 시작이야.",
},
];
/**
* @description 프리미엄한 텍스트 리빌 효과를 제공하는 브랜드 톤 컴포넌트
*/
export function AnimatedBrandTone() {
const [index, setIndex] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setIndex((prev) => (prev + 1) % TONE_PHRASES.length);
}, 5000);
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 (
<div className="flex min-h-[300px] flex-col items-center justify-center py-10 text-center md:min-h-[400px]">
<AnimatePresence mode="wait">
<motion.div
key={index}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="flex w-full flex-col items-center"
>
{/* 질문 (Q) */}
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="text-sm font-medium text-brand-300/60 md:text-lg"
>
&ldquo;{TONE_PHRASES[index].q}&rdquo;
</motion.p>
{/* 답변 (A) - 타이핑 효과 */}
<div className="mt-8 flex w-full flex-col items-center gap-2 px-2 sm:px-4">
<h2
className="w-full font-bold text-white drop-shadow-[0_12px_30px_rgba(0,0,0,0.38)]"
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
key={i}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0,
delay: 0.45 + i * 0.055,
}}
className={cn(
"inline-block align-baseline",
i < 5 ? "text-brand-300" : "text-white",
)}
>
{char === " " ? "\u00A0" : char}
</motion.span>
))}
{/* 깜빡이는 커서 */}
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: [0, 1, 0] }}
transition={{
duration: 0.8,
repeat: Infinity,
ease: "linear",
}}
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>
</h2>
</div>
</motion.div>
</AnimatePresence>
{/* 인디케이터 - 유휴 상태 표시 및 선택 기능 */}
<div className="mt-16 flex gap-3">
{TONE_PHRASES.map((_, i) => (
<button
key={i}
onClick={() => setIndex(i)}
className={cn(
"h-1.5 transition-all duration-500 rounded-full",
i === index
? "w-10 bg-brand-500 shadow-[0_0_20px_rgba(20,184,166,0.3)]"
: "w-2 bg-white/10 hover:bg-white/20",
)}
aria-label={`Go to slide ${i + 1}`}
/>
))}
</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";
}

48
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator" import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"

View File

@@ -0,0 +1,244 @@
"use client";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
interface ShaderBackgroundProps {
className?: string;
opacity?: number;
}
const VS_SOURCE = `
attribute vec4 aVertexPosition;
void main() {
gl_Position = aVertexPosition;
}
`;
const FS_SOURCE = `
precision highp float;
uniform vec2 iResolution;
uniform float iTime;
const float overallSpeed = 0.2;
const float gridSmoothWidth = 0.015;
const float axisWidth = 0.05;
const float majorLineWidth = 0.025;
const float minorLineWidth = 0.0125;
const float majorLineFrequency = 5.0;
const float minorLineFrequency = 1.0;
const vec4 gridColor = vec4(0.5);
const float scale = 5.0;
const vec4 lineColor = vec4(0.4, 0.2, 0.8, 1.0);
const float minLineWidth = 0.01;
const float maxLineWidth = 0.2;
const float lineSpeed = 1.0 * overallSpeed;
const float lineAmplitude = 1.0;
const float lineFrequency = 0.2;
const float warpSpeed = 0.2 * overallSpeed;
const float warpFrequency = 0.5;
const float warpAmplitude = 1.0;
const float offsetFrequency = 0.5;
const float offsetSpeed = 1.33 * overallSpeed;
const float minOffsetSpread = 0.6;
const float maxOffsetSpread = 2.0;
const int linesPerGroup = 16;
#define drawCircle(pos, radius, coord) smoothstep(radius + gridSmoothWidth, radius, length(coord - (pos)))
#define drawSmoothLine(pos, halfWidth, t) smoothstep(halfWidth, 0.0, abs(pos - (t)))
#define drawCrispLine(pos, halfWidth, t) smoothstep(halfWidth + gridSmoothWidth, halfWidth, abs(pos - (t)))
#define drawPeriodicLine(freq, width, t) drawCrispLine(freq / 2.0, width, abs(mod(t, freq) - (freq) / 2.0))
float drawGridLines(float axis) {
return drawCrispLine(0.0, axisWidth, axis)
+ drawPeriodicLine(majorLineFrequency, majorLineWidth, axis)
+ drawPeriodicLine(minorLineFrequency, minorLineWidth, axis);
}
float drawGrid(vec2 space) {
return min(1.0, drawGridLines(space.x) + drawGridLines(space.y));
}
float random(float t) {
return (cos(t) + cos(t * 1.3 + 1.3) + cos(t * 1.4 + 1.4)) / 3.0;
}
float getPlasmaY(float x, float horizontalFade, float offset) {
return random(x * lineFrequency + iTime * lineSpeed) * horizontalFade * lineAmplitude + offset;
}
void main() {
vec2 fragCoord = gl_FragCoord.xy;
vec4 fragColor;
vec2 uv = fragCoord.xy / iResolution.xy;
vec2 space = (fragCoord - iResolution.xy / 2.0) / iResolution.x * 2.0 * scale;
float horizontalFade = 1.0 - (cos(uv.x * 6.28) * 0.5 + 0.5);
float verticalFade = 1.0 - (cos(uv.y * 6.28) * 0.5 + 0.5);
space.y += random(space.x * warpFrequency + iTime * warpSpeed) * warpAmplitude * (0.5 + horizontalFade);
space.x += random(space.y * warpFrequency + iTime * warpSpeed + 2.0) * warpAmplitude * horizontalFade;
vec4 lines = vec4(0.0);
vec4 bgColor1 = vec4(0.1, 0.1, 0.3, 1.0);
vec4 bgColor2 = vec4(0.3, 0.1, 0.5, 1.0);
for(int l = 0; l < linesPerGroup; l++) {
float normalizedLineIndex = float(l) / float(linesPerGroup);
float offsetTime = iTime * offsetSpeed;
float offsetPosition = float(l) + space.x * offsetFrequency;
float rand = random(offsetPosition + offsetTime) * 0.5 + 0.5;
float halfWidth = mix(minLineWidth, maxLineWidth, rand * horizontalFade) / 2.0;
float offset = random(offsetPosition + offsetTime * (1.0 + normalizedLineIndex)) * mix(minOffsetSpread, maxOffsetSpread, horizontalFade);
float linePosition = getPlasmaY(space.x, horizontalFade, offset);
float line = drawSmoothLine(linePosition, halfWidth, space.y) / 2.0 + drawCrispLine(linePosition, halfWidth * 0.15, space.y);
float circleX = mod(float(l) + iTime * lineSpeed, 25.0) - 12.0;
vec2 circlePosition = vec2(circleX, getPlasmaY(circleX, horizontalFade, offset));
float circle = drawCircle(circlePosition, 0.01, space) * 4.0;
line = line + circle;
lines += line * lineColor * rand;
}
fragColor = mix(bgColor1, bgColor2, uv.x);
fragColor *= verticalFade;
fragColor.a = 1.0;
fragColor += lines;
gl_FragColor = fragColor;
}
`;
/**
* @description Compile one shader source.
* @see components/ui/shader-background.tsx ShaderBackground useEffect - WebGL init flow
*/
function loadShader(gl: WebGLRenderingContext, type: number, source: string) {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error("Shader compile error:", gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
/**
* @description Create and link WebGL shader program.
* @see components/ui/shader-background.tsx ShaderBackground useEffect - shader program setup
*/
function initShaderProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string) {
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vertexSource);
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
if (!vertexShader || !fragmentShader) return null;
const shaderProgram = gl.createProgram();
if (!shaderProgram) return null;
gl.attachShader(shaderProgram, vertexShader);
gl.attachShader(shaderProgram, fragmentShader);
gl.linkProgram(shaderProgram);
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
console.error("Shader program link error:", gl.getProgramInfoLog(shaderProgram));
gl.deleteProgram(shaderProgram);
return null;
}
return shaderProgram;
}
/**
* @description Animated shader background canvas.
* @param className Tailwind class for canvas.
* @param opacity Canvas opacity.
* @see https://21st.dev/community/components/thanh/shader-background/default
*/
const ShaderBackground = ({ className, opacity = 0.9 }: ShaderBackgroundProps) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const gl = canvas.getContext("webgl");
if (!gl) {
console.warn("WebGL not supported.");
return;
}
const shaderProgram = initShaderProgram(gl, VS_SOURCE, FS_SOURCE);
if (!shaderProgram) return;
const positionBuffer = gl.createBuffer();
if (!positionBuffer) return;
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
const positions = [-1.0, -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0];
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
const vertexPosition = gl.getAttribLocation(shaderProgram, "aVertexPosition");
const resolution = gl.getUniformLocation(shaderProgram, "iResolution");
const time = gl.getUniformLocation(shaderProgram, "iTime");
const resizeCanvas = () => {
const dpr = window.devicePixelRatio || 1;
const nextWidth = Math.floor(window.innerWidth * dpr);
const nextHeight = Math.floor(window.innerHeight * dpr);
canvas.width = nextWidth;
canvas.height = nextHeight;
gl.viewport(0, 0, nextWidth, nextHeight);
};
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
const startTime = Date.now();
let frameId = 0;
const render = () => {
const currentTime = (Date.now() - startTime) / 1000;
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(shaderProgram);
if (resolution) gl.uniform2f(resolution, canvas.width, canvas.height);
if (time) gl.uniform1f(time, currentTime);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.vertexAttribPointer(vertexPosition, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(vertexPosition);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
frameId = requestAnimationFrame(render);
};
frameId = requestAnimationFrame(render);
return () => {
cancelAnimationFrame(frameId);
window.removeEventListener("resize", resizeCanvas);
gl.deleteBuffer(positionBuffer);
gl.deleteProgram(shaderProgram);
};
}, []);
return (
<canvas
ref={canvasRef}
aria-hidden="true"
className={cn("fixed inset-0 -z-10 h-full w-full", className)}
style={{ opacity }}
/>
);
};
export default ShaderBackground;

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

91
components/ui/tabs.tsx Normal file
View File

@@ -0,0 +1,91 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@@ -1,32 +0,0 @@
# Antigravity Rules
This document defines the coding and behavior rules for the Antigravity agent.
## General Rules
- **Language**: All output, explanations, and commit messages MUST be in **Korean (한국어)**.
- **Tone**: Professional, helpful, and concise.
## Documentation Rules
### JSX Comments
- Mandatory use of section comments in JSX to delineate logical blocks.
- Format: `{/* ========== SECTION NAME ========== */}`
### JSDoc Tags
- **@see**: Mandatory for function/component documentation. Must include calling file, function/event name, and purpose.
- **@author**: Mandatory file-level tag. Use `@author jihoon87.lee`.
### Inline Comments
- High density of inline comments required for:
- State definitions
- Event handlers
- Complex logic in JSX
- Balance conciseness with clarity.
## Code Style
- Follow Project-specific linting and formatting rules.

View File

@@ -26,7 +26,6 @@ import { InlineSpinner } from "@/components/ui/loading-spinner";
*/ */
export default function LoginForm() { export default function LoginForm() {
// ========== 상태 관리 ========== // ========== 상태 관리 ==========
// 서버와 클라이언트 초기 렌더링을 일치시키기 위해 초기값은 고정
const [email, setEmail] = useState(() => { const [email, setEmail] = useState(() => {
if (typeof window === "undefined") return ""; if (typeof window === "undefined") return "";
return localStorage.getItem("auto-trade-saved-email") || ""; return localStorage.getItem("auto-trade-saved-email") || "";
@@ -37,11 +36,6 @@ export default function LoginForm() {
}); });
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// ========== 마운트 시 localStorage 데이터 복구 ==========
// localStorage는 클라이언트 전용 외부 시스템이므로 useEffect에서 동기화하는 것이 올바른 패턴
// React 공식 문서: "외부 시스템과 동기화"는 useEffect의 정확한 사용 사례
// useState lazy initializer + window guard handles localStorage safely
// ========== 폼 제출 핸들러 ========== // ========== 폼 제출 핸들러 ==========
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
@@ -83,7 +77,7 @@ export default function LoginForm() {
required required
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
className="h-11 transition-all duration-200" className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
/> />
</div> </div>
@@ -102,7 +96,7 @@ export default function LoginForm() {
minLength={8} minLength={8}
pattern="^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])(?=.*[!@#$%^&*]).{8,}$" pattern="^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])(?=.*[!@#$%^&*]).{8,}$"
title="비밀번호는 최소 8자 이상, 대문자, 소문자, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다." title="비밀번호는 최소 8자 이상, 대문자, 소문자, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다."
className="h-11 transition-all duration-200" className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
/> />
</div> </div>
@@ -121,10 +115,9 @@ export default function LoginForm() {
</Label> </Label>
</div> </div>
{/* 비밀번호 찾기 링크 */}
<Link <Link
href={AUTH_ROUTES.FORGOT_PASSWORD} href={AUTH_ROUTES.FORGOT_PASSWORD}
className="text-sm font-medium text-gray-700 hover:text-black dark:text-gray-300 dark:hover:text-white" className="text-sm font-medium text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
> >
</Link> </Link>
@@ -134,7 +127,7 @@ export default function LoginForm() {
<Button <Button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading}
className="h-11 w-full bg-linear-to-r from-gray-900 to-black font-semibold shadow-lg transition-all duration-200 hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white" className="h-11 w-full bg-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all duration-200 hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
size="lg" size="lg"
> >
{isLoading ? ( {isLoading ? (
@@ -148,11 +141,11 @@ export default function LoginForm() {
</Button> </Button>
{/* ========== 회원가입 링크 ========== */} {/* ========== 회원가입 링크 ========== */}
<p className="text-center text-sm text-gray-600 dark:text-gray-400"> <p className="text-center text-sm text-muted-foreground">
?{" "} ?{" "}
<Link <Link
href={AUTH_ROUTES.SIGNUP} href={AUTH_ROUTES.SIGNUP}
className="font-semibold text-gray-900 transition-colors hover:text-black dark:text-gray-100 dark:hover:text-white" className="font-semibold text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
> >
</Link> </Link>
@@ -162,7 +155,7 @@ export default function LoginForm() {
{/* ========== 소셜 로그인 구분선 ========== */} {/* ========== 소셜 로그인 구분선 ========== */}
<div className="relative"> <div className="relative">
<Separator className="my-6" /> <Separator className="my-6" />
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white px-3 text-xs font-medium text-gray-500 dark:bg-gray-900 dark:text-gray-400"> <span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white px-3 text-xs font-medium text-muted-foreground dark:bg-brand-950">
</span> </span>
</div> </div>
@@ -174,7 +167,7 @@ export default function LoginForm() {
<Button <Button
type="submit" type="submit"
variant="outline" variant="outline"
className="h-11 w-full border-gray-200 bg-white shadow-sm transition-all duration-200 hover:bg-gray-50 hover:shadow-md dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-750" className="h-11 w-full border-brand-200/50 bg-white shadow-sm transition-all duration-200 hover:bg-brand-50 hover:shadow-md dark:border-brand-800/50 dark:bg-brand-950/50 dark:hover:bg-brand-900/50"
> >
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24"> <svg className="mr-2 h-5 w-5" viewBox="0 0 24 24">
<path <path

View File

@@ -79,9 +79,9 @@ export default function ResetPasswordForm() {
autoComplete="new-password" autoComplete="new-password"
disabled={isLoading} disabled={isLoading}
{...register("password")} {...register("password")}
className="h-11 transition-all duration-200" className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
/> />
<p className="text-xs text-gray-500 dark:text-gray-400"> <p className="text-xs text-muted-foreground">
8 , /// 1 . 8 , /// 1 .
</p> </p>
{errors.password && ( {errors.password && (
@@ -102,7 +102,7 @@ export default function ResetPasswordForm() {
autoComplete="new-password" autoComplete="new-password"
disabled={isLoading} disabled={isLoading}
{...register("confirmPassword")} {...register("confirmPassword")}
className="h-11 transition-all duration-200" className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
/> />
{confirmPassword && {confirmPassword &&
password !== confirmPassword && password !== confirmPassword &&
@@ -114,7 +114,7 @@ export default function ResetPasswordForm() {
{confirmPassword && {confirmPassword &&
password === confirmPassword && password === confirmPassword &&
!errors.confirmPassword && ( !errors.confirmPassword && (
<p className="text-xs text-green-600 dark:text-green-400"> <p className="text-xs text-brand-600 dark:text-brand-400">
. .
</p> </p>
)} )}
@@ -128,7 +128,7 @@ export default function ResetPasswordForm() {
<Button <Button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading}
className="h-11 w-full bg-linear-to-r from-gray-900 to-black font-semibold text-white shadow-lg transition-all hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white" className="h-11 w-full bg-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
> >
{isLoading ? ( {isLoading ? (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">

View File

@@ -24,11 +24,19 @@ 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분 전) - 현재 미사용 (즉시 로그아웃)
// const WARNING_MS = 60 * 1000; // const WARNING_MS = 60 * 1000;
const SESSION_RELATED_STORAGE_KEYS = [
"session-storage",
"auth-storage",
"autotrade-kis-runtime-store",
...KIS_REMEMBER_LOCAL_STORAGE_KEYS,
] as const;
/** /**
* 세션 관리자 컴포넌트 * 세션 관리자 컴포넌트
* 사용자 활동을 감지하여 세션 연장 및 타임아웃 처리 * 사용자 활동을 감지하여 세션 연장 및 타임아웃 처리
@@ -51,6 +59,19 @@ export function SessionManager() {
const { setLastActive } = useSessionStore(); const { setLastActive } = useSessionStore();
/**
* @description 세션 만료 로그아웃 시 세션 관련 로컬 스토리지를 정리합니다.
* @see features/layout/components/user-menu.tsx 수동 로그아웃 경로에서도 동일한 키를 제거합니다.
*/
const clearSessionRelatedStorage = useCallback(() => {
if (typeof window === "undefined") return;
for (const key of SESSION_RELATED_STORAGE_KEYS) {
window.localStorage.removeItem(key);
window.sessionStorage.removeItem(key);
}
}, []);
/** /**
* 로그아웃 처리 핸들러 * 로그아웃 처리 핸들러
* @see session-timer.tsx - 타임아웃 시 동일한 로직 필요 가능성 있음 * @see session-timer.tsx - 타임아웃 시 동일한 로직 필요 가능성 있음
@@ -64,11 +85,12 @@ export function SessionManager() {
// [Step 3] 로컬 스토어 및 세션 정보 초기화 // [Step 3] 로컬 스토어 및 세션 정보 초기화
useSessionStore.persist.clearStorage(); useSessionStore.persist.clearStorage();
clearSessionRelatedStorage();
// [Step 4] 로그인 페이지로 리다이렉트 및 메시지 표시 // [Step 4] 로그인 페이지로 리다이렉트 및 메시지 표시
router.push("/login?message=세션이 만료되었습니다. 다시 로그인해주세요."); router.push("/login?message=세션이 만료되었습니다. 다시 로그인해주세요.");
router.refresh(); router.refresh();
}, [router]); }, [clearSessionRelatedStorage, router]);
useEffect(() => { useEffect(() => {
if (isAuthPage) return; if (isAuthPage) return;
@@ -79,6 +101,10 @@ export function SessionManager() {
if (showWarning) setShowWarning(false); if (showWarning) setShowWarning(false);
}; };
// [Step 0] 인증 페이지에서 메인 페이지로 진입한 직후를 "활동"으로 간주해
// 이전 세션 잔여 시간(예: 00:00)으로 즉시 로그아웃되는 현상을 방지합니다.
updateLastActive();
// [Step 1] 사용자 활동 이벤트 감지 (마우스, 키보드, 스크롤, 터치) // [Step 1] 사용자 활동 이벤트 감지 (마우스, 키보드, 스크롤, 터치)
const events = ["mousedown", "keydown", "scroll", "touchstart"]; const events = ["mousedown", "keydown", "scroll", "touchstart"];
const handleActivity = () => updateLastActive(); const handleActivity = () => updateLastActive();

View File

@@ -13,6 +13,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
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 { cn } from "@/lib/utils";
/** /**
* 세션 만료 타이머 컴포넌트 * 세션 만료 타이머 컴포넌트
@@ -21,7 +22,12 @@ import { SESSION_TIMEOUT_MS } from "@/features/auth/constants";
* @remarks 1초마다 리렌더링 발생 * @remarks 1초마다 리렌더링 발생
* @see header.tsx - 로그인 상태일 때 헤더에 표시 * @see header.tsx - 로그인 상태일 때 헤더에 표시
*/ */
export function SessionTimer() { interface SessionTimerProps {
/** 셰이더 배경 위에서 가독성을 높이는 투명 모드 */
blendWithBackground?: boolean;
}
export function SessionTimer({ blendWithBackground = false }: SessionTimerProps) {
const lastActive = useSessionStore((state) => state.lastActive); const lastActive = useSessionStore((state) => state.lastActive);
// [State] 남은 시간 (밀리초) // [State] 남은 시간 (밀리초)
@@ -54,11 +60,14 @@ export function SessionTimer() {
return ( return (
<div <div
className={`text-sm font-medium tabular-nums px-3 py-1.5 rounded-md border bg-background/50 backdrop-blur-md hidden md:block transition-colors ${ className={cn(
"hidden rounded-full border px-3 py-1.5 text-sm font-medium tabular-nums backdrop-blur-md transition-colors md:block",
isUrgent isUrgent
? "text-red-500 border-red-200 bg-red-50/50 dark:bg-red-900/20 dark:border-red-800" ? "border-red-200 bg-red-50/50 text-red-500 dark:border-red-800 dark:bg-red-900/20"
: "text-muted-foreground border-border/40" : blendWithBackground
}`} ? "border-white/30 bg-black/45 text-white shadow-sm shadow-black/40"
: "border-border/40 bg-background/50 text-muted-foreground",
)}
> >
{/* ========== 라벨 ========== */} {/* ========== 라벨 ========== */}
<span className="mr-2"> </span> <span className="mr-2"> </span>

Some files were not shown because too many files have changed in this diff Show More