Compare commits
34 Commits
0436ddf41c
...
features/d
| Author | SHA1 | Date | |
|---|---|---|---|
| e51d767878 | |||
| 406af7408a | |||
| 4c52d6d82f | |||
| 076f27a12a | |||
| f875e338eb | |||
| a16af8ad7d | |||
| 19ebb1c6ea | |||
| 276ef09d89 | |||
| b73867c65d | |||
| 7c194d7452 | |||
| 1ac907cd27 | |||
| 12feeb2775 | |||
| 434a814246 | |||
| 8f1d75b4d5 | |||
| 3cea3e66d0 | |||
| f650d51f68 | |||
| 95291e6922 | |||
| def87bd47a | |||
| 89bad1d141 | |||
| e5a518b211 | |||
| ca01f33d71 | |||
| 851a2acd69 | |||
| 35916430b7 | |||
| ac7effc939 | |||
| d2c66a639d | |||
| d31e3f9bc9 | |||
| f1e340d9f1 | |||
| ded49b5e2a | |||
| 2d34d70948 | |||
| 9c967af9c1 | |||
| aae7000807 | |||
| 22ced3a6ae | |||
| edcfa2a837 | |||
| 4b41267ea5 |
@@ -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 활용
|
|
||||||
- 변경 사항은 반드시 로컬에서 검증 후 완료 보고
|
|
||||||
- 에러 발생 시 근본 원인 파악 및 해결
|
|
||||||
@@ -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`
|
|
||||||
@@ -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
|
|
||||||
64
.agents/skills/dev-auto-pipeline/SKILL.md
Normal file
64
.agents/skills/dev-auto-pipeline/SKILL.md
Normal 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 -> 결과 반영
|
||||||
|
- 각 단계는 파일/라인 링크 포함
|
||||||
|
```
|
||||||
6
.agents/skills/dev-auto-pipeline/agents/openai.yaml
Normal file
6
.agents/skills/dev-auto-pipeline/agents/openai.yaml
Normal 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
|
||||||
123
.agents/skills/dev-mcp-implementation/SKILL.md
Normal file
123
.agents/skills/dev-mcp-implementation/SKILL.md
Normal 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는 문서 근거 없이 단정하지 않는다.
|
||||||
|
- 구현 단계에서 성능에 큰 악영향이 보이면 즉시 메모(기록)하고 다음 단계에서 정리한다.
|
||||||
23
.agents/skills/dev-mcp-implementation/agents/openai.yaml
Normal file
23
.agents/skills/dev-mcp-implementation/agents/openai.yaml
Normal 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
|
||||||
58
.agents/skills/dev-plan-completion-checker/SKILL.md
Normal file
58
.agents/skills/dev-plan-completion-checker/SKILL.md
Normal 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. ...
|
||||||
|
|
||||||
|
[최종 판정]
|
||||||
|
- 배포 가능/보완 필요
|
||||||
|
```
|
||||||
@@ -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
|
||||||
153
.agents/skills/dev-plan-writer/SKILL.md
Normal file
153
.agents/skills/dev-plan-writer/SKILL.md
Normal 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-..-..: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 규칙
|
||||||
|
|
||||||
|
- 계획 승인 전에 실제 구현 코드를 대량 작성하지 않는다.
|
||||||
|
- 파일 삭제는 반드시 필요성/대체 경로를 확인한 뒤 진행한다.
|
||||||
|
- 동작 변경과 리팩토링을 섞지 않는다.
|
||||||
17
.agents/skills/dev-plan-writer/agents/openai.yaml
Normal file
17
.agents/skills/dev-plan-writer/agents/openai.yaml
Normal 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
|
||||||
212
.agents/skills/dev-refactor-polish/SKILL.md
Normal file
212
.agents/skills/dev-refactor-polish/SKILL.md
Normal 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 개선을 했으면 왜 개선했는지 한 줄 근거를 남긴다.
|
||||||
14
.agents/skills/dev-refactor-polish/agents/openai.yaml
Normal file
14
.agents/skills/dev-refactor-polish/agents/openai.yaml
Normal 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
|
||||||
91
.agents/skills/dev-test-gate/SKILL.md
Normal file
91
.agents/skills/dev-test-gate/SKILL.md
Normal 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 결과
|
||||||
|
- 생략/실패 사유 및 대체 검증
|
||||||
14
.agents/skills/dev-test-gate/agents/openai.yaml
Normal file
14
.agents/skills/dev-test-gate/agents/openai.yaml
Normal 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
|
||||||
14
.agents/skills/nextjs-app-router-patterns/agents/openai.yaml
Normal file
14
.agents/skills/nextjs-app-router-patterns/agents/openai.yaml
Normal 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
|
||||||
40
.env.example
40
.env.example
@@ -1,6 +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
|
||||||
|
|
||||||
|
# 자동매매/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
6
.gemini/settings.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"approvalMode": "auto_edit",
|
||||||
|
"allowed": ["run_shell_command"]
|
||||||
|
}
|
||||||
|
}
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -119,8 +119,14 @@ storybook-static/
|
|||||||
*.local
|
*.local
|
||||||
.cache/
|
.cache/
|
||||||
node_modules
|
node_modules
|
||||||
|
.tmp/
|
||||||
|
|
||||||
# ========================================
|
# ========================================
|
||||||
# Custom
|
# Custom
|
||||||
# ========================================
|
# ========================================
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Documentation (문서)
|
||||||
|
# ========================================
|
||||||
|
docs/
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"chatgpt.openOnStartup": false
|
||||||
|
}
|
||||||
25
AGENTS.md
Normal file
25
AGENTS.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# AGENTS.md (auto-trade)
|
||||||
|
|
||||||
|
## 운영 원칙
|
||||||
|
|
||||||
|
- 중복 규칙은 `AGENTS.md`에 두지 않고 각 스킬(`.agents/skills/*/SKILL.md`)에서 관리한다.
|
||||||
|
- 개발 작업은 스킬 기반으로 수행한다.
|
||||||
|
|
||||||
|
## 스킬 호출 규칙
|
||||||
|
|
||||||
|
- 개발 요청 키워드(`개발해줘`, `구현해줘`, `기능 추가`, `버그 수정`, `리팩토링`, `개선해줘`, `고쳐줘`, `최적화해줘`, `만들어줘`)가 들어오면 `dev-auto-pipeline` 스킬을 우선 사용한다.
|
||||||
|
- 파이프라인 단계 스킬은 아래 순서로 사용한다.
|
||||||
|
1. `dev-plan-writer`
|
||||||
|
2. `dev-mcp-implementation`
|
||||||
|
3. `dev-refactor-polish`
|
||||||
|
4. `dev-test-gate`
|
||||||
|
5. `dev-plan-completion-checker`
|
||||||
|
- 단순 설명/문서 요약/잡담 요청에는 파이프라인 스킬을 강제하지 않는다.
|
||||||
|
|
||||||
|
## 설명 방식 규칙
|
||||||
|
|
||||||
|
- 사용자 설명은 어려운 용어보다 쉬운 한국어를 우선 사용한다.
|
||||||
|
- 기술 용어를 써야 할 때는 바로 아래 줄에 쉬운 말로 다시 풀어쓴다.
|
||||||
|
- 데이터 흐름 설명은 항상 `입력 -> 처리 -> 결과` 순서의 짧은 단계로 말한다.
|
||||||
|
- 사용자가 헷갈린 상황에서는 추상 설명보다 "지금 화면에서 확인할 것"을 먼저 안내한다.
|
||||||
|
- 요청/응답 설명 시에는 핵심 필드 3~5개만 먼저 보여주고, 필요 시 상세를 추가한다.
|
||||||
164
README.md
164
README.md
@@ -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는 운영 환경에 맞게 보완이 필요합니다.
|
||||||
|
|||||||
87
app/(auth)/forgot-password/page.tsx
Normal file
87
app/(auth)/forgot-password/page.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import FormMessage from "@/components/form-message";
|
||||||
|
import { requestPasswordReset } from "@/features/auth/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AUTH_ROUTES } from "@/features/auth/constants";
|
||||||
|
import { Mail } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [비밀번호 찾기 페이지]
|
||||||
|
*
|
||||||
|
* 사용자가 비밀번호를 잊어버렸을 때 재설정 링크를 요청하는 페이지입니다.
|
||||||
|
* - 이메일 입력 폼 제공
|
||||||
|
* - 서버 액션(requestPasswordReset)과 연동
|
||||||
|
*/
|
||||||
|
export default async function ForgotPasswordPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ message?: string }>;
|
||||||
|
}) {
|
||||||
|
const { message } = await searchParams;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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} />}
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<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">
|
||||||
|
<Mail className="h-7 w-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||||
|
비밀번호 재설정
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
가입한 이메일 주소를 입력하시면 비밀번호 재설정 링크를 보내드립니다.
|
||||||
|
<br />
|
||||||
|
메일을 받지 못하셨다면 스팸함을 확인해 주세요.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<form className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email" className="text-sm font-medium">
|
||||||
|
이메일
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="name@example.com"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
formAction={requestPasswordReset}
|
||||||
|
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>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<Link
|
||||||
|
href={AUTH_ROUTES.LOGIN}
|
||||||
|
className="text-sm font-medium text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||||
|
>
|
||||||
|
로그인 페이지로 돌아가기
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
app/(auth)/layout.tsx
Normal file
34
app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Header } from "@/features/layout/components/header";
|
||||||
|
import { createClient } from "@/utils/supabase/server";
|
||||||
|
|
||||||
|
export default async function AuthLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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} />
|
||||||
|
|
||||||
|
{/* ========== 배경 그라디언트 레이어 ========== */}
|
||||||
|
<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-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-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-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">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
app/(auth)/login/page.tsx
Normal file
51
app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import FormMessage from "@/components/form-message";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import LoginForm from "@/features/auth/components/login-form";
|
||||||
|
import { LogIn } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [로그인 페이지 컴포넌트]
|
||||||
|
*
|
||||||
|
* 브랜드 컬러 기반 글래스모피즘 카드 디자인
|
||||||
|
* - 보라색 그라디언트 아이콘 배지
|
||||||
|
* - shadcn/ui 컴포넌트로 일관된 디자인 시스템 유지
|
||||||
|
*
|
||||||
|
* @param searchParams - URL 쿼리 파라미터 (에러 메시지 전달용)
|
||||||
|
*/
|
||||||
|
export default async function LoginPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ message: string }>;
|
||||||
|
}) {
|
||||||
|
const { message } = await searchParams;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
|
<FormMessage message={message} />
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<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">
|
||||||
|
<LogIn className="h-7 w-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||||
|
환영합니다!
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
서비스 이용을 위해 로그인해 주세요.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<LoginForm />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
app/(auth)/reset-password/page.tsx
Normal file
62
app/(auth)/reset-password/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import FormMessage from "@/components/form-message";
|
||||||
|
import ResetPasswordForm from "@/features/auth/components/reset-password-form";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { createClient } from "@/utils/supabase/server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { KeyRound } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [비밀번호 재설정 페이지]
|
||||||
|
*
|
||||||
|
* 이메일 링크를 타고 들어온 사용자가 새 비밀번호를 설정하는 페이지입니다.
|
||||||
|
* - URL에 포함된 토큰 검증은 Middleware 및 Auth Confirm Route에서 선행됩니다.
|
||||||
|
* - 유효한 세션(Recovery Mode)이 없으면 로그인 페이지로 리다이렉트됩니다.
|
||||||
|
*/
|
||||||
|
export default async function ResetPasswordPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ message?: string }>;
|
||||||
|
}) {
|
||||||
|
const params = await searchParams;
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect(`/login`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { message } = params;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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} />}
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<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">
|
||||||
|
<KeyRound className="h-7 w-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||||
|
비밀번호 재설정
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
새 비밀번호를 입력해 주세요.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<ResetPasswordForm />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
app/(auth)/signup/page.tsx
Normal file
54
app/(auth)/signup/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { AUTH_ROUTES } from "@/features/auth/constants";
|
||||||
|
import FormMessage from "@/components/form-message";
|
||||||
|
import SignupForm from "@/features/auth/components/signup-form";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { UserPlus } from "lucide-react";
|
||||||
|
|
||||||
|
export default async function SignupPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ message: string }>;
|
||||||
|
}) {
|
||||||
|
const { message } = await searchParams;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
|
<FormMessage message={message} />
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<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">
|
||||||
|
<UserPlus className="h-7 w-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||||
|
회원가입
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
몇 가지 정보만 입력하면 바로 시작할 수 있습니다.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<SignupForm />
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
이미 계정이 있으신가요?{" "}
|
||||||
|
<Link
|
||||||
|
href={AUTH_ROUTES.LOGIN}
|
||||||
|
className="font-semibold text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||||
|
>
|
||||||
|
로그인 하러 가기
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
213
app/(home)/page.tsx
Normal file
213
app/(home)/page.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* @file app/(home)/page.tsx
|
||||||
|
* @description 서비스 메인 랜딩 페이지(Server Component)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowRight, Sparkles } from "lucide-react";
|
||||||
|
import { Header } from "@/features/layout/components/header";
|
||||||
|
import { AUTH_ROUTES } from "@/features/auth/constants";
|
||||||
|
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 랜딩 UI
|
||||||
|
*/
|
||||||
|
export default async function HomePage() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
const primaryCtaHref = user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.SIGNUP;
|
||||||
|
const primaryCtaLabel = user ? "내 전략 시작하기" : "무료로 시작하기";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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} blendWithBackground={true} />
|
||||||
|
|
||||||
|
<main className="relative isolate flex-1">
|
||||||
|
{/* ========== BACKGROUND ========== */}
|
||||||
|
<ShaderBackground opacity={0.6} className="-z-20" />
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-0 -z-10 bg-black/60"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ========== HERO SECTION ========== */}
|
||||||
|
<section className="container mx-auto max-w-5xl px-4 pt-32 md:pt-48">
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
<span className="inline-flex animate-in fade-in-0 slide-in-from-top-2 items-center gap-2 rounded-full border border-brand-400/30 bg-white/5 px-4 py-1.5 text-xs font-medium tracking-wide text-brand-200 backdrop-blur-md duration-1000">
|
||||||
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
처음 하는 자동매매도 쉽게, JOORIN-E
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<h1 className="mt-8 animate-in slide-in-from-bottom-4 text-5xl font-black tracking-tight text-white duration-1000 md:text-8xl">
|
||||||
|
복잡한 차트 대신
|
||||||
|
<br />
|
||||||
|
<span className="bg-linear-to-b from-brand-300 via-brand-200 to-brand-500 bg-clip-text text-transparent">
|
||||||
|
쉬운 자동매매로 시작하세요.
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-8 max-w-2xl animate-in slide-in-from-bottom-4 text-sm leading-relaxed text-white/60 duration-1000 md:text-xl">
|
||||||
|
감으로 사고파는 불안한 투자, 이제 줄여보세요.
|
||||||
|
<br className="hidden md:block" />
|
||||||
|
예산과 손실선을 먼저 지키는 방식으로, 주식을 더 편하게 도와드립니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-12 flex animate-in slide-in-from-bottom-6 flex-col gap-4 duration-1000 sm:flex-row">
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
size="lg"
|
||||||
|
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={primaryCtaHref}>
|
||||||
|
{primaryCtaLabel}
|
||||||
|
<ArrowRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ========== BRAND TONE SECTION (움직이는 글자) ========== */}
|
||||||
|
<section className="container mx-auto max-w-5xl px-4 py-24 md:py-40">
|
||||||
|
<AnimatedBrandTone />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ========== SIMPLE STEPS SECTION ========== */}
|
||||||
|
<section className="container mx-auto max-w-5xl px-4 py-24">
|
||||||
|
<div className="flex flex-col items-center gap-16 md:flex-row md:items-start">
|
||||||
|
<div className="flex-1 text-center md:text-left">
|
||||||
|
<h2 className="text-3xl font-black md:text-5xl">
|
||||||
|
주식이 처음이어도
|
||||||
|
<br />
|
||||||
|
<span className="text-brand-300">3단계면 준비 끝.</span>
|
||||||
|
</h2>
|
||||||
|
<p className="mt-6 text-sm leading-relaxed text-white/50 md:text-lg">
|
||||||
|
앱키 연결 -> 투자금/손실선 설정 -> 시작 버튼.
|
||||||
|
<br />
|
||||||
|
어려운 용어 없이, 필요한 것만 빠르게 설정해보세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-2 grid w-full gap-4 md:grid-cols-1">
|
||||||
|
{START_STEPS.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.step}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* 보안 안심 문구 (사용자 요청 반영) */}
|
||||||
|
<div className="mt-16 flex flex-col items-center justify-center text-center animate-in slide-in-from-bottom-6 duration-1000 delay-300">
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ========== FINAL CTA SECTION ========== */}
|
||||||
|
<section className="container mx-auto max-w-5xl px-4 py-32">
|
||||||
|
<div className="relative overflow-hidden rounded-[2.5rem] border border-brand-500/20 bg-linear-to-b from-brand-500/10 to-transparent p-12 text-center md:p-24">
|
||||||
|
<h2 className="text-3xl font-black md:text-6xl">
|
||||||
|
감으로 매매하던 습관에서
|
||||||
|
<br />
|
||||||
|
오늘부터 규칙 매매로 바꿔보세요.
|
||||||
|
</h2>
|
||||||
|
<div className="mt-12 flex justify-center">
|
||||||
|
<Button
|
||||||
|
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>
|
||||||
|
|
||||||
|
<p className="mt-8 text-sm text-white/30">
|
||||||
|
© 2026 POPUP STUDIO. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
app/(main)/dashboard/page.tsx
Normal file
25
app/(main)/dashboard/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* @file app/(main)/dashboard/page.tsx
|
||||||
|
* @description 로그인 사용자 전용 대시보드 페이지(Server Component)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { DashboardContainer } from "@/features/dashboard/components/DashboardContainer";
|
||||||
|
import { createClient } from "@/utils/supabase/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 페이지
|
||||||
|
* @returns DashboardContainer UI
|
||||||
|
* @see features/dashboard/components/DashboardContainer.tsx 대시보드 상태 헤더/지수/보유종목 UI를 제공합니다.
|
||||||
|
*/
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
// 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
|
||||||
|
return <DashboardContainer />;
|
||||||
|
}
|
||||||
25
app/(main)/layout.tsx
Normal file
25
app/(main)/layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Header } from "@/features/layout/components/header";
|
||||||
|
import { MobileBottomNav, Sidebar } from "@/features/layout/components/sidebar";
|
||||||
|
import { createClient } from "@/utils/supabase/server";
|
||||||
|
|
||||||
|
export default async function MainLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
|
<Header user={user} />
|
||||||
|
<div className="flex flex-1 pt-16">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="min-w-0 flex-1 pb-20 md:pb-0">{children}</main>
|
||||||
|
</div>
|
||||||
|
<MobileBottomNav />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
app/(main)/settings/page.tsx
Normal file
26
app/(main)/settings/page.tsx
Normal 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
26
app/(main)/trade/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
|
|
||||||
227
app/api/autotrade/_shared.ts
Normal file
227
app/api/autotrade/_shared.ts
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { hasKisConfig } from "@/lib/kis/config";
|
||||||
|
import {
|
||||||
|
readKisAccountParts,
|
||||||
|
readKisCredentialsFromHeaders,
|
||||||
|
} from "@/app/api/kis/domestic/_shared";
|
||||||
|
import type {
|
||||||
|
AutotradeSessionInfo,
|
||||||
|
AutotradeStopReason,
|
||||||
|
} from "@/features/autotrade/types/autotrade.types";
|
||||||
|
import { createClient } from "@/utils/supabase/server";
|
||||||
|
|
||||||
|
export const AUTOTRADE_DEV_BYPASS_HEADER = "x-autotrade-dev-bypass";
|
||||||
|
export const AUTOTRADE_WORKER_TOKEN_HEADER = "x-autotrade-worker-token";
|
||||||
|
|
||||||
|
export const AUTOTRADE_API_ERROR_CODE = {
|
||||||
|
AUTH_REQUIRED: "AUTOTRADE_AUTH_REQUIRED",
|
||||||
|
INVALID_REQUEST: "AUTOTRADE_INVALID_REQUEST",
|
||||||
|
CREDENTIAL_REQUIRED: "AUTOTRADE_CREDENTIAL_REQUIRED",
|
||||||
|
SESSION_NOT_FOUND: "AUTOTRADE_SESSION_NOT_FOUND",
|
||||||
|
CONFLICT: "AUTOTRADE_CONFLICT",
|
||||||
|
INTERNAL: "AUTOTRADE_INTERNAL",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type AutotradeApiErrorCode =
|
||||||
|
(typeof AUTOTRADE_API_ERROR_CODE)[keyof typeof AUTOTRADE_API_ERROR_CODE];
|
||||||
|
|
||||||
|
export interface AutotradeSessionRecord extends AutotradeSessionInfo {
|
||||||
|
userId: string;
|
||||||
|
strategySummary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var __autotradeSessionMap: Map<string, AutotradeSessionRecord> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionMap() {
|
||||||
|
if (!globalThis.__autotradeSessionMap) {
|
||||||
|
globalThis.__autotradeSessionMap = new Map<string, AutotradeSessionRecord>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return globalThis.__autotradeSessionMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function createAutotradeErrorResponse(options: {
|
||||||
|
status: number;
|
||||||
|
code: AutotradeApiErrorCode;
|
||||||
|
message: string;
|
||||||
|
extra?: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
errorCode: options.code,
|
||||||
|
message: options.message,
|
||||||
|
...(options.extra ?? {}),
|
||||||
|
},
|
||||||
|
{ status: options.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAutotradeUserId(headers?: Headers) {
|
||||||
|
if (isAutotradeDevBypass(headers)) {
|
||||||
|
return "dev-autotrade-user";
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
error,
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (error || !user) return null;
|
||||||
|
return user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readJsonBody(request: Request) {
|
||||||
|
const text = await request.text();
|
||||||
|
if (!text.trim()) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as unknown;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAutotradeKisRuntimeHeaders(headers: Headers) {
|
||||||
|
if (isAutotradeDevBypass(headers)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = readKisCredentialsFromHeaders(headers);
|
||||||
|
const account = readKisAccountParts(headers);
|
||||||
|
|
||||||
|
return Boolean(hasKisConfig(credentials) && account);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertAutotradeSession(record: AutotradeSessionRecord) {
|
||||||
|
const map = getSessionMap();
|
||||||
|
map.set(record.userId, record);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAutotradeSession(userId: string) {
|
||||||
|
const map = getSessionMap();
|
||||||
|
const record = map.get(userId) ?? null;
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
if (record.runtimeState === "RUNNING" && isHeartbeatExpired(record.lastHeartbeatAt)) {
|
||||||
|
const stoppedRecord = {
|
||||||
|
...record,
|
||||||
|
runtimeState: "STOPPED" as const,
|
||||||
|
stopReason: "heartbeat_timeout" as const,
|
||||||
|
endedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
map.set(userId, stoppedRecord);
|
||||||
|
return stoppedRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAutotradeSessions() {
|
||||||
|
return Array.from(getSessionMap().values()).sort((a, b) =>
|
||||||
|
b.startedAt.localeCompare(a.startedAt),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopAutotradeSession(userId: string, reason: AutotradeStopReason) {
|
||||||
|
const map = getSessionMap();
|
||||||
|
const record = map.get(userId);
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
const stoppedRecord: AutotradeSessionRecord = {
|
||||||
|
...record,
|
||||||
|
runtimeState: "STOPPED",
|
||||||
|
stopReason: reason,
|
||||||
|
endedAt: new Date().toISOString(),
|
||||||
|
lastHeartbeatAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
map.set(userId, stoppedRecord);
|
||||||
|
return stoppedRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sweepExpiredAutotradeSessions() {
|
||||||
|
const map = getSessionMap();
|
||||||
|
let expiredCount = 0;
|
||||||
|
|
||||||
|
for (const [userId, record] of map.entries()) {
|
||||||
|
if (record.runtimeState !== "RUNNING") continue;
|
||||||
|
if (!isHeartbeatExpired(record.lastHeartbeatAt)) continue;
|
||||||
|
|
||||||
|
const stoppedRecord: AutotradeSessionRecord = {
|
||||||
|
...record,
|
||||||
|
runtimeState: "STOPPED",
|
||||||
|
stopReason: "heartbeat_timeout",
|
||||||
|
endedAt: new Date().toISOString(),
|
||||||
|
lastHeartbeatAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
map.set(userId, stoppedRecord);
|
||||||
|
expiredCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSessionCount: map.size,
|
||||||
|
expiredCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAutotradeHeartbeatTtlSec() {
|
||||||
|
const parsed = Number.parseInt(process.env.AUTOTRADE_HEARTBEAT_TTL_SEC ?? "90", 10);
|
||||||
|
if (!Number.isFinite(parsed)) return 90;
|
||||||
|
return Math.min(300, Math.max(30, parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHeartbeatExpired(lastHeartbeatAt: string) {
|
||||||
|
const lastHeartbeatMs = new Date(lastHeartbeatAt).getTime();
|
||||||
|
if (!Number.isFinite(lastHeartbeatMs)) return true;
|
||||||
|
|
||||||
|
return Date.now() - lastHeartbeatMs > getAutotradeHeartbeatTtlSec() * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeAutotradeError(error: unknown, fallback: string) {
|
||||||
|
const message = error instanceof Error ? error.message : fallback;
|
||||||
|
return maskSensitiveTokens(message) || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskSensitiveTokens(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/([A-Za-z0-9]{4})[A-Za-z0-9]{8,}([A-Za-z0-9]{4})/g, "$1********$2")
|
||||||
|
.replace(/(x-kis-app-secret\s*[:=]\s*)([^\s]+)/gi, "$1********")
|
||||||
|
.replace(/(x-kis-app-key\s*[:=]\s*)([^\s]+)/gi, "$1********");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAutotradeWorkerAuthorized(headers: Headers) {
|
||||||
|
const providedToken = headers.get(AUTOTRADE_WORKER_TOKEN_HEADER)?.trim();
|
||||||
|
if (!providedToken) return false;
|
||||||
|
|
||||||
|
const expectedToken = process.env.AUTOTRADE_WORKER_TOKEN?.trim();
|
||||||
|
if (expectedToken) {
|
||||||
|
return providedToken === expectedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 운영 환경에서는 토큰 미설정 상태를 허용하지 않습니다.
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return providedToken === "autotrade-worker-local";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAutotradeDevBypass(headers?: Headers) {
|
||||||
|
if (!headers || process.env.NODE_ENV === "production") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providedToken = headers.get(AUTOTRADE_DEV_BYPASS_HEADER)?.trim();
|
||||||
|
if (!providedToken) return false;
|
||||||
|
|
||||||
|
const expectedToken =
|
||||||
|
process.env.AUTOTRADE_DEV_BYPASS_TOKEN?.trim() || "autotrade-dev-bypass";
|
||||||
|
return providedToken === expectedToken;
|
||||||
|
}
|
||||||
25
app/api/autotrade/sessions/active/route.ts
Normal file
25
app/api/autotrade/sessions/active/route.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
AUTOTRADE_API_ERROR_CODE,
|
||||||
|
createAutotradeErrorResponse,
|
||||||
|
getAutotradeSession,
|
||||||
|
getAutotradeUserId,
|
||||||
|
} from "@/app/api/autotrade/_shared";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const userId = await getAutotradeUserId(request.headers);
|
||||||
|
if (!userId) {
|
||||||
|
return createAutotradeErrorResponse({
|
||||||
|
status: 401,
|
||||||
|
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
|
||||||
|
message: "로그인이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = getAutotradeSession(userId);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
session: session && session.runtimeState === "RUNNING" ? session : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
73
app/api/autotrade/sessions/heartbeat/route.ts
Normal file
73
app/api/autotrade/sessions/heartbeat/route.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
AUTOTRADE_API_ERROR_CODE,
|
||||||
|
createAutotradeErrorResponse,
|
||||||
|
getAutotradeSession,
|
||||||
|
getAutotradeUserId,
|
||||||
|
readJsonBody,
|
||||||
|
sanitizeAutotradeError,
|
||||||
|
upsertAutotradeSession,
|
||||||
|
} from "@/app/api/autotrade/_shared";
|
||||||
|
|
||||||
|
const heartbeatRequestSchema = z.object({
|
||||||
|
sessionId: z.string().uuid(),
|
||||||
|
leaderTabId: z.string().trim().min(1).max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const userId = await getAutotradeUserId(request.headers);
|
||||||
|
if (!userId) {
|
||||||
|
return createAutotradeErrorResponse({
|
||||||
|
status: 401,
|
||||||
|
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
|
||||||
|
message: "로그인이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = await readJsonBody(request);
|
||||||
|
const parsed = heartbeatRequestSchema.safeParse(rawBody);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return createAutotradeErrorResponse({
|
||||||
|
status: 400,
|
||||||
|
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
|
||||||
|
message: parsed.error.issues[0]?.message ?? "heartbeat 요청값이 올바르지 않습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = getAutotradeSession(userId);
|
||||||
|
if (!session || session.runtimeState !== "RUNNING") {
|
||||||
|
return createAutotradeErrorResponse({
|
||||||
|
status: 404,
|
||||||
|
code: AUTOTRADE_API_ERROR_CODE.SESSION_NOT_FOUND,
|
||||||
|
message: "실행 중인 자동매매 세션이 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.sessionId !== parsed.data.sessionId) {
|
||||||
|
return createAutotradeErrorResponse({
|
||||||
|
status: 409,
|
||||||
|
code: AUTOTRADE_API_ERROR_CODE.CONFLICT,
|
||||||
|
message: "세션 식별자가 일치하지 않습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = upsertAutotradeSession({
|
||||||
|
...session,
|
||||||
|
lastHeartbeatAt: new Date().toISOString(),
|
||||||
|
leaderTabId: parsed.data.leaderTabId ?? session.leaderTabId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
session: updated,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return createAutotradeErrorResponse({
|
||||||
|
status: 500,
|
||||||
|
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
|
||||||
|
message: sanitizeAutotradeError(error, "heartbeat 처리 중 오류가 발생했습니다."),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
77
app/api/autotrade/sessions/start/route.ts
Normal file
77
app/api/autotrade/sessions/start/route.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
AUTOTRADE_API_ERROR_CODE,
|
||||||
|
createAutotradeErrorResponse,
|
||||||
|
getAutotradeUserId,
|
||||||
|
hasAutotradeKisRuntimeHeaders,
|
||||||
|
readJsonBody,
|
||||||
|
sanitizeAutotradeError,
|
||||||
|
upsertAutotradeSession,
|
||||||
|
} from "@/app/api/autotrade/_shared";
|
||||||
|
|
||||||
|
const startRequestSchema = z.object({
|
||||||
|
symbol: z.string().trim().regex(/^\d{6}$/),
|
||||||
|
leaderTabId: z.string().trim().min(1).max(100),
|
||||||
|
effectiveAllocationAmount: z.number().int().positive(),
|
||||||
|
effectiveDailyLossLimit: z.number().int().positive(),
|
||||||
|
strategySummary: z.string().trim().min(1).max(320),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const userId = await getAutotradeUserId(request.headers);
|
||||||
|
if (!userId) {
|
||||||
|
return createAutotradeErrorResponse({
|
||||||
|
status: 401,
|
||||||
|
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
|
||||||
|
message: "로그인이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAutotradeKisRuntimeHeaders(request.headers)) {
|
||||||
|
return createAutotradeErrorResponse({
|
||||||
|
status: 400,
|
||||||
|
code: AUTOTRADE_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||||
|
message: "자동매매 시작에는 KIS 인증 헤더가 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = await readJsonBody(request);
|
||||||
|
const parsed = startRequestSchema.safeParse(rawBody);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return createAutotradeErrorResponse({
|
||||||
|
status: 400,
|
||||||
|
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
|
||||||
|
message: parsed.error.issues[0]?.message ?? "세션 시작 입력값이 올바르지 않습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const session = upsertAutotradeSession({
|
||||||
|
userId,
|
||||||
|
sessionId: crypto.randomUUID(),
|
||||||
|
symbol: parsed.data.symbol,
|
||||||
|
runtimeState: "RUNNING",
|
||||||
|
leaderTabId: parsed.data.leaderTabId,
|
||||||
|
startedAt: now,
|
||||||
|
lastHeartbeatAt: now,
|
||||||
|
endedAt: null,
|
||||||
|
stopReason: null,
|
||||||
|
effectiveAllocationAmount: parsed.data.effectiveAllocationAmount,
|
||||||
|
effectiveDailyLossLimit: parsed.data.effectiveDailyLossLimit,
|
||||||
|
strategySummary: parsed.data.strategySummary,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
session,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return createAutotradeErrorResponse({
|
||||||
|
status: 500,
|
||||||
|
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
|
||||||
|
message: sanitizeAutotradeError(error, "자동매매 세션 시작 중 오류가 발생했습니다."),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
78
app/api/autotrade/sessions/stop/route.ts
Normal file
78
app/api/autotrade/sessions/stop/route.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
AUTOTRADE_API_ERROR_CODE,
|
||||||
|
createAutotradeErrorResponse,
|
||||||
|
getAutotradeSession,
|
||||||
|
getAutotradeUserId,
|
||||||
|
readJsonBody,
|
||||||
|
sanitizeAutotradeError,
|
||||||
|
stopAutotradeSession,
|
||||||
|
} from "@/app/api/autotrade/_shared";
|
||||||
|
import type { AutotradeStopReason } from "@/features/autotrade/types/autotrade.types";
|
||||||
|
|
||||||
|
const stopRequestSchema = z.object({
|
||||||
|
sessionId: z.string().uuid().optional(),
|
||||||
|
reason: z
|
||||||
|
.enum([
|
||||||
|
"browser_exit",
|
||||||
|
"external_leave",
|
||||||
|
"manual",
|
||||||
|
"emergency",
|
||||||
|
"heartbeat_timeout",
|
||||||
|
])
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const userId = await getAutotradeUserId(request.headers);
|
||||||
|
if (!userId) {
|
||||||
|
return createAutotradeErrorResponse({
|
||||||
|
status: 401,
|
||||||
|
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
|
||||||
|
message: "로그인이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = await readJsonBody(request);
|
||||||
|
const parsed = stopRequestSchema.safeParse(rawBody ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
return createAutotradeErrorResponse({
|
||||||
|
status: 400,
|
||||||
|
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
|
||||||
|
message: parsed.error.issues[0]?.message ?? "세션 종료 입력값이 올바르지 않습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = getAutotradeSession(userId);
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
session: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.sessionId && parsed.data.sessionId !== session.sessionId) {
|
||||||
|
return createAutotradeErrorResponse({
|
||||||
|
status: 409,
|
||||||
|
code: AUTOTRADE_API_ERROR_CODE.CONFLICT,
|
||||||
|
message: "세션 식별자가 일치하지 않습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason: AutotradeStopReason = parsed.data.reason ?? "manual";
|
||||||
|
const stopped = stopAutotradeSession(userId, reason);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
session: stopped,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return createAutotradeErrorResponse({
|
||||||
|
status: 500,
|
||||||
|
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
|
||||||
|
message: sanitizeAutotradeError(error, "세션 종료 중 오류가 발생했습니다."),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
440
app/api/autotrade/signals/generate/route.ts
Normal file
440
app/api/autotrade/signals/generate/route.ts
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
/**
|
||||||
|
* [파일 역할]
|
||||||
|
* 컴파일된 전략 + 시세 스냅샷으로 매수/매도/대기 신호를 생성하는 API 라우트입니다.
|
||||||
|
*
|
||||||
|
* [주요 책임]
|
||||||
|
* - 요청 검증(strategy/snapshot)
|
||||||
|
* - provider 분기(OpenAI/구독형 CLI/fallback)
|
||||||
|
* - 실패 시 fallback 신호로 대체
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
AUTOTRADE_API_ERROR_CODE,
|
||||||
|
createAutotradeErrorResponse,
|
||||||
|
getAutotradeUserId,
|
||||||
|
readJsonBody,
|
||||||
|
sanitizeAutotradeError,
|
||||||
|
} from "@/app/api/autotrade/_shared";
|
||||||
|
import { AUTOTRADE_TECHNIQUE_IDS } from "@/features/autotrade/types/autotrade.types";
|
||||||
|
import {
|
||||||
|
generateSignalWithSubscriptionCliDetailed,
|
||||||
|
summarizeSubscriptionCliExecution,
|
||||||
|
} from "@/lib/autotrade/cli-provider";
|
||||||
|
import { generateSignalWithOpenAi, isOpenAiConfigured } from "@/lib/autotrade/openai";
|
||||||
|
import { createFallbackSignalCandidate } from "@/lib/autotrade/strategy";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const strategySchema = z.object({
|
||||||
|
provider: z.enum(["openai", "fallback", "subscription_cli"]),
|
||||||
|
summary: z.string().trim().min(1).max(320),
|
||||||
|
selectedTechniques: z.array(z.enum(AUTOTRADE_TECHNIQUE_IDS)).default([]),
|
||||||
|
confidenceThreshold: z.number().min(0.45).max(0.95),
|
||||||
|
maxDailyOrders: z.number().int().min(1).max(200),
|
||||||
|
cooldownSec: z.number().int().min(10).max(600),
|
||||||
|
maxOrderAmountRatio: z.number().min(0.05).max(1),
|
||||||
|
createdAt: z.string().trim().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const signalRequestSchema = z.object({
|
||||||
|
aiMode: z
|
||||||
|
.enum(["auto", "openai_api", "subscription_cli", "rule_fallback"])
|
||||||
|
.default("auto"),
|
||||||
|
subscriptionCliVendor: z.enum(["auto", "codex", "gemini"]).optional(),
|
||||||
|
subscriptionCliModel: z.string().trim().max(80).optional(),
|
||||||
|
prompt: z.string().trim().max(1200).default(""),
|
||||||
|
strategy: strategySchema,
|
||||||
|
snapshot: z.object({
|
||||||
|
symbol: z.string().trim().regex(/^\d{6}$/),
|
||||||
|
stockName: z.string().trim().max(120).optional(),
|
||||||
|
market: z.enum(["KOSPI", "KOSDAQ"]).optional(),
|
||||||
|
requestAtIso: z.string().trim().max(40).optional(),
|
||||||
|
requestAtKst: z.string().trim().max(40).optional(),
|
||||||
|
tickTime: z.string().trim().max(12).optional(),
|
||||||
|
executionClassCode: z.string().trim().max(10).optional(),
|
||||||
|
isExpected: z.boolean().optional(),
|
||||||
|
trId: z.string().trim().max(32).optional(),
|
||||||
|
currentPrice: z.number().positive(),
|
||||||
|
prevClose: z.number().nonnegative().optional(),
|
||||||
|
changeRate: z.number(),
|
||||||
|
open: z.number().nonnegative(),
|
||||||
|
high: z.number().nonnegative(),
|
||||||
|
low: z.number().nonnegative(),
|
||||||
|
tradeVolume: z.number().nonnegative(),
|
||||||
|
accumulatedVolume: z.number().nonnegative(),
|
||||||
|
tradeStrength: z.number().optional(),
|
||||||
|
askPrice1: z.number().nonnegative().optional(),
|
||||||
|
bidPrice1: z.number().nonnegative().optional(),
|
||||||
|
askSize1: z.number().nonnegative().optional(),
|
||||||
|
bidSize1: z.number().nonnegative().optional(),
|
||||||
|
totalAskSize: z.number().nonnegative().optional(),
|
||||||
|
totalBidSize: z.number().nonnegative().optional(),
|
||||||
|
buyExecutionCount: z.number().int().optional(),
|
||||||
|
sellExecutionCount: z.number().int().optional(),
|
||||||
|
netBuyExecutionCount: z.number().int().optional(),
|
||||||
|
spread: z.number().nonnegative().optional(),
|
||||||
|
spreadRate: z.number().optional(),
|
||||||
|
dayRangePercent: z.number().nonnegative().optional(),
|
||||||
|
dayRangePosition: z.number().min(0).max(1).optional(),
|
||||||
|
volumeRatio: z.number().nonnegative().optional(),
|
||||||
|
recentTradeCount: z.number().int().nonnegative().optional(),
|
||||||
|
recentTradeVolumeSum: z.number().nonnegative().optional(),
|
||||||
|
recentAverageTradeVolume: z.number().nonnegative().optional(),
|
||||||
|
accumulatedVolumeDelta: z.number().nonnegative().optional(),
|
||||||
|
netBuyExecutionDelta: z.number().optional(),
|
||||||
|
orderBookImbalance: z.number().min(-1).max(1).optional(),
|
||||||
|
liquidityDepth: z.number().nonnegative().optional(),
|
||||||
|
topLevelOrderBookImbalance: z.number().min(-1).max(1).optional(),
|
||||||
|
buySellExecutionRatio: z.number().nonnegative().optional(),
|
||||||
|
recentPriceHigh: z.number().positive().optional(),
|
||||||
|
recentPriceLow: z.number().positive().optional(),
|
||||||
|
recentPriceRangePercent: z.number().nonnegative().optional(),
|
||||||
|
recentTradeVolumes: z.array(z.number().nonnegative()).max(20).optional(),
|
||||||
|
recentNetBuyTrail: z.array(z.number()).max(20).optional(),
|
||||||
|
recentTickAgesSec: z.array(z.number().nonnegative()).max(20).optional(),
|
||||||
|
intradayMomentum: z.number().optional(),
|
||||||
|
recentReturns: z.array(z.number()).max(12).optional(),
|
||||||
|
recentPrices: z.array(z.number().positive()).min(3).max(30),
|
||||||
|
marketDataLatencySec: z.number().nonnegative().optional(),
|
||||||
|
recentMinuteCandles: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
time: z.string().trim().max(32),
|
||||||
|
open: z.number().positive(),
|
||||||
|
high: z.number().positive(),
|
||||||
|
low: z.number().positive(),
|
||||||
|
close: z.number().positive(),
|
||||||
|
volume: z.number().nonnegative(),
|
||||||
|
timestamp: z.number().int().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.max(30)
|
||||||
|
.optional(),
|
||||||
|
minutePatternContext: z
|
||||||
|
.object({
|
||||||
|
timeframe: z.literal("1m"),
|
||||||
|
candleCount: z.number().int().min(1).max(30),
|
||||||
|
impulseDirection: z.enum(["up", "down", "flat"]),
|
||||||
|
impulseBarCount: z.number().int().min(1).max(20),
|
||||||
|
consolidationBarCount: z.number().int().min(1).max(12),
|
||||||
|
impulseChangeRate: z.number().optional(),
|
||||||
|
impulseRangePercent: z.number().nonnegative().optional(),
|
||||||
|
consolidationRangePercent: z.number().nonnegative().optional(),
|
||||||
|
consolidationCloseClusterPercent: z.number().nonnegative().optional(),
|
||||||
|
consolidationVolumeRatio: z.number().nonnegative().optional(),
|
||||||
|
breakoutUpper: z.number().positive().optional(),
|
||||||
|
breakoutLower: z.number().positive().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
budgetContext: z
|
||||||
|
.object({
|
||||||
|
setupAllocationPercent: z.number().nonnegative(),
|
||||||
|
setupAllocationAmount: z.number().nonnegative(),
|
||||||
|
effectiveAllocationAmount: z.number().nonnegative(),
|
||||||
|
strategyMaxOrderAmountRatio: z.number().min(0).max(1),
|
||||||
|
effectiveOrderBudgetAmount: z.number().nonnegative(),
|
||||||
|
estimatedBuyUnitCost: z.number().nonnegative(),
|
||||||
|
estimatedBuyableQuantity: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
portfolioContext: z
|
||||||
|
.object({
|
||||||
|
holdingQuantity: z.number().int().nonnegative(),
|
||||||
|
sellableQuantity: z.number().int().nonnegative(),
|
||||||
|
averagePrice: z.number().nonnegative(),
|
||||||
|
estimatedSellableNetAmount: z.number().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
executionCostProfile: z
|
||||||
|
.object({
|
||||||
|
buyFeeRate: z.number().nonnegative(),
|
||||||
|
sellFeeRate: z.number().nonnegative(),
|
||||||
|
sellTaxRate: z.number().nonnegative(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const signalResultSchema = z.object({
|
||||||
|
signal: z.enum(["buy", "sell", "hold"]),
|
||||||
|
confidence: z.number().min(0).max(1),
|
||||||
|
reason: z.string().min(1).max(160),
|
||||||
|
ttlSec: z.number().int().min(5).max(300),
|
||||||
|
riskFlags: z.array(z.string()).max(10).default([]),
|
||||||
|
proposedOrder: z
|
||||||
|
.object({
|
||||||
|
symbol: z.string().trim().regex(/^\d{6}$/),
|
||||||
|
side: z.enum(["buy", "sell"]),
|
||||||
|
orderType: z.enum(["limit", "market"]),
|
||||||
|
price: z.number().positive().optional(),
|
||||||
|
quantity: z.number().int().positive().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const userId = await getAutotradeUserId(request.headers);
|
||||||
|
if (!userId) {
|
||||||
|
return createAutotradeErrorResponse({
|
||||||
|
status: 401,
|
||||||
|
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
|
||||||
|
message: "로그인이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = await readJsonBody(request);
|
||||||
|
const parsed = signalRequestSchema.safeParse(rawBody);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return createAutotradeErrorResponse({
|
||||||
|
status: 400,
|
||||||
|
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
|
||||||
|
message: parsed.error.issues[0]?.message ?? "신호 생성 요청값이 올바르지 않습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// [Step 1] 안전망: 우선 규칙 기반 fallback 신호를 준비합니다.
|
||||||
|
const fallbackSignal = createFallbackSignalCandidate({
|
||||||
|
strategy: parsed.data.strategy,
|
||||||
|
snapshot: parsed.data.snapshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
// [Step 2] 규칙 기반 강제 모드
|
||||||
|
if (parsed.data.aiMode === "rule_fallback") {
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
signal: fallbackSignal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Step 3] OpenAI 모드(auto/openai_api): 성공 시 해당 신호를 그대로 반환
|
||||||
|
const shouldUseOpenAi = parsed.data.aiMode === "openai_api" || parsed.data.aiMode === "auto";
|
||||||
|
if (shouldUseOpenAi && isOpenAiConfigured()) {
|
||||||
|
const aiSignal = await generateSignalWithOpenAi({
|
||||||
|
prompt: parsed.data.prompt,
|
||||||
|
strategy: parsed.data.strategy,
|
||||||
|
snapshot: parsed.data.snapshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (aiSignal) {
|
||||||
|
const localizedReason = ensureKoreanReason(aiSignal.reason, aiSignal.signal);
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
signal: {
|
||||||
|
...aiSignal,
|
||||||
|
reason: localizedReason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Step 4] 구독형 CLI 모드(subscription_cli/auto-fallback): codex/gemini CLI 자동판단
|
||||||
|
const shouldUseCli =
|
||||||
|
parsed.data.aiMode === "subscription_cli" ||
|
||||||
|
(parsed.data.aiMode === "auto" && !isOpenAiConfigured());
|
||||||
|
if (shouldUseCli) {
|
||||||
|
const cliResult = await generateSignalWithSubscriptionCliDetailed({
|
||||||
|
prompt: parsed.data.prompt,
|
||||||
|
strategy: parsed.data.strategy,
|
||||||
|
snapshot: parsed.data.snapshot,
|
||||||
|
preferredVendor: parsed.data.subscriptionCliVendor,
|
||||||
|
preferredModel:
|
||||||
|
parsed.data.subscriptionCliVendor && parsed.data.subscriptionCliVendor !== "auto"
|
||||||
|
? parsed.data.subscriptionCliModel
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
const normalizedCliSignal = normalizeCliSignalCandidate(
|
||||||
|
cliResult.parsed,
|
||||||
|
parsed.data.snapshot.symbol,
|
||||||
|
);
|
||||||
|
const cliParsed = signalResultSchema.safeParse(normalizedCliSignal);
|
||||||
|
if (cliParsed.success) {
|
||||||
|
const localizedReason = ensureKoreanReason(
|
||||||
|
cliParsed.data.reason,
|
||||||
|
cliParsed.data.signal,
|
||||||
|
);
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
signal: {
|
||||||
|
...cliParsed.data,
|
||||||
|
reason: localizedReason,
|
||||||
|
source: "subscription_cli",
|
||||||
|
providerVendor: cliResult.vendor ?? undefined,
|
||||||
|
providerModel: cliResult.model ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cliExecutionSummary = summarizeSubscriptionCliExecution(cliResult);
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
signal: {
|
||||||
|
...fallbackSignal,
|
||||||
|
// CLI 응답이 비정상이어도 주문 엔진이 멈추지 않도록 fallback 신호로 대체합니다.
|
||||||
|
reason: `구독형 CLI 응답을 해석하지 못해 규칙 기반 신호로 대체했습니다. (${cliExecutionSummary})`,
|
||||||
|
providerVendor: cliResult.vendor ?? undefined,
|
||||||
|
providerModel: cliResult.model ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
signal: fallbackSignal,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return createAutotradeErrorResponse({
|
||||||
|
status: 500,
|
||||||
|
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
|
||||||
|
message: sanitizeAutotradeError(error, "신호 생성 중 오류가 발생했습니다."),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCliSignalCandidate(raw: unknown, defaultSymbol: string) {
|
||||||
|
const source = resolveSignalPayloadSource(raw);
|
||||||
|
if (!source) return raw;
|
||||||
|
|
||||||
|
const signal = normalizeSignalValue(source.signal ?? source.action ?? source.side);
|
||||||
|
const confidence = clampNumber(source.confidence ?? source.score ?? source.probability, 0, 1);
|
||||||
|
const reason = normalizeReasonText(source.reason ?? source.rationale ?? source.comment);
|
||||||
|
const ttlSec = normalizeInteger(source.ttlSec ?? source.ttl, 20, 5, 300);
|
||||||
|
const riskFlags = normalizeRiskFlags(source.riskFlags ?? source.risks);
|
||||||
|
const proposedOrder = normalizeProposedOrder(source.proposedOrder ?? source.order, defaultSymbol);
|
||||||
|
|
||||||
|
return {
|
||||||
|
signal: signal ?? source.signal,
|
||||||
|
confidence,
|
||||||
|
reason,
|
||||||
|
ttlSec,
|
||||||
|
riskFlags,
|
||||||
|
proposedOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSignalPayloadSource(raw: unknown): Record<string, unknown> | null {
|
||||||
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
const source = raw as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (source.signal || source.action || source.side || source.proposedOrder || source.order) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestedCandidate =
|
||||||
|
source.decision ??
|
||||||
|
source.result ??
|
||||||
|
source.data ??
|
||||||
|
source.output ??
|
||||||
|
source.payload;
|
||||||
|
if (!nestedCandidate || typeof nestedCandidate !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nestedCandidate as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSignalValue(raw: unknown) {
|
||||||
|
if (typeof raw !== "string") return null;
|
||||||
|
const normalized = raw.trim().toLowerCase();
|
||||||
|
if (normalized === "buy" || normalized === "sell" || normalized === "hold") {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampNumber(raw: unknown, min: number, max: number) {
|
||||||
|
const value = typeof raw === "number" ? raw : Number.parseFloat(String(raw ?? ""));
|
||||||
|
if (!Number.isFinite(value)) return 0.5;
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInteger(raw: unknown, fallback: number, min: number, max: number) {
|
||||||
|
const value = Number.parseInt(String(raw ?? ""), 10);
|
||||||
|
if (!Number.isFinite(value)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReasonText(raw: unknown) {
|
||||||
|
const value = typeof raw === "string" ? raw.trim() : "";
|
||||||
|
if (!value) return "신호 사유가 없어 hold로 처리했습니다.";
|
||||||
|
return value.slice(0, 160);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureKoreanReason(
|
||||||
|
reason: string,
|
||||||
|
signal: "buy" | "sell" | "hold",
|
||||||
|
) {
|
||||||
|
const normalized = normalizeReasonText(reason);
|
||||||
|
if (/[가-힣]/.test(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal === "buy") {
|
||||||
|
return "상승 신호가 확인되어 매수 관점으로 판단했습니다.";
|
||||||
|
}
|
||||||
|
if (signal === "sell") {
|
||||||
|
return "하락 또는 과열 신호가 확인되어 매도 관점으로 판단했습니다.";
|
||||||
|
}
|
||||||
|
return "명확한 방향성이 부족해 대기 신호로 판단했습니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRiskFlags(raw: unknown) {
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
return raw
|
||||||
|
.map((item) => String(item).trim())
|
||||||
|
.filter((item) => item.length > 0)
|
||||||
|
.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
return raw
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item.length > 0)
|
||||||
|
.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProposedOrder(raw: unknown, defaultSymbol: string) {
|
||||||
|
if (!raw || typeof raw !== "object") return undefined;
|
||||||
|
const source = raw as Record<string, unknown>;
|
||||||
|
|
||||||
|
const side = normalizeSignalValue(source.side);
|
||||||
|
if (side !== "buy" && side !== "sell") return undefined;
|
||||||
|
|
||||||
|
const orderTypeRaw = String(source.orderType ?? source.type ?? "limit")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const orderType = orderTypeRaw === "market" ? "market" : "limit";
|
||||||
|
const symbolRaw = String(source.symbol ?? defaultSymbol).trim();
|
||||||
|
const symbol = /^\d{6}$/.test(symbolRaw) ? symbolRaw : defaultSymbol;
|
||||||
|
const price = parseOptionalPositiveNumber(source.price);
|
||||||
|
const quantity = parseOptionalPositiveInteger(source.quantity ?? source.qty);
|
||||||
|
|
||||||
|
return {
|
||||||
|
symbol,
|
||||||
|
side,
|
||||||
|
orderType,
|
||||||
|
price,
|
||||||
|
quantity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalPositiveNumber(raw: unknown) {
|
||||||
|
if (raw === undefined || raw === null || raw === "") return undefined;
|
||||||
|
const value = typeof raw === "number" ? raw : Number.parseFloat(String(raw));
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return undefined;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalPositiveInteger(raw: unknown) {
|
||||||
|
if (raw === undefined || raw === null || raw === "") return undefined;
|
||||||
|
const value = Number.parseInt(String(raw), 10);
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return undefined;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
411
app/api/autotrade/strategies/compile/route.ts
Normal file
411
app/api/autotrade/strategies/compile/route.ts
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
/**
|
||||||
|
* [파일 역할]
|
||||||
|
* 전략 프롬프트를 실행 가능한 자동매매 전략(JSON)으로 컴파일하는 API 라우트입니다.
|
||||||
|
*
|
||||||
|
* [주요 책임]
|
||||||
|
* - 요청 검증(aiMode/prompt/기법/신뢰도)
|
||||||
|
* - provider 분기(OpenAI/구독형 CLI/fallback)
|
||||||
|
* - 실패 시 fallback 전략으로 안전하게 응답
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
AUTOTRADE_API_ERROR_CODE,
|
||||||
|
createAutotradeErrorResponse,
|
||||||
|
getAutotradeUserId,
|
||||||
|
readJsonBody,
|
||||||
|
sanitizeAutotradeError,
|
||||||
|
} from "@/app/api/autotrade/_shared";
|
||||||
|
import {
|
||||||
|
AUTOTRADE_DEFAULT_TECHNIQUES,
|
||||||
|
AUTOTRADE_TECHNIQUE_IDS,
|
||||||
|
} from "@/features/autotrade/types/autotrade.types";
|
||||||
|
import {
|
||||||
|
compileStrategyWithSubscriptionCliDetailed,
|
||||||
|
summarizeSubscriptionCliExecution,
|
||||||
|
} from "@/lib/autotrade/cli-provider";
|
||||||
|
import { compileStrategyWithOpenAi, isOpenAiConfigured } from "@/lib/autotrade/openai";
|
||||||
|
import { createFallbackCompiledStrategy } from "@/lib/autotrade/strategy";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const compileRequestSchema = z.object({
|
||||||
|
aiMode: z
|
||||||
|
.enum(["auto", "openai_api", "subscription_cli", "rule_fallback"])
|
||||||
|
.default("auto"),
|
||||||
|
subscriptionCliVendor: z.enum(["auto", "codex", "gemini"]).optional(),
|
||||||
|
subscriptionCliModel: z.string().trim().max(80).optional(),
|
||||||
|
prompt: z.string().trim().max(1200).default(""),
|
||||||
|
selectedTechniques: z.array(z.enum(AUTOTRADE_TECHNIQUE_IDS)).default([]),
|
||||||
|
confidenceThreshold: z.number().min(0.45).max(0.95).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const compileResultSchema = z.object({
|
||||||
|
summary: z.string().min(1).max(320),
|
||||||
|
confidenceThreshold: z.number().min(0.45).max(0.95),
|
||||||
|
maxDailyOrders: z.number().int().min(1).max(200),
|
||||||
|
cooldownSec: z.number().int().min(10).max(600),
|
||||||
|
maxOrderAmountRatio: z.number().min(0.05).max(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const userId = await getAutotradeUserId(request.headers);
|
||||||
|
if (!userId) {
|
||||||
|
return createAutotradeErrorResponse({
|
||||||
|
status: 401,
|
||||||
|
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
|
||||||
|
message: "로그인이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = await readJsonBody(request);
|
||||||
|
const parsed = compileRequestSchema.safeParse(rawBody);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return createAutotradeErrorResponse({
|
||||||
|
status: 400,
|
||||||
|
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
|
||||||
|
message: parsed.error.issues[0]?.message ?? "전략 입력값이 올바르지 않습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const selectedTechniques =
|
||||||
|
parsed.data.selectedTechniques.length > 0
|
||||||
|
? parsed.data.selectedTechniques
|
||||||
|
: AUTOTRADE_DEFAULT_TECHNIQUES;
|
||||||
|
|
||||||
|
// [Step 1] 어떤 모드든 공통 최소전략(규칙 기반)을 먼저 준비해 둡니다.
|
||||||
|
const fallback = createFallbackCompiledStrategy({
|
||||||
|
prompt: parsed.data.prompt,
|
||||||
|
selectedTechniques,
|
||||||
|
confidenceThreshold: parsed.data.confidenceThreshold ?? 0.65,
|
||||||
|
});
|
||||||
|
|
||||||
|
// [Step 2] 규칙 기반 강제 모드는 즉시 fallback 전략으로 반환합니다.
|
||||||
|
if (parsed.data.aiMode === "rule_fallback") {
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
compiledStrategy: {
|
||||||
|
...fallback,
|
||||||
|
summary: `규칙 기반 모드: ${fallback.summary}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Step 3] OpenAI 모드(auto/openai_api): API 키가 있으면 OpenAI 결과를 우선 사용합니다.
|
||||||
|
const shouldUseOpenAi = parsed.data.aiMode === "auto" || parsed.data.aiMode === "openai_api";
|
||||||
|
if (shouldUseOpenAi && isOpenAiConfigured()) {
|
||||||
|
const aiResult = await compileStrategyWithOpenAi({
|
||||||
|
prompt: parsed.data.prompt,
|
||||||
|
selectedTechniques,
|
||||||
|
confidenceThreshold: fallback.confidenceThreshold,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (aiResult) {
|
||||||
|
const finalizedSummary = finalizeCompiledSummary({
|
||||||
|
summary: aiResult.summary,
|
||||||
|
prompt: parsed.data.prompt,
|
||||||
|
selectedTechniques,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
compiledStrategy: {
|
||||||
|
...fallback,
|
||||||
|
provider: "openai",
|
||||||
|
summary: finalizedSummary,
|
||||||
|
confidenceThreshold: aiResult.confidenceThreshold,
|
||||||
|
maxDailyOrders: aiResult.maxDailyOrders,
|
||||||
|
cooldownSec: aiResult.cooldownSec,
|
||||||
|
maxOrderAmountRatio: aiResult.maxOrderAmountRatio,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Step 4] 구독형 CLI 모드(subscription_cli/auto-fallback): codex/gemini CLI를 호출합니다.
|
||||||
|
const shouldUseCli =
|
||||||
|
parsed.data.aiMode === "subscription_cli" ||
|
||||||
|
(parsed.data.aiMode === "auto" && !isOpenAiConfigured());
|
||||||
|
if (shouldUseCli) {
|
||||||
|
const cliResult = await compileStrategyWithSubscriptionCliDetailed({
|
||||||
|
prompt: parsed.data.prompt,
|
||||||
|
selectedTechniques,
|
||||||
|
confidenceThreshold: fallback.confidenceThreshold,
|
||||||
|
preferredVendor: parsed.data.subscriptionCliVendor,
|
||||||
|
preferredModel:
|
||||||
|
parsed.data.subscriptionCliVendor && parsed.data.subscriptionCliVendor !== "auto"
|
||||||
|
? parsed.data.subscriptionCliModel
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
const normalizedCliCompile = normalizeCliCompileResult(cliResult.parsed, fallback);
|
||||||
|
const cliParsed = compileResultSchema.safeParse(normalizedCliCompile);
|
||||||
|
if (cliParsed.success) {
|
||||||
|
const finalizedSummary = finalizeCompiledSummary({
|
||||||
|
summary: cliParsed.data.summary,
|
||||||
|
prompt: parsed.data.prompt,
|
||||||
|
selectedTechniques,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
compiledStrategy: {
|
||||||
|
...fallback,
|
||||||
|
provider: "subscription_cli",
|
||||||
|
providerVendor: cliResult.vendor ?? undefined,
|
||||||
|
providerModel: cliResult.model ?? undefined,
|
||||||
|
summary: finalizedSummary,
|
||||||
|
confidenceThreshold: cliParsed.data.confidenceThreshold,
|
||||||
|
maxDailyOrders: cliParsed.data.maxDailyOrders,
|
||||||
|
cooldownSec: cliParsed.data.cooldownSec,
|
||||||
|
maxOrderAmountRatio: cliParsed.data.maxOrderAmountRatio,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseSummary = summarizeCompileParseFailure(cliResult.parsed);
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
compiledStrategy: {
|
||||||
|
...fallback,
|
||||||
|
provider: "subscription_cli",
|
||||||
|
providerVendor: cliResult.vendor ?? undefined,
|
||||||
|
providerModel: cliResult.model ?? undefined,
|
||||||
|
// CLI가 실패해도 자동매매가 멈추지 않도록 fallback 전략으로 안전하게 유지합니다.
|
||||||
|
summary: `구독형 CLI 응답을 해석하지 못해 규칙 기반 전략으로 동작합니다. (${summarizeSubscriptionCliExecution(cliResult)}; parse=${parseSummary})`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
compiledStrategy: fallback,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return createAutotradeErrorResponse({
|
||||||
|
status: 500,
|
||||||
|
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
|
||||||
|
message: sanitizeAutotradeError(error, "전략 컴파일 중 오류가 발생했습니다."),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCliCompileResult(raw: unknown, fallback: ReturnType<typeof createFallbackCompiledStrategy>) {
|
||||||
|
const source = resolveCompilePayloadSource(raw);
|
||||||
|
if (!source) return raw;
|
||||||
|
|
||||||
|
const summary = normalizeSummaryText(
|
||||||
|
source.summary ??
|
||||||
|
source.strategySummary ??
|
||||||
|
source.description ??
|
||||||
|
source.plan ??
|
||||||
|
source.reason ??
|
||||||
|
fallback.summary,
|
||||||
|
fallback.summary,
|
||||||
|
);
|
||||||
|
const confidenceThreshold = normalizeRatioNumber(
|
||||||
|
source.confidenceThreshold ?? source.confidence ?? source.threshold,
|
||||||
|
fallback.confidenceThreshold,
|
||||||
|
0.45,
|
||||||
|
0.95,
|
||||||
|
);
|
||||||
|
const maxDailyOrders = normalizeIntegerValue(
|
||||||
|
source.maxDailyOrders ?? source.dailyOrderLimit ?? source.maxOrdersPerDay ?? source.orderLimit,
|
||||||
|
fallback.maxDailyOrders,
|
||||||
|
1,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
const cooldownSec = normalizeIntegerValue(
|
||||||
|
source.cooldownSec ?? source.cooldownSeconds ?? source.cooldown ?? source.minIntervalSec,
|
||||||
|
fallback.cooldownSec,
|
||||||
|
10,
|
||||||
|
600,
|
||||||
|
);
|
||||||
|
const maxOrderAmountRatio = normalizeRatioNumber(
|
||||||
|
source.maxOrderAmountRatio ??
|
||||||
|
source.maxPositionRatio ??
|
||||||
|
source.positionSizeRatio ??
|
||||||
|
source.orderAmountRatio,
|
||||||
|
fallback.maxOrderAmountRatio,
|
||||||
|
0.05,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary,
|
||||||
|
confidenceThreshold,
|
||||||
|
maxDailyOrders,
|
||||||
|
cooldownSec,
|
||||||
|
maxOrderAmountRatio,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCompilePayloadSource(raw: unknown): Record<string, unknown> | null {
|
||||||
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
const source = raw as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
source.summary ||
|
||||||
|
source.strategySummary ||
|
||||||
|
source.confidenceThreshold ||
|
||||||
|
source.maxDailyOrders ||
|
||||||
|
source.cooldownSec ||
|
||||||
|
source.maxOrderAmountRatio
|
||||||
|
) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestedCandidate =
|
||||||
|
source.strategy ??
|
||||||
|
source.compiledStrategy ??
|
||||||
|
source.result ??
|
||||||
|
source.output ??
|
||||||
|
source.data ??
|
||||||
|
source.payload;
|
||||||
|
if (!nestedCandidate || typeof nestedCandidate !== "object") {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nestedCandidate as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSummaryText(raw: unknown, fallback: string) {
|
||||||
|
const text = typeof raw === "string" ? raw.trim() : "";
|
||||||
|
if (!text) return fallback;
|
||||||
|
return text.slice(0, 320);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRatioNumber(
|
||||||
|
raw: unknown,
|
||||||
|
fallback: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
) {
|
||||||
|
let value = typeof raw === "number" ? raw : Number.parseFloat(String(raw ?? ""));
|
||||||
|
if (!Number.isFinite(value)) return fallback;
|
||||||
|
if (value > 1 && value <= 100) value /= 100;
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIntegerValue(
|
||||||
|
raw: unknown,
|
||||||
|
fallback: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
) {
|
||||||
|
const value = Number.parseInt(String(raw ?? ""), 10);
|
||||||
|
if (!Number.isFinite(value)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeCompileParseFailure(raw: unknown) {
|
||||||
|
if (raw === null || raw === undefined) return "empty";
|
||||||
|
if (typeof raw === "string") return `string:${raw.slice(0, 80)}`;
|
||||||
|
if (typeof raw !== "object") return typeof raw;
|
||||||
|
try {
|
||||||
|
const keys = Object.keys(raw as Record<string, unknown>).slice(0, 8);
|
||||||
|
return `keys:${keys.join("|") || "none"}`;
|
||||||
|
} catch {
|
||||||
|
return "object";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeCompiledSummary(params: {
|
||||||
|
summary: string;
|
||||||
|
prompt: string;
|
||||||
|
selectedTechniques: readonly string[];
|
||||||
|
}) {
|
||||||
|
const cleanedSummary = params.summary.trim();
|
||||||
|
const prompt = params.prompt.trim();
|
||||||
|
|
||||||
|
if (!prompt) {
|
||||||
|
return cleanedSummary.slice(0, 320);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loweredSummary = cleanedSummary.toLowerCase();
|
||||||
|
const loweredPrompt = prompt.toLowerCase();
|
||||||
|
const suspiciousPhrases = [
|
||||||
|
"테스트 목적",
|
||||||
|
"테스트용",
|
||||||
|
"sample",
|
||||||
|
"example",
|
||||||
|
"for testing",
|
||||||
|
"test purpose",
|
||||||
|
];
|
||||||
|
const hasSuspiciousPhrase =
|
||||||
|
suspiciousPhrases.some((phrase) => loweredSummary.includes(phrase)) &&
|
||||||
|
!suspiciousPhrases.some((phrase) => loweredPrompt.includes(phrase));
|
||||||
|
|
||||||
|
const hasPromptCoverage = detectPromptCoverage(cleanedSummary, prompt);
|
||||||
|
|
||||||
|
if (hasSuspiciousPhrase) {
|
||||||
|
return buildPromptAnchoredSummary(prompt, params.selectedTechniques, cleanedSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPromptCoverage) {
|
||||||
|
return buildPromptAnchoredSummary(prompt, params.selectedTechniques, cleanedSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanedSummary.slice(0, 320);
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectPromptCoverage(summary: string, prompt: string) {
|
||||||
|
const normalizedSummary = normalizeCoverageText(summary);
|
||||||
|
const keywords = extractPromptKeywords(prompt);
|
||||||
|
if (keywords.length === 0) return true;
|
||||||
|
return keywords.some((keyword) => normalizedSummary.includes(keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCoverageText(text: string) {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9가-힣]+/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPromptKeywords(prompt: string) {
|
||||||
|
const stopwords = new Set([
|
||||||
|
"그리고",
|
||||||
|
"그냥",
|
||||||
|
"우선",
|
||||||
|
"위주",
|
||||||
|
"중심",
|
||||||
|
"하게",
|
||||||
|
"하면",
|
||||||
|
"현재",
|
||||||
|
"지금",
|
||||||
|
"please",
|
||||||
|
"with",
|
||||||
|
"from",
|
||||||
|
"that",
|
||||||
|
"this",
|
||||||
|
]);
|
||||||
|
|
||||||
|
return normalizeCoverageText(prompt)
|
||||||
|
.split(" ")
|
||||||
|
.map((token) => token.trim())
|
||||||
|
.filter((token) => token.length >= 2 && !stopwords.has(token))
|
||||||
|
.slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPromptAnchoredSummary(
|
||||||
|
prompt: string,
|
||||||
|
selectedTechniques: readonly string[],
|
||||||
|
aiSummary?: string,
|
||||||
|
) {
|
||||||
|
const promptExcerpt = prompt.replace(/\s+/g, " ").trim().slice(0, 120);
|
||||||
|
const techniquesText =
|
||||||
|
selectedTechniques.length > 0 ? ` (${selectedTechniques.join(", ")})` : "";
|
||||||
|
const aiSummaryText = aiSummary?.replace(/\s+/g, " ").trim().slice(0, 120);
|
||||||
|
if (!aiSummaryText) {
|
||||||
|
return `프롬프트 반영 전략${techniquesText}: ${promptExcerpt}`.slice(0, 320);
|
||||||
|
}
|
||||||
|
return `프롬프트 반영 전략${techniquesText}: ${promptExcerpt} | AI요약: ${aiSummaryText}`.slice(
|
||||||
|
0,
|
||||||
|
320,
|
||||||
|
);
|
||||||
|
}
|
||||||
43
app/api/autotrade/strategies/validate/route.ts
Normal file
43
app/api/autotrade/strategies/validate/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
AUTOTRADE_API_ERROR_CODE,
|
||||||
|
createAutotradeErrorResponse,
|
||||||
|
getAutotradeUserId,
|
||||||
|
readJsonBody,
|
||||||
|
} from "@/app/api/autotrade/_shared";
|
||||||
|
import { buildRiskEnvelope } from "@/lib/autotrade/risk";
|
||||||
|
|
||||||
|
const validateRequestSchema = z.object({
|
||||||
|
cashBalance: z.number().nonnegative(),
|
||||||
|
allocationPercent: z.number().nonnegative(),
|
||||||
|
allocationAmount: z.number().positive(),
|
||||||
|
dailyLossPercent: z.number().nonnegative(),
|
||||||
|
dailyLossAmount: z.number().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const userId = await getAutotradeUserId(request.headers);
|
||||||
|
if (!userId) {
|
||||||
|
return createAutotradeErrorResponse({
|
||||||
|
status: 401,
|
||||||
|
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
|
||||||
|
message: "로그인이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = await readJsonBody(request);
|
||||||
|
const parsed = validateRequestSchema.safeParse(rawBody);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return createAutotradeErrorResponse({
|
||||||
|
status: 400,
|
||||||
|
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
|
||||||
|
message: parsed.error.issues[0]?.message ?? "검증 입력값이 올바르지 않습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
validation: buildRiskEnvelope(parsed.data),
|
||||||
|
});
|
||||||
|
}
|
||||||
39
app/api/autotrade/worker/tick/route.ts
Normal file
39
app/api/autotrade/worker/tick/route.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
AUTOTRADE_API_ERROR_CODE,
|
||||||
|
AUTOTRADE_WORKER_TOKEN_HEADER,
|
||||||
|
createAutotradeErrorResponse,
|
||||||
|
isAutotradeWorkerAuthorized,
|
||||||
|
listAutotradeSessions,
|
||||||
|
sanitizeAutotradeError,
|
||||||
|
sweepExpiredAutotradeSessions,
|
||||||
|
} from "@/app/api/autotrade/_shared";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!isAutotradeWorkerAuthorized(request.headers)) {
|
||||||
|
return createAutotradeErrorResponse({
|
||||||
|
status: 401,
|
||||||
|
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
|
||||||
|
message: `${AUTOTRADE_WORKER_TOKEN_HEADER} 인증이 필요합니다.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sweep = sweepExpiredAutotradeSessions();
|
||||||
|
const sessions = listAutotradeSessions();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
sweep,
|
||||||
|
runningSessions: sessions.filter((session) => session.runtimeState === "RUNNING").length,
|
||||||
|
stoppedSessions: sessions.filter((session) => session.runtimeState === "STOPPED").length,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return createAutotradeErrorResponse({
|
||||||
|
status: 500,
|
||||||
|
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
|
||||||
|
message: sanitizeAutotradeError(error, "자동매매 워커 점검 중 오류가 발생했습니다."),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/api/kis/_response.ts
Normal file
56
app/api/kis/_response.ts
Normal 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
18
app/api/kis/_session.ts
Normal 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);
|
||||||
|
}
|
||||||
39
app/api/kis/domestic/_shared.ts
Normal file
39
app/api/kis/domestic/_shared.ts
Normal 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);
|
||||||
|
}
|
||||||
85
app/api/kis/domestic/activity/route.ts
Normal file
85
app/api/kis/domestic/activity/route.ts
Normal 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,
|
||||||
|
"주문내역/매매일지 조회 중 오류가 발생했습니다.",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
78
app/api/kis/domestic/balance/route.ts
Normal file
78
app/api/kis/domestic/balance/route.ts
Normal 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, "잔고 조회 중 오류가 발생했습니다."),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
103
app/api/kis/domestic/chart/route.ts
Normal file
103
app/api/kis/domestic/chart/route.ts
Normal 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 차트 조회 중 오류가 발생했습니다."),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
64
app/api/kis/domestic/indices/route.ts
Normal file
64
app/api/kis/domestic/indices/route.ts
Normal 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, "지수 조회 중 오류가 발생했습니다."),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/api/kis/domestic/market-hub/route.ts
Normal file
72
app/api/kis/domestic/market-hub/route.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { DashboardMarketHubResponse } from "@/features/dashboard/types/dashboard.types";
|
||||||
|
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||||
|
import { getDomesticDashboardMarketHub } from "@/lib/kis/dashboard";
|
||||||
|
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||||
|
import {
|
||||||
|
createKisApiErrorResponse,
|
||||||
|
KIS_API_ERROR_CODE,
|
||||||
|
toKisApiErrorMessage,
|
||||||
|
} from "@/app/api/kis/_response";
|
||||||
|
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file app/api/kis/domestic/market-hub/route.ts
|
||||||
|
* @description 국내주식 시장 허브(급등/인기/뉴스) 조회 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 시장 허브 조회 API
|
||||||
|
* @returns 급등주식/인기종목/주요뉴스 목록
|
||||||
|
* @remarks UI 흐름: DashboardContainer -> useDashboardData -> /api/kis/domestic/market-hub -> MarketHubSection 렌더링
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const hasSession = await hasKisApiSession();
|
||||||
|
if (!hasSession) {
|
||||||
|
return createKisApiErrorResponse({
|
||||||
|
status: 401,
|
||||||
|
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||||
|
message: "로그인이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||||
|
if (!hasKisConfig(credentials)) {
|
||||||
|
return createKisApiErrorResponse({
|
||||||
|
status: 400,
|
||||||
|
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||||
|
message: "KIS API 키 설정이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getDomesticDashboardMarketHub(credentials);
|
||||||
|
const response: DashboardMarketHubResponse = {
|
||||||
|
source: "kis",
|
||||||
|
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||||
|
gainers: result.gainers,
|
||||||
|
losers: result.losers,
|
||||||
|
popularByVolume: result.popularByVolume,
|
||||||
|
popularByValue: result.popularByValue,
|
||||||
|
news: result.news,
|
||||||
|
pulse: result.pulse,
|
||||||
|
warnings: result.warnings,
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response, {
|
||||||
|
headers: {
|
||||||
|
"cache-control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return createKisApiErrorResponse({
|
||||||
|
status: 500,
|
||||||
|
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||||
|
message: toKisApiErrorMessage(
|
||||||
|
error,
|
||||||
|
"시장 허브 조회 중 오류가 발생했습니다.",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
168
app/api/kis/domestic/order-cash/route.ts
Normal file
168
app/api/kis/domestic/order-cash/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
124
app/api/kis/domestic/orderable-cash/route.ts
Normal file
124
app/api/kis/domestic/orderable-cash/route.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { executeInquireOrderableCash } from "@/lib/kis/trade";
|
||||||
|
import type { DashboardStockOrderableCashResponse } from "@/features/trade/types/trade.types";
|
||||||
|
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||||
|
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||||
|
import {
|
||||||
|
createKisApiErrorResponse,
|
||||||
|
KIS_API_ERROR_CODE,
|
||||||
|
toKisApiErrorMessage,
|
||||||
|
} from "@/app/api/kis/_response";
|
||||||
|
import {
|
||||||
|
readKisAccountParts,
|
||||||
|
readKisCredentialsFromHeaders,
|
||||||
|
} from "@/app/api/kis/domestic/_shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file app/api/kis/domestic/orderable-cash/route.ts
|
||||||
|
* @description 국내주식 매수가능금액(주문가능현금) 조회 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
const orderableCashBodySchema = z.object({
|
||||||
|
symbol: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^\d{6}$/, "종목코드는 6자리 숫자여야 합니다."),
|
||||||
|
price: z.coerce.number().positive("기준 가격은 0보다 커야 합니다."),
|
||||||
|
orderType: z.enum(["limit", "market"]).default("market"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||||
|
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
|
||||||
|
|
||||||
|
const hasSession = await hasKisApiSession();
|
||||||
|
if (!hasSession) {
|
||||||
|
return createKisApiErrorResponse({
|
||||||
|
status: 401,
|
||||||
|
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||||
|
message: "로그인이 필요합니다.",
|
||||||
|
tradingEnv,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasKisConfig(credentials)) {
|
||||||
|
return createKisApiErrorResponse({
|
||||||
|
status: 400,
|
||||||
|
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||||
|
message: "KIS API 키 설정이 필요합니다.",
|
||||||
|
tradingEnv,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = readKisAccountParts(request.headers);
|
||||||
|
if (!account) {
|
||||||
|
return createKisApiErrorResponse({
|
||||||
|
status: 400,
|
||||||
|
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
|
||||||
|
message:
|
||||||
|
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
|
||||||
|
tradingEnv,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let rawBody: unknown = {};
|
||||||
|
try {
|
||||||
|
rawBody = (await request.json()) as unknown;
|
||||||
|
} catch {
|
||||||
|
return createKisApiErrorResponse({
|
||||||
|
status: 400,
|
||||||
|
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||||
|
message: "요청 본문(JSON)을 읽을 수 없습니다.",
|
||||||
|
tradingEnv,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = orderableCashBodySchema.safeParse(rawBody);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return createKisApiErrorResponse({
|
||||||
|
status: 400,
|
||||||
|
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||||
|
message: parsed.error.issues[0]?.message ?? "요청값이 올바르지 않습니다.",
|
||||||
|
tradingEnv,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeInquireOrderableCash(
|
||||||
|
{
|
||||||
|
symbol: parsed.data.symbol,
|
||||||
|
price: parsed.data.price,
|
||||||
|
orderType: parsed.data.orderType,
|
||||||
|
accountNo: account.accountNo,
|
||||||
|
accountProductCode: account.accountProductCode,
|
||||||
|
},
|
||||||
|
credentials,
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: DashboardStockOrderableCashResponse = {
|
||||||
|
ok: true,
|
||||||
|
tradingEnv,
|
||||||
|
orderableCash: result.orderableCash,
|
||||||
|
noReceivableBuyAmount: result.noReceivableBuyAmount,
|
||||||
|
maxBuyAmount: result.maxBuyAmount,
|
||||||
|
maxBuyQuantity: result.maxBuyQuantity,
|
||||||
|
noReceivableBuyQuantity: result.noReceivableBuyQuantity,
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response, {
|
||||||
|
headers: {
|
||||||
|
"cache-control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return createKisApiErrorResponse({
|
||||||
|
status: 500,
|
||||||
|
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||||
|
message: toKisApiErrorMessage(error, "매수가능금액 조회 중 오류가 발생했습니다."),
|
||||||
|
tradingEnv,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
163
app/api/kis/domestic/orderbook/route.ts
Normal file
163
app/api/kis/domestic/orderbook/route.ts
Normal 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}`];
|
||||||
|
}
|
||||||
98
app/api/kis/domestic/overview/route.ts
Normal file
98
app/api/kis/domestic/overview/route.ts
Normal 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);
|
||||||
|
}
|
||||||
122
app/api/kis/domestic/search/route.ts
Normal file
122
app/api/kis/domestic/search/route.ts
Normal 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;
|
||||||
|
}
|
||||||
64
app/api/kis/indices/route.ts
Normal file
64
app/api/kis/indices/route.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* @file app/api/kis/indices/route.ts
|
||||||
|
* @description 국내 KOSPI/KOSDAQ 지수 조회 API
|
||||||
|
*
|
||||||
|
* @description [주요 책임]
|
||||||
|
* - 로그인 및 KIS API 설정 여부 확인
|
||||||
|
* - `getDomesticDashboardIndices` 함수를 호출하여 지수 데이터를 조회
|
||||||
|
* - 조회된 데이터를 클라이언트에 JSON 형식으로 반환
|
||||||
|
*/
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { hasKisConfig } from "@/lib/kis/config";
|
||||||
|
import { getDomesticDashboardIndices } from "@/lib/kis/dashboard";
|
||||||
|
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||||
|
import {
|
||||||
|
createKisApiErrorResponse,
|
||||||
|
KIS_API_ERROR_CODE,
|
||||||
|
toKisApiErrorMessage,
|
||||||
|
} from "@/app/api/kis/_response";
|
||||||
|
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const hasSession = await hasKisApiSession();
|
||||||
|
if (!hasSession) {
|
||||||
|
return createKisApiErrorResponse({
|
||||||
|
status: 401,
|
||||||
|
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||||
|
message: "로그인이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||||
|
|
||||||
|
if (!hasKisConfig(credentials)) {
|
||||||
|
return createKisApiErrorResponse({
|
||||||
|
status: 400,
|
||||||
|
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||||
|
message: "KIS API 키 설정이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const indices = await getDomesticDashboardIndices(credentials);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
indices,
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"cache-control": "no-store",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return createKisApiErrorResponse({
|
||||||
|
status: 500,
|
||||||
|
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||||
|
message: toKisApiErrorMessage(
|
||||||
|
error,
|
||||||
|
"지수 조회 중 오류가 발생했습니다.",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
65
app/api/kis/revoke/route.ts
Normal file
65
app/api/kis/revoke/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
247
app/api/kis/validate-profile/route.ts
Normal file
247
app/api/kis/validate-profile/route.ts
Normal 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
|
||||||
|
: "알 수 없는 오류가 발생했습니다.";
|
||||||
|
}
|
||||||
65
app/api/kis/validate/route.ts
Normal file
65
app/api/kis/validate/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
71
app/api/kis/ws/approval/route.ts
Normal file
71
app/api/kis/ws/approval/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,33 @@
|
|||||||
import { createClient } from "@/utils/supabase/server";
|
import { createClient } from "@/utils/supabase/server";
|
||||||
import { NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server"; // NextRequest 추가
|
||||||
import { AUTH_ERROR_MESSAGES, AUTH_ROUTES } from "@/features/auth/constants";
|
import { AUTH_ERROR_MESSAGES, AUTH_ROUTES } from "@/features/auth/constants";
|
||||||
|
import { getAuthErrorMessage } from "@/features/auth/errors";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [인증 콜백 라우트 핸들러]
|
* OAuth/이메일 인증 콜백 처리
|
||||||
*
|
*
|
||||||
* Supabase 인증 이메일(회원가입 확인, 비밀번호 재설정 등) 및 OAuth(소셜 로그인)
|
* Supabase 인증 후 리다이렉트되는 라우트입니다.
|
||||||
* 리다이렉트될 때 호출되는 API 라우트입니다.
|
* - 인증 코드를 세션으로 교환합니다.
|
||||||
|
* - 인증 에러를 처리합니다.
|
||||||
|
* - 최종 목적지(Next URL)로 리다이렉트합니다.
|
||||||
*/
|
*/
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams, origin } = new URL(request.url);
|
// --------------------------------------------------------------------------
|
||||||
|
// 1. 요청 파라미터 및 URL 준비
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
const requestUrl = request.nextUrl.clone(); // URL 조작을 위해 복제
|
||||||
|
const code = requestUrl.searchParams.get("code");
|
||||||
|
const next = requestUrl.searchParams.get("next") ?? AUTH_ROUTES.HOME;
|
||||||
|
|
||||||
// 1. URL에서 주요 직접 파라미터 및 에러 추출
|
// 에러 파라미터 확인
|
||||||
const code = searchParams.get("code");
|
const error = requestUrl.searchParams.get("error");
|
||||||
const next = searchParams.get("next") ?? AUTH_ROUTES.HOME;
|
const error_code = requestUrl.searchParams.get("error_code");
|
||||||
const error = searchParams.get("error");
|
const error_description = requestUrl.searchParams.get("error_description");
|
||||||
const error_code = searchParams.get("error_code");
|
const origin = requestUrl.origin;
|
||||||
const error_description = searchParams.get("error_description");
|
|
||||||
|
|
||||||
// 2. 인증 오류가 있는 경우 (예: 구글 로그인 취소 등)
|
// --------------------------------------------------------------------------
|
||||||
|
// 2. 초기 에러 처리 (Provider 레벨 에러)
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("Auth callback error parameter:", {
|
console.error("Auth callback error parameter:", {
|
||||||
error,
|
error,
|
||||||
@@ -26,45 +35,83 @@ export async function GET(request: Request) {
|
|||||||
error_description,
|
error_description,
|
||||||
});
|
});
|
||||||
|
|
||||||
let message = AUTH_ERROR_MESSAGES.DEFAULT;
|
let message: string = AUTH_ERROR_MESSAGES.DEFAULT;
|
||||||
|
|
||||||
// 에러 종류에 따른 메시지 분기
|
|
||||||
if (error === "access_denied") {
|
if (error === "access_denied") {
|
||||||
message = AUTH_ERROR_MESSAGES.OAUTH_ACCESS_DENIED;
|
message = AUTH_ERROR_MESSAGES.OAUTH_ACCESS_DENIED;
|
||||||
} else if (error === "server_error") {
|
} else if (error === "server_error") {
|
||||||
message = AUTH_ERROR_MESSAGES.OAUTH_SERVER_ERROR;
|
message = AUTH_ERROR_MESSAGES.OAUTH_SERVER_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 로그인 페이지로 에러와 함께 이동
|
||||||
return NextResponse.redirect(
|
return NextResponse.redirect(
|
||||||
`${origin}${AUTH_ROUTES.LOGIN}?message=${encodeURIComponent(message)}`,
|
`${origin}${AUTH_ROUTES.LOGIN}?message=${encodeURIComponent(message)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. code가 있으면 세션으로 교환 (정상 플로우)
|
// --------------------------------------------------------------------------
|
||||||
|
// 3. 인증 코드 교환 (Supabase 공식 패턴 적용)
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
if (code) {
|
if (code) {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
// 코드 교환 실행
|
||||||
const { error: exchangeError } =
|
const { error: exchangeError } =
|
||||||
await supabase.auth.exchangeCodeForSession(code);
|
await supabase.auth.exchangeCodeForSession(code);
|
||||||
|
|
||||||
if (!exchangeError) {
|
if (!exchangeError) {
|
||||||
// 세션 교환 성공 - 원래 목적지로 리다이렉트
|
// ----------------------------------------------------------------------
|
||||||
const forwardedHost = request.headers.get("x-forwarded-host");
|
// 3-1. 교환 성공: 리다이렉트 처리
|
||||||
|
// code 교환으로 세션이 생성된 상태입니다.
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// 회원가입 인증 여부 확인 (쿼리 파라미터 기반)
|
||||||
|
// actions.ts의 signup 함수에서 emailRedirectTo에 auth_type=signup을 추가해서 보냅니다.
|
||||||
|
const authType = requestUrl.searchParams.get("auth_type");
|
||||||
|
const isSignupVerification = authType === "signup";
|
||||||
|
|
||||||
|
// 회원가입 인증인 경우:
|
||||||
|
// 이메일 인증만 완료하고, 자동 로그인된 세션은 종료시킨 뒤 로그인 페이지로 보냅니다.
|
||||||
|
if (isSignupVerification) {
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
return NextResponse.redirect(
|
||||||
|
`${origin}${AUTH_ROUTES.LOGIN}?message=${encodeURIComponent(
|
||||||
|
AUTH_ERROR_MESSAGES.EMAIL_VERIFIED_SUCCESS,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그 외 일반적인 로그인/인증인 경우:
|
||||||
|
// 코드 파라미터 등을 제거하고 깨끗한 URL로 이동합니다.
|
||||||
|
const forwardedHost = request.headers.get("x-forwarded-host"); // 로드밸런서 지원
|
||||||
const isLocalEnv = process.env.NODE_ENV === "development";
|
const isLocalEnv = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
|
// 리다이렉트할 최종 URL 설정
|
||||||
if (isLocalEnv) {
|
if (isLocalEnv) {
|
||||||
|
// 로컬 개발 환경
|
||||||
return NextResponse.redirect(`${origin}${next}`);
|
return NextResponse.redirect(`${origin}${next}`);
|
||||||
} else if (forwardedHost) {
|
} else if (forwardedHost) {
|
||||||
|
// 프로덕션 환경 (Vercel 등 프록시 뒤)
|
||||||
return NextResponse.redirect(`https://${forwardedHost}${next}`);
|
return NextResponse.redirect(`https://${forwardedHost}${next}`);
|
||||||
} else {
|
} else {
|
||||||
|
// 기본
|
||||||
return NextResponse.redirect(`${origin}${next}`);
|
return NextResponse.redirect(`${origin}${next}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 세션 교환 실패 시 로그 및 에러 메시지 설정
|
// ------------------------------------------------------------------------
|
||||||
|
// 3-2. 교환 실패: 에러 처리
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
console.error("Auth exchange error:", exchangeError.message);
|
console.error("Auth exchange error:", exchangeError.message);
|
||||||
|
const message = getAuthErrorMessage(exchangeError);
|
||||||
|
return NextResponse.redirect(
|
||||||
|
`${origin}${AUTH_ROUTES.LOGIN}?message=${encodeURIComponent(message)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. code가 없거나 교환 실패 시 기본 에러 페이지로 리다이렉트
|
// --------------------------------------------------------------------------
|
||||||
|
// 4. 잘못된 접근 처리
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
const errorMessage = encodeURIComponent(
|
const errorMessage = encodeURIComponent(
|
||||||
AUTH_ERROR_MESSAGES.INVALID_AUTH_LINK,
|
AUTH_ERROR_MESSAGES.INVALID_AUTH_LINK,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,76 +1,84 @@
|
|||||||
import { createClient } from "@/utils/supabase/server";
|
import { createClient } from "@/utils/supabase/server";
|
||||||
import { AUTH_ERROR_MESSAGES } from "@/features/auth/constants";
|
import {
|
||||||
|
AUTH_ERROR_MESSAGES,
|
||||||
|
AUTH_ROUTES,
|
||||||
|
RECOVERY_COOKIE_MAX_AGE_SECONDS,
|
||||||
|
RECOVERY_COOKIE_NAME,
|
||||||
|
} from "@/features/auth/constants";
|
||||||
|
import { getAuthErrorMessage } from "@/features/auth/errors";
|
||||||
import { type EmailOtpType } from "@supabase/supabase-js";
|
import { type EmailOtpType } from "@supabase/supabase-js";
|
||||||
import { redirect } from "next/navigation";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
import { type NextRequest } from "next/server";
|
|
||||||
|
|
||||||
// ========================================
|
const RESET_PASSWORD_PATH = AUTH_ROUTES.RESET_PASSWORD;
|
||||||
// 상수 정의
|
const LOGIN_PATH = AUTH_ROUTES.LOGIN;
|
||||||
// ========================================
|
|
||||||
|
|
||||||
/** 비밀번호 재설정 후 이동할 경로 */
|
|
||||||
const RESET_PASSWORD_PATH = "/reset-password";
|
|
||||||
|
|
||||||
/** 인증 실패 시 리다이렉트할 경로 */
|
|
||||||
const LOGIN_PATH = "/login";
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 라우트 핸들러
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [이메일 인증 확인 라우트]
|
* 이메일 인증(/auth/confirm) 처리
|
||||||
*
|
* - token_hash + type 검증
|
||||||
* Supabase 이메일 템플릿의 인증 링크를 처리합니다.
|
* - recovery 타입일 경우 세션 쿠키 설정 후 비밀번호 재설정 페이지로 리다이렉트
|
||||||
* - 회원가입 이메일 확인
|
|
||||||
* - 비밀번호 재설정
|
|
||||||
*
|
|
||||||
* @example Supabase 이메일 템플릿 형식
|
|
||||||
* {{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=recovery
|
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
// 1) 이메일 링크에 들어있는 값 읽기
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
// ========== 파라미터 추출 ==========
|
// token_hash: 인증에 필요한 값
|
||||||
|
// type: 어떤 인증인지 구분 (예: 가입, 비밀번호 재설정)
|
||||||
const tokenHash = searchParams.get("token_hash");
|
const tokenHash = searchParams.get("token_hash");
|
||||||
const type = searchParams.get("type") as EmailOtpType | null;
|
const type = searchParams.get("type") as EmailOtpType | null;
|
||||||
const rawNext = searchParams.get("next");
|
|
||||||
|
|
||||||
// 보안: 외부 URL 리다이렉트 방지 (상대 경로만 허용)
|
// redirect_to/next: 인증 후에 이동할 주소
|
||||||
const nextPath = rawNext?.startsWith("/") ? rawNext : "/";
|
const rawRedirect =
|
||||||
|
searchParams.get("redirect_to") ?? searchParams.get("next");
|
||||||
|
|
||||||
// ========== 토큰 검증 ==========
|
// 보안상 우리 사이트 안 경로(`/...`)만 허용
|
||||||
|
const safeRedirect =
|
||||||
|
rawRedirect && rawRedirect.startsWith("/") ? rawRedirect : null;
|
||||||
|
|
||||||
|
// 일반 인증이 끝난 뒤 이동할 경로
|
||||||
|
const nextPath = safeRedirect ?? AUTH_ROUTES.HOME;
|
||||||
|
// 비밀번호 재설정일 때 이동할 경로
|
||||||
|
const recoveryPath = safeRedirect ?? RESET_PASSWORD_PATH;
|
||||||
|
|
||||||
|
// 필수 값이 없으면 로그인으로 보내고 에러를 보여줌
|
||||||
if (!tokenHash || !type) {
|
if (!tokenHash || !type) {
|
||||||
return redirectWithError(AUTH_ERROR_MESSAGES.INVALID_AUTH_LINK);
|
return redirectWithError(request, AUTH_ERROR_MESSAGES.INVALID_AUTH_LINK);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2) Supabase에게 "이 링크가 맞는지" 확인 요청
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
const { error } = await supabase.auth.verifyOtp({
|
const { error } = await supabase.auth.verifyOtp({
|
||||||
type,
|
type,
|
||||||
token_hash: tokenHash,
|
token_hash: tokenHash,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 확인 실패 시 이유를 알기 쉬운 메시지로 보여줌
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("[Auth Confirm] verifyOtp 실패:", error.message);
|
console.error("[Auth Confirm] verifyOtp error:", error.message);
|
||||||
return redirectWithError(AUTH_ERROR_MESSAGES.INVALID_AUTH_LINK);
|
const message = getAuthErrorMessage(error);
|
||||||
|
return redirectWithError(request, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 검증 성공 - 적절한 페이지로 리다이렉트 ==========
|
// 3) 비밀번호 재설정이면 재설정 페이지로 보내고 쿠키를 저장
|
||||||
if (type === "recovery") {
|
if (type === "recovery") {
|
||||||
redirect(RESET_PASSWORD_PATH);
|
const response = NextResponse.redirect(new URL(recoveryPath, request.url));
|
||||||
|
response.cookies.set(RECOVERY_COOKIE_NAME, "1", {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
maxAge: RECOVERY_COOKIE_MAX_AGE_SECONDS,
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
redirect(nextPath);
|
// 4) 그 외 인증은 기본 경로로 이동
|
||||||
|
return NextResponse.redirect(new URL(nextPath, request.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// 로그인 페이지로 보내면서 에러 메시지를 함께 전달
|
||||||
// 헬퍼 함수
|
function redirectWithError(request: NextRequest, message: string) {
|
||||||
// ========================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 에러 메시지와 함께 로그인 페이지로 리다이렉트
|
|
||||||
*/
|
|
||||||
function redirectWithError(message: string): never {
|
|
||||||
const encodedMessage = encodeURIComponent(message);
|
const encodedMessage = encodeURIComponent(message);
|
||||||
redirect(`${LOGIN_PATH}?message=${encodedMessage}`);
|
return NextResponse.redirect(
|
||||||
|
new URL(`${LOGIN_PATH}?message=${encodedMessage}`, request.url),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
import FormMessage from "@/components/form-message";
|
|
||||||
import { requestPasswordReset } from "@/features/auth/actions";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [비밀번호 찾기 페이지]
|
|
||||||
*
|
|
||||||
* 사용자가 이메일을 입력하면 비밀번호 재설정 링크를 이메일로 발송합니다.
|
|
||||||
* 로그인/회원가입 페이지와 동일한 디자인 시스템 사용
|
|
||||||
*
|
|
||||||
* @param searchParams - URL 쿼리 파라미터 (에러 메시지 전달용)
|
|
||||||
*/
|
|
||||||
export default async function ForgotPasswordPage({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{ message: string }>;
|
|
||||||
}) {
|
|
||||||
// URL에서 메시지 파라미터 추출
|
|
||||||
const { message } = await searchParams;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 via-white to-gray-100 px-4 py-12 dark:from-black dark:via-gray-950 dark:to-gray-900">
|
|
||||||
{/* ========== 배경 그라디언트 ========== */}
|
|
||||||
<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_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 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 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="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
|
||||||
{/* 에러/성공 메시지 */}
|
|
||||||
<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">
|
|
||||||
<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-gradient-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
|
||||||
<span className="text-4xl">🔑</span>
|
|
||||||
</div>
|
|
||||||
{/* 페이지 제목 */}
|
|
||||||
<CardTitle className="text-3xl font-bold tracking-tight">
|
|
||||||
비밀번호 찾기
|
|
||||||
</CardTitle>
|
|
||||||
{/* 페이지 설명 */}
|
|
||||||
<CardDescription className="text-base">
|
|
||||||
가입하신 이메일 주소를 입력하시면
|
|
||||||
<br />
|
|
||||||
비밀번호 재설정 링크를 보내드립니다.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{/* ========== 폼 영역 ========== */}
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{/* 비밀번호 재설정 요청 폼 */}
|
|
||||||
<form className="space-y-5">
|
|
||||||
{/* ========== 이메일 입력 필드 ========== */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="email" className="text-sm font-medium">
|
|
||||||
이메일
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="your@email.com"
|
|
||||||
autoComplete="email"
|
|
||||||
required
|
|
||||||
className="h-11 transition-all duration-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ========== 재설정 링크 발송 버튼 ========== */}
|
|
||||||
<Button
|
|
||||||
formAction={requestPasswordReset}
|
|
||||||
className="h-11 w-full bg-gradient-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"
|
|
||||||
>
|
|
||||||
재설정 링크 보내기
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* ========== 로그인 페이지로 돌아가기 ========== */}
|
|
||||||
<div className="text-center">
|
|
||||||
<Link
|
|
||||||
href="/login"
|
|
||||||
className="text-sm font-medium text-gray-700 hover:text-black dark:text-gray-300 dark:hover:text-white"
|
|
||||||
>
|
|
||||||
← 로그인 페이지로 돌아가기
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
117
app/globals.css
117
app/globals.css
@@ -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,8 +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-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);
|
||||||
@@ -37,6 +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: var(--brand-50);
|
||||||
|
--color-brand-100: var(--brand-100);
|
||||||
|
--color-brand-200: var(--brand-200);
|
||||||
|
--color-brand-300: var(--brand-300);
|
||||||
|
--color-brand-400: var(--brand-400);
|
||||||
|
--color-brand-500: var(--brand-500);
|
||||||
|
--color-brand-600: var(--brand-600);
|
||||||
|
--color-brand-700: var(--brand-700);
|
||||||
|
--color-brand-800: var(--brand-800);
|
||||||
|
--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);
|
||||||
@@ -44,9 +56,58 @@
|
|||||||
--radius-2xl: calc(var(--radius) + 8px);
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
--radius-3xl: calc(var(--radius) + 12px);
|
--radius-3xl: calc(var(--radius) + 12px);
|
||||||
--radius-4xl: calc(var(--radius) + 16px);
|
--radius-4xl: calc(var(--radius) + 16px);
|
||||||
|
|
||||||
|
--animate-gradient-x: gradient-x 15s ease infinite;
|
||||||
|
|
||||||
|
@keyframes gradient-x {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
background-size: 200% 200%;
|
||||||
|
background-position: left center;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-size: 200% 200%;
|
||||||
|
background-position: right center;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
: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);
|
||||||
@@ -54,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.205 0 0);
|
--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);
|
||||||
@@ -65,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.708 0 0);
|
--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);
|
||||||
@@ -73,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.205 0 0);
|
--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);
|
||||||
@@ -82,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.922 0 0);
|
--primary: var(--brand-600);
|
||||||
--primary-foreground: oklch(0.205 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.556 0 0);
|
--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.488 0.243 264.376);
|
--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 {
|
||||||
@@ -122,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* @file app/layout.tsx
|
||||||
|
* @description 애플리케이션의 최상위 루트 레이아웃 (RootLayout)
|
||||||
|
* @remarks
|
||||||
|
* - [레이어] Infrastructure/Layout
|
||||||
|
* - [역할] 전역 스타일(Font/CSS), 테마(Provider), 세션 관리(Manager) 초기화
|
||||||
|
* - [데이터 흐름] Providers -> Children
|
||||||
|
* - [연관 파일] globals.css, theme-provider.tsx
|
||||||
|
*/
|
||||||
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } 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 { 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({
|
||||||
@@ -14,21 +37,50 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "JOORIN-E (주린이) - 감이 아닌 전략으로 시작하는 자동매매",
|
||||||
description: "Generated by create next app",
|
description:
|
||||||
|
"주린이를 위한 자동매매 파트너 JOORIN-E. 손실 방어 규칙, 데이터 신호 분석, 실시간 자동 실행으로 초보의 첫 수익 루틴을 만듭니다.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RootLayout 컴포넌트
|
||||||
|
* @param children 렌더링할 자식 컴포넌트
|
||||||
|
* @returns HTML 구조 및 전역 Provider 래퍼
|
||||||
|
* @see theme-provider.tsx - 다크모드 지원
|
||||||
|
* @see session-manager.tsx - 세션 타임아웃 감지
|
||||||
|
*/
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html
|
||||||
|
lang="en"
|
||||||
|
className="scroll-smooth"
|
||||||
|
data-scroll-behavior="smooth"
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${notoSansKr.variable} ${geistMono.variable} ${gowunDodum.variable} font-sans antialiased`}
|
||||||
>
|
>
|
||||||
<QueryProvider>{children}</QueryProvider>
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
<SessionManager />
|
||||||
|
<GlobalAlertModal />
|
||||||
|
<QueryProvider>{children}</QueryProvider>
|
||||||
|
<Toaster
|
||||||
|
richColors
|
||||||
|
position="top-right"
|
||||||
|
toastOptions={{
|
||||||
|
duration: 4000,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import FormMessage from "@/components/form-message";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import LoginForm from "@/features/auth/components/login-form";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [로그인 페이지 컴포넌트]
|
|
||||||
*
|
|
||||||
* Modern UI with glassmorphism effect (유리 형태 디자인)
|
|
||||||
* - 투명 배경 + 블러 효과로 깊이감 표현
|
|
||||||
* - 그라디언트 배경으로 생동감 추가
|
|
||||||
* - shadcn/ui 컴포넌트로 일관된 디자인 시스템 유지
|
|
||||||
*
|
|
||||||
* @param searchParams - URL 쿼리 파라미터 (에러 메시지 전달용)
|
|
||||||
*/
|
|
||||||
export default async function LoginPage({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{ message: string }>;
|
|
||||||
}) {
|
|
||||||
// URL에서 메시지 파라미터 추출 (로그인 실패 시 에러 메시지 표시)
|
|
||||||
const { message } = await searchParams;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 via-white to-gray-100 px-4 py-12 dark:from-black dark:via-gray-950 dark:to-gray-900">
|
|
||||||
{/* ========== 배경 그라디언트 레이어 ========== */}
|
|
||||||
{/* 웹 페이지 전체 배경을 그라디언트로 채웁니다 */}
|
|
||||||
{/* 라이트 모드: 부드러운 그레이 톤 (gray → white → gray) */}
|
|
||||||
{/* 다크 모드: 깊은 블랙 톤으로 고급스러운 느낌 */}
|
|
||||||
|
|
||||||
{/* 추가 그라디언트 효과 1: 우상단에서 시작하는 원형 그라디언트 */}
|
|
||||||
<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" />
|
|
||||||
|
|
||||||
{/* 추가 그라디언트 효과 2: 좌하단에서 시작하는 원형 그라디언트 */}
|
|
||||||
<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" />
|
|
||||||
|
|
||||||
{/* ========== 애니메이션 블러 효과 ========== */}
|
|
||||||
{/* 부드럽게 깜빡이는 원형 블러로 생동감 표현 */}
|
|
||||||
{/* animate-pulse: 1.5초 주기로 opacity 변화 */}
|
|
||||||
<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" />
|
|
||||||
{/* delay-700: 700ms 지연으로 교차 애니메이션 효과 */}
|
|
||||||
<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" />
|
|
||||||
|
|
||||||
{/* ========== 메인 콘텐츠 영역 ========== */}
|
|
||||||
{/* z-10: 배경보다 위에 표시 */}
|
|
||||||
{/* animate-in: 페이지 로드 시 fade-in + slide-up 애니메이션 */}
|
|
||||||
<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} />
|
|
||||||
|
|
||||||
{/* ========== 로그인 카드 (Glassmorphism) ========== */}
|
|
||||||
{/* 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">
|
|
||||||
{/* 아이콘 배경: 그라디언트 원형 */}
|
|
||||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
|
||||||
<span className="text-4xl">👋</span>
|
|
||||||
</div>
|
|
||||||
{/* 페이지 제목 */}
|
|
||||||
<CardTitle className="text-3xl font-bold tracking-tight">
|
|
||||||
환영합니다!
|
|
||||||
</CardTitle>
|
|
||||||
{/* 페이지 설명 */}
|
|
||||||
<CardDescription className="text-base">
|
|
||||||
서비스 이용을 위해 로그인해 주세요.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{/* ========== 카드 콘텐츠 영역 (폼) ========== */}
|
|
||||||
<CardContent>
|
|
||||||
<LoginForm />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
114
app/page.tsx
114
app/page.tsx
@@ -1,114 +0,0 @@
|
|||||||
import Image from "next/image";
|
|
||||||
import { signout } from "@/features/auth/actions";
|
|
||||||
import { createClient } from "@/utils/supabase/server";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [메인 페이지 컴포넌트]
|
|
||||||
*
|
|
||||||
* 로그인한 사용자만 접근 가능 (Middleware에서 보호)
|
|
||||||
* - 사용자 정보 표시 (이메일, 프로필 아바타)
|
|
||||||
* - 로그아웃 버튼 제공
|
|
||||||
*/
|
|
||||||
export default async function Home() {
|
|
||||||
// 현재 로그인한 사용자 정보 가져오기
|
|
||||||
// Middleware에서 이미 인증을 확인했으므로 여기서는 user가 항상 존재함
|
|
||||||
const supabase = await createClient();
|
|
||||||
const {
|
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
|
||||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
|
||||||
{/* ========== 헤더: 로그인 정보 및 로그아웃 버튼 ========== */}
|
|
||||||
<div className="flex w-full items-center justify-between">
|
|
||||||
{/* 사용자 프로필 표시 */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* 프로필 아바타: 이메일 첫 글자 표시 */}
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 text-white font-semibold">
|
|
||||||
{user?.email?.charAt(0).toUpperCase() || "U"}
|
|
||||||
</div>
|
|
||||||
{/* 이메일 및 로그인 상태 텍스트 */}
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
|
||||||
{user?.email || "사용자"}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
로그인됨
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* 로그아웃 폼 */}
|
|
||||||
{/* formAction: 서버 액션(signout)을 호출하여 로그아웃 처리 */}
|
|
||||||
<form>
|
|
||||||
<Button
|
|
||||||
formAction={signout}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
로그아웃
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import FormMessage from "@/components/form-message";
|
|
||||||
import ResetPasswordForm from "@/features/auth/components/reset-password-form";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { createClient } from "@/utils/supabase/server";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [비밀번호 재설정 페이지]
|
|
||||||
*
|
|
||||||
* 이메일 링크를 통해 접근한 사용자만 새 비밀번호를 설정할 수 있습니다.
|
|
||||||
* Supabase recovery 세션 검증으로 직접 URL 접근 차단
|
|
||||||
*
|
|
||||||
* PKCE 플로우:
|
|
||||||
* 1. 사용자가 비밀번호 재설정 이메일의 링크 클릭
|
|
||||||
* 2. Supabase가 토큰 검증 후 이 페이지로 ?code=xxx 파라미터와 함께 리다이렉트
|
|
||||||
* 3. 이 페이지에서 code를 세션으로 교환
|
|
||||||
* 4. 유효한 세션이 있으면 비밀번호 재설정 폼 표시
|
|
||||||
*
|
|
||||||
* @param searchParams - URL 쿼리 파라미터 (code: PKCE 코드, message: 에러/성공 메시지)
|
|
||||||
*/
|
|
||||||
export default async function ResetPasswordPage({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{ message?: string; code?: string }>;
|
|
||||||
}) {
|
|
||||||
const params = await searchParams;
|
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
// 1. 이메일 링크에서 code 파라미터 확인
|
|
||||||
// Supabase는 이메일 링크를 통해 ?code=xxx 형태로 PKCE code를 전달합니다
|
|
||||||
if (params.code) {
|
|
||||||
// code를 세션으로 교환
|
|
||||||
const { error } = await supabase.auth.exchangeCodeForSession(params.code);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
// code 교환 실패 (만료되었거나 유효하지 않음)
|
|
||||||
console.error("Password reset code exchange error:", error.message);
|
|
||||||
const message = encodeURIComponent(
|
|
||||||
"비밀번호 재설정 링크가 만료되었거나 유효하지 않습니다.",
|
|
||||||
);
|
|
||||||
redirect(`/login?message=${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// code 교환 성공 - code 없이 같은 페이지로 리다이렉트
|
|
||||||
// (URL을 깨끗하게 유지하고 세션 쿠키가 설정된 상태로 리로드)
|
|
||||||
redirect("/reset-password");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 세션 확인
|
|
||||||
const {
|
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
// 3. 유효한 세션이 없으면 로그인 페이지로 리다이렉트
|
|
||||||
if (!user) {
|
|
||||||
const message = encodeURIComponent(
|
|
||||||
"비밀번호 재설정 링크가 만료되었거나 유효하지 않습니다.",
|
|
||||||
);
|
|
||||||
redirect(`/login?message=${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// URL에서 메시지 파라미터 추출
|
|
||||||
const { message } = params;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 via-white to-gray-100 px-4 py-12 dark:from-black dark:via-gray-950 dark:to-gray-900">
|
|
||||||
{/* ========== 배경 그라디언트 ========== */}
|
|
||||||
<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_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 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 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="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
|
||||||
{/* 에러/성공 메시지 */}
|
|
||||||
{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">
|
|
||||||
<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-gradient-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
|
||||||
<span className="text-4xl">🔐</span>
|
|
||||||
</div>
|
|
||||||
{/* 페이지 제목 */}
|
|
||||||
<CardTitle className="text-3xl font-bold tracking-tight">
|
|
||||||
비밀번호 재설정
|
|
||||||
</CardTitle>
|
|
||||||
{/* 페이지 설명 */}
|
|
||||||
<CardDescription className="text-base">
|
|
||||||
새로운 비밀번호를 입력해 주세요.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{/* ========== 폼 영역 ========== */}
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<ResetPasswordForm />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import FormMessage from "@/components/form-message";
|
|
||||||
import SignupForm from "@/features/auth/components/signup-form";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
|
|
||||||
export default async function SignupPage({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{ message: string }>;
|
|
||||||
}) {
|
|
||||||
const { message } = await searchParams;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 via-white to-gray-100 px-4 py-12 dark:from-black dark:via-gray-950 dark:to-gray-900">
|
|
||||||
{/* 배경 그라데이션 효과 */}
|
|
||||||
<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_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 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 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="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
|
||||||
{/* 메시지 알림 */}
|
|
||||||
<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">
|
|
||||||
<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-gradient-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
|
||||||
<span className="text-4xl">🚀</span>
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-3xl font-bold tracking-tight">
|
|
||||||
회원가입
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-base">
|
|
||||||
몇 가지 정보만 입력하면 바로 시작할 수 있습니다.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{/* ========== 폼 영역 ========== */}
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<SignupForm />
|
|
||||||
|
|
||||||
{/* ========== 로그인 링크 ========== */}
|
|
||||||
<p className="text-center text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
이미 계정이 있으신가요?{" "}
|
|
||||||
<Link
|
|
||||||
href="/login"
|
|
||||||
className="font-semibold text-gray-900 transition-colors hover:text-black dark:text-gray-100 dark:hover:text-white"
|
|
||||||
>
|
|
||||||
로그인 하러 가기
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
30
common-docs/api-reference/kis-error-code-reference.md
Normal file
30
common-docs/api-reference/kis-error-code-reference.md
Normal 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 기준으로 맵에 추가합니다.
|
||||||
466
common-docs/api-reference/kis_api_reference.md
Normal file
466
common-docs/api-reference/kis_api_reference.md
Normal 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`
|
||||||
BIN
common-docs/api-reference/openapi_all.xlsx
Normal file
BIN
common-docs/api-reference/openapi_all.xlsx
Normal file
Binary file not shown.
266
common-docs/features-autotrade-design.md
Normal file
266
common-docs/features-autotrade-design.md
Normal 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 키/계좌 정보는 서버 저장을 금지하고 요청 단위 처리만 허용합니다.
|
||||||
82
common-docs/features/autotrade-model-catalog-runbook.md
Normal file
82
common-docs/features/autotrade-model-catalog-runbook.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# 자동매매 모델 카탈로그 운영 런북 (Codex/Gemini)
|
||||||
|
|
||||||
|
이 문서는 **새 모델이 나왔을 때** 자동매매 모델 선택 UI/서버 설정을 안전하게 갱신하기 위한 운영 절차입니다.
|
||||||
|
|
||||||
|
## 1) 목적
|
||||||
|
|
||||||
|
1. Codex/Gemini 신모델을 빠르게 목록에 반영한다.
|
||||||
|
2. 잘못된 모델 ID로 인해 자동매매가 fallback으로 떨어지는 문제를 줄인다.
|
||||||
|
3. 운영자가 "어디를 고치고 어떻게 검증하는지"를 한 번에 확인할 수 있게 한다.
|
||||||
|
|
||||||
|
## 2) 적용 범위
|
||||||
|
|
||||||
|
1. 자동매매 설정창 모델 드롭다운
|
||||||
|
2. 서버 모델 선택 우선순위(env + UI)
|
||||||
|
3. 전략/신호 응답에서 `providerVendor`, `providerModel` 추적
|
||||||
|
|
||||||
|
## 3) 빠른 절차 (입력 -> 처리 -> 결과)
|
||||||
|
|
||||||
|
1. 입력: 공식 문서에서 신규 모델 ID 확인
|
||||||
|
2. 처리: 모델 옵션 상수 + 안내 문구 + 기본 env 값 점검
|
||||||
|
3. 결과: UI 선택 가능 + 로그/응답에서 실제 모델 확인 가능
|
||||||
|
|
||||||
|
## 4) 공식 소스(항상 여기 먼저 확인)
|
||||||
|
|
||||||
|
1. OpenAI Codex CLI: <https://developers.openai.com/codex/cli>
|
||||||
|
2. OpenAI Models: <https://platform.openai.com/docs/models>
|
||||||
|
3. Gemini CLI model command: <https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/model.md>
|
||||||
|
4. Gemini CLI model routing: <https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/model-routing.md>
|
||||||
|
5. Gemini API models: <https://ai.google.dev/gemini-api/docs/models>
|
||||||
|
|
||||||
|
## 5) 코드 반영 위치
|
||||||
|
|
||||||
|
1. 모델 드롭다운 목록
|
||||||
|
- `features/autotrade/types/autotrade.types.ts`
|
||||||
|
- `AUTOTRADE_SUBSCRIPTION_CLI_MODEL_OPTIONS.codex`
|
||||||
|
- `AUTOTRADE_SUBSCRIPTION_CLI_MODEL_OPTIONS.gemini`
|
||||||
|
2. 기본값/우선순위 점검
|
||||||
|
- `lib/autotrade/strategy.ts` (`resolveDefaultSubscriptionCliModel`)
|
||||||
|
- `lib/autotrade/cli-provider.ts` (`resolveSubscriptionCliModel`)
|
||||||
|
3. 사용자 안내 문구(필요 시)
|
||||||
|
- `features/autotrade/components/AutotradeControlPanel.tsx`
|
||||||
|
4. 샘플 환경변수 문서화
|
||||||
|
- `.env.example`
|
||||||
|
|
||||||
|
## 6) 모델 추가 규칙
|
||||||
|
|
||||||
|
1. 모델 ID는 **공식 문서 표기 그대로** 입력한다.
|
||||||
|
2. preview 모델은 라벨에 `(프리뷰)`를 명시한다.
|
||||||
|
3. 종료 예정 모델은 라벨/설명에 종료 예정일을 남긴다.
|
||||||
|
4. 기존 안정형 모델 1개 이상은 항상 남겨둔다.
|
||||||
|
5. 목록에 없는 모델도 쓸 수 있도록 `직접 입력` 경로는 유지한다.
|
||||||
|
|
||||||
|
## 7) 검증 체크리스트
|
||||||
|
|
||||||
|
- [ ] 드롭다운에 신규 모델이 보인다.
|
||||||
|
- [ ] 신규 모델 선택 후 compile/signal 요청 payload에 `subscriptionCliModel`이 들어간다.
|
||||||
|
- [ ] 응답에 `providerVendor`, `providerModel`이 기대값으로 온다.
|
||||||
|
- [ ] 자동매매 로그에 `subscription_cli:vendor:model`이 표시된다.
|
||||||
|
- [ ] `npm run -s lint` 통과
|
||||||
|
|
||||||
|
## 8) 수동 검증 포인트(화면 기준)
|
||||||
|
|
||||||
|
1. 자동매매 설정 -> 구독형 CLI 엔진 선택(codex 또는 gemini)
|
||||||
|
2. 신규 모델 선택 후 자동매매 시작
|
||||||
|
3. 로그에서 아래 3개 필드 확인
|
||||||
|
- `subscriptionCliVendor`
|
||||||
|
- `subscriptionCliModel`
|
||||||
|
- `providerModel`
|
||||||
|
|
||||||
|
## 9) 장애 대응
|
||||||
|
|
||||||
|
1. 모델 호출 실패 시 우선 `직접 입력`으로 동일 ID 재시도
|
||||||
|
2. 계속 실패하면 직전 안정 모델로 즉시 롤백
|
||||||
|
3. `AUTOTRADE_SUBSCRIPTION_CLI_DEBUG=1`로 서버 로그에서 CLI stderr 확인
|
||||||
|
|
||||||
|
## 10) 변경 이력 템플릿
|
||||||
|
|
||||||
|
```md
|
||||||
|
- YYYY-MM-DD: [vendor] modelA, modelB 추가
|
||||||
|
- YYYY-MM-DD: [vendor] modelX 종료 예정 표기
|
||||||
|
- YYYY-MM-DD: 기본 추천 모델 변경 (old -> new)
|
||||||
|
```
|
||||||
144
common-docs/features/autotrade-prompt-flow-guide.md
Normal file
144
common-docs/features/autotrade-prompt-flow-guide.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# 자동매매 프롬프트 흐름 추적 가이드 (UI -> 함수 -> AI -> 주문)
|
||||||
|
|
||||||
|
이 문서는 "전략 프롬프트를 입력하면 실제로 어디 함수로 흘러가고, 어디서 AI가 호출되는지"를 코드 라인 기준으로 설명합니다.
|
||||||
|
|
||||||
|
## 1) 한 줄 요약
|
||||||
|
|
||||||
|
사용자가 UI에 프롬프트를 입력하면, 시작/검증 시점에 `compile` API로 전달되어 전략 JSON으로 바뀌고, 실행 중에는 그 전략 JSON + 실시간 시세로 신호를 생성해 주문 여부를 결정합니다.
|
||||||
|
|
||||||
|
## 2) 구조 그림
|
||||||
|
|
||||||
|
```text
|
||||||
|
[브라우저 UI]
|
||||||
|
AutotradeControlPanel.tsx
|
||||||
|
└─ 프롬프트 입력 + 시작/검증 클릭
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[브라우저 엔진 훅]
|
||||||
|
useAutotradeEngine.ts
|
||||||
|
└─ prepareStrategy()에서 compile/validate 실행
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[브라우저 API 클라이언트]
|
||||||
|
autotrade.api.ts
|
||||||
|
└─ /api/autotrade/strategies/compile 호출
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[Next 서버 route]
|
||||||
|
strategies/compile/route.ts
|
||||||
|
└─ OpenAI / subscription_cli / fallback 분기
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[AI Provider]
|
||||||
|
openai.ts 또는 cli-provider.ts
|
||||||
|
└─ 전략 JSON 반환
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[브라우저 엔진 훅]
|
||||||
|
useAutotradeEngine.ts
|
||||||
|
└─ compiledStrategy 저장 후 실행 루프 시작
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[신호 루프]
|
||||||
|
/api/autotrade/signals/generate -> 리스크 게이트 -> 주문 API
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3) 프롬프트 입력 -> 전략 컴파일 (상세 추적)
|
||||||
|
|
||||||
|
1. 프롬프트 입력 UI
|
||||||
|
- 컴포넌트: [`AutotradeControlPanel.tsx#L335`](../../features/autotrade/components/AutotradeControlPanel.tsx#L335)
|
||||||
|
- 입력 이벤트: [`handlePromptChange`](../../features/autotrade/components/AutotradeControlPanel.tsx#L123)
|
||||||
|
- store 반영: [`patchSetupForm({ prompt })`](../../features/autotrade/components/AutotradeControlPanel.tsx#L126)
|
||||||
|
- 같은 화면에서 구독형 CLI vendor/model도 선택 가능: `subscriptionCliVendor`, `subscriptionCliModel`
|
||||||
|
|
||||||
|
2. 시작/검증 버튼 클릭
|
||||||
|
- 시작 버튼 핸들러: [`handleStartAutotrade`](../../features/autotrade/components/AutotradeControlPanel.tsx#L102)
|
||||||
|
- 검증 버튼 핸들러: [`handlePreviewValidation`](../../features/autotrade/components/AutotradeControlPanel.tsx#L113)
|
||||||
|
|
||||||
|
3. 엔진 훅에서 전략 준비
|
||||||
|
- 함수: [`prepareStrategy()`](../../features/autotrade/hooks/useAutotradeEngine.ts#L138)
|
||||||
|
- compile 호출: [`compileAutotradeStrategy(...)`](../../features/autotrade/hooks/useAutotradeEngine.ts#L153)
|
||||||
|
|
||||||
|
4. 브라우저 API 클라이언트
|
||||||
|
- 함수: [`compileAutotradeStrategy`](../../features/autotrade/apis/autotrade.api.ts#L30)
|
||||||
|
- HTTP 호출: [`POST /api/autotrade/strategies/compile`](../../features/autotrade/apis/autotrade.api.ts#L36)
|
||||||
|
- 전달 필드: `aiMode`, `subscriptionCliVendor`, `subscriptionCliModel`, `prompt`, `selectedTechniques`, `confidenceThreshold`
|
||||||
|
|
||||||
|
5. Next API route에서 provider 분기
|
||||||
|
- 엔드포인트: [`strategies/compile/route.ts#L44`](../../app/api/autotrade/strategies/compile/route.ts#L44)
|
||||||
|
- fallback 전략 준비: [`createFallbackCompiledStrategy`](../../app/api/autotrade/strategies/compile/route.ts#L67)
|
||||||
|
- OpenAI 분기: [`compileStrategyWithOpenAi`](../../app/api/autotrade/strategies/compile/route.ts#L87)
|
||||||
|
- 구독형 CLI 분기: [`compileStrategyWithSubscriptionCliDetailed`](../../app/api/autotrade/strategies/compile/route.ts#L119)
|
||||||
|
|
||||||
|
6. OpenAI 실제 호출 지점
|
||||||
|
- OpenAI 전략 함수: [`compileStrategyWithOpenAi`](../../lib/autotrade/openai.ts#L51)
|
||||||
|
- 공통 호출기: [`callOpenAiJson`](../../lib/autotrade/openai.ts#L203)
|
||||||
|
- 외부 API: [`https://api.openai.com/v1/chat/completions`](../../lib/autotrade/openai.ts#L19)
|
||||||
|
|
||||||
|
7. 컴파일 결과 반영
|
||||||
|
- compiledStrategy 저장: [`setCompiledStrategy(...)`](../../features/autotrade/hooks/useAutotradeEngine.ts#L160)
|
||||||
|
- validate 저장: [`setValidation(...)`](../../features/autotrade/hooks/useAutotradeEngine.ts#L173)
|
||||||
|
|
||||||
|
## 4) 실행 중 "자동 프롬프트"가 도는 방식
|
||||||
|
|
||||||
|
중요: 실행 중 매 틱마다 자연어 프롬프트를 다시 보내지 않습니다.
|
||||||
|
|
||||||
|
1. 시작 시점에만 프롬프트를 전략 JSON으로 컴파일합니다.
|
||||||
|
2. 실행 루프에서는 "컴파일된 전략 JSON + 현재 시세 스냅샷"으로 신호를 만듭니다.
|
||||||
|
|
||||||
|
관련 코드:
|
||||||
|
|
||||||
|
1. 신호 요청 주기(12초): [`SIGNAL_REQUEST_INTERVAL_MS`](../../features/autotrade/hooks/useAutotradeEngine.ts#L51)
|
||||||
|
2. 신호 API 호출: [`generateAutotradeSignal(...)`](../../features/autotrade/hooks/useAutotradeEngine.ts#L495)
|
||||||
|
3. 서버 신호 route: [`signals/generate/route.ts#L74`](../../app/api/autotrade/signals/generate/route.ts#L74)
|
||||||
|
4. 신호 생성 OpenAI 함수: [`generateSignalWithOpenAi`](../../lib/autotrade/openai.ts#L116)
|
||||||
|
|
||||||
|
신호 요청 시 스냅샷 실제 필드:
|
||||||
|
|
||||||
|
1. `symbol`
|
||||||
|
2. `currentPrice`
|
||||||
|
3. `changeRate`
|
||||||
|
4. `open`
|
||||||
|
5. `high`
|
||||||
|
6. `low`
|
||||||
|
7. `tradeVolume`
|
||||||
|
8. `accumulatedVolume`
|
||||||
|
9. `recentPrices`
|
||||||
|
|
||||||
|
## 5) 신호 -> 주문 판단 (자동 실행 핵심)
|
||||||
|
|
||||||
|
1. 신호 생성 결과 수신: [`runtime.setLastSignal(signal)`](../../features/autotrade/hooks/useAutotradeEngine.ts#L504)
|
||||||
|
2. 리스크 게이트 검사: [`evaluateSignalBlockers(...)`](../../features/autotrade/hooks/useAutotradeEngine.ts#L516)
|
||||||
|
3. 통과 시 주문 API 호출: [`fetchOrderCash(...)`](../../features/autotrade/hooks/useAutotradeEngine.ts#L556)
|
||||||
|
|
||||||
|
즉, AI가 `buy/sell`을 주더라도 리스크 게이트를 통과하지 못하면 주문은 실행되지 않습니다.
|
||||||
|
|
||||||
|
## 6) AI를 못 쓰는 경우
|
||||||
|
|
||||||
|
1. 전략 폴백: [`createFallbackCompiledStrategy`](../../lib/autotrade/strategy.ts#L26)
|
||||||
|
2. 신호 폴백: [`createFallbackSignalCandidate`](../../lib/autotrade/strategy.ts#L48)
|
||||||
|
|
||||||
|
AI(OpenAI/CLI) 응답 실패 시에도 시스템이 멈추지 않고 보수적으로 동작하도록 설계되어 있습니다.
|
||||||
|
|
||||||
|
## 7) Codex CLI인지 Gemini CLI인지 확인하는 법
|
||||||
|
|
||||||
|
1. 자동매매 로그에서 확인
|
||||||
|
- `신호 수신 [subscription_cli:codex:gpt-5-codex]` 또는 `신호 수신 [subscription_cli:gemini:flash]`
|
||||||
|
- 로그 코드: [`useAutotradeEngine.ts`](../../features/autotrade/hooks/useAutotradeEngine.ts#L564)
|
||||||
|
|
||||||
|
2. Network 응답에서 확인
|
||||||
|
- 전략 컴파일 응답: `compiledStrategy.providerVendor`
|
||||||
|
- 신호 생성 응답: `signal.providerVendor`
|
||||||
|
|
||||||
|
3. 실패 시 어떤 순서로 시도했는지 확인
|
||||||
|
- 파싱 실패 문구에 `selected=vendor:model; attempts=vendor:model:status` 포함
|
||||||
|
- `status=timeout`이면 CLI 실행시간 초과입니다. `AUTOTRADE_SUBSCRIPTION_CLI_TIMEOUT_MS`를 늘리세요(권장: 60000).
|
||||||
|
- 생성 코드: [`summarizeSubscriptionCliExecution`](../../lib/autotrade/cli-provider.ts#L112)
|
||||||
|
|
||||||
|
4. 모델 선택 환경변수
|
||||||
|
- `AUTOTRADE_CODEX_MODEL` (예: `gpt-5-codex`)
|
||||||
|
- `AUTOTRADE_GEMINI_MODEL` (예: `auto`, `pro`, `flash`, `flash-lite`)
|
||||||
|
- `AUTOTRADE_SUBSCRIPTION_CLI_MODEL` (vendor 전용 값이 없을 때 공통 fallback)
|
||||||
|
|
||||||
|
5. 모델 선택 UI (환경변수보다 우선)
|
||||||
|
- 자동매매 설정창에서 `subscriptionCliVendor`, `subscriptionCliModel` 선택 시 해당 값이 API payload로 전달되어 CLI 실행 인자에 우선 적용됩니다.
|
||||||
407
common-docs/features/autotrade-usage-security-guide.md
Normal file
407
common-docs/features/autotrade-usage-security-guide.md
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
# 자동매매 사용/검증/보안 가이드 (3계층 구조)
|
||||||
|
|
||||||
|
이 문서는 자동매매를 아래 3개 영역으로 나눠서 설명합니다.
|
||||||
|
|
||||||
|
1. 사용자 브라우저
|
||||||
|
2. Next.js 서버(API)
|
||||||
|
3. 워커(Node)
|
||||||
|
|
||||||
|
프롬프트 입력값이 실제로 어디 함수/어디 API로 흘러가는지 추적하려면 아래 문서를 같이 보세요.
|
||||||
|
|
||||||
|
- `common-docs/features/autotrade-prompt-flow-guide.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) 한눈에 구조
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌───────────────────────────── 사용자 브라우저 ─────────────────────────────┐
|
||||||
|
│ /trade 자동매매 UI │
|
||||||
|
│ - 설정 입력(전략/투자금/손실한도/임계치) │
|
||||||
|
│ - start/stop/heartbeat/signals 호출 │
|
||||||
|
└───────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────── Next.js 서버 (API) ──────────────────────────┐
|
||||||
|
│ /api/autotrade/strategies/* │
|
||||||
|
│ /api/autotrade/sessions/* │
|
||||||
|
│ /api/autotrade/signals/generate │
|
||||||
|
│ /api/autotrade/worker/tick │
|
||||||
|
└───────────────────────────────────────────────────────────────────────────┘
|
||||||
|
▲
|
||||||
|
│ x-autotrade-worker-token
|
||||||
|
│
|
||||||
|
┌────────────────────────────── Worker (Node) ─────────────────────────────┐
|
||||||
|
│ scripts/autotrade-worker.mjs │
|
||||||
|
│ - 주기적으로 /api/autotrade/worker/tick 호출 │
|
||||||
|
│ - heartbeat 만료 세션 정리 │
|
||||||
|
└───────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1-1) 개발 실행 시 (내 PC 기준)
|
||||||
|
|
||||||
|
1. 브라우저: React 화면 사용
|
||||||
|
2. Next 개발 서버(`npm run dev`): 화면 + API를 함께 처리
|
||||||
|
3. 워커(`node scripts/autotrade-worker.mjs`): tick 호출 담당
|
||||||
|
|
||||||
|
즉, 개발에서는 보통 `Next 1개 + Worker 1개` 프로세스를 실행합니다.
|
||||||
|
|
||||||
|
## 1-2) 운영 배포 시
|
||||||
|
|
||||||
|
운영은 보통 아래 2가지 중 하나입니다.
|
||||||
|
|
||||||
|
1. 같은 Linux 서버에 Next + Worker 같이 운영
|
||||||
|
2. Next는 배포 플랫폼, Worker는 별도 Linux 서버에서 운영
|
||||||
|
|
||||||
|
공통 원칙:
|
||||||
|
|
||||||
|
1. 브라우저는 Next API를 호출
|
||||||
|
2. 워커도 Next API(`/api/autotrade/worker/tick`)를 호출
|
||||||
|
3. 워커 인증은 `x-autotrade-worker-token`으로 처리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) 레이어별 역할
|
||||||
|
|
||||||
|
## 2-1) 사용자 브라우저
|
||||||
|
|
||||||
|
하는 일:
|
||||||
|
|
||||||
|
1. 자동매매 설정 입력
|
||||||
|
2. 전략 컴파일/검증 요청
|
||||||
|
3. 세션 시작 후 10초마다 heartbeat 전송
|
||||||
|
4. 신호 요청 후 주문 가능 여부 판단
|
||||||
|
5. 브라우저 종료/외부 이동 시 중지 처리
|
||||||
|
|
||||||
|
핵심 소스:
|
||||||
|
|
||||||
|
1. UI: [`AutotradeControlPanel`](../../features/autotrade/components/AutotradeControlPanel.tsx#L25)
|
||||||
|
2. 엔진: [`useAutotradeEngine`](../../features/autotrade/hooks/useAutotradeEngine.ts#L118)
|
||||||
|
3. heartbeat 루프: [`useAutotradeEngine`](../../features/autotrade/hooks/useAutotradeEngine.ts#L336)
|
||||||
|
4. 주문 직전 게이트+주문 호출: [`useAutotradeEngine`](../../features/autotrade/hooks/useAutotradeEngine.ts#L426)
|
||||||
|
|
||||||
|
## 2-2) Next.js 서버(API)
|
||||||
|
|
||||||
|
하는 일:
|
||||||
|
|
||||||
|
1. 사용자 인증 검사
|
||||||
|
2. 전략 compile/validate 처리
|
||||||
|
3. 세션 start/heartbeat/stop/active 관리
|
||||||
|
4. AI 호출 실패 시 폴백 전략/신호로 대응
|
||||||
|
5. 워커 토큰 인증 후 만료 세션 정리
|
||||||
|
|
||||||
|
핵심 소스:
|
||||||
|
|
||||||
|
1. 공통 유틸: [`_shared.ts`](../../app/api/autotrade/_shared.ts)
|
||||||
|
2. compile: [`POST /strategies/compile`](../../app/api/autotrade/strategies/compile/route.ts#L22)
|
||||||
|
3. validate: [`POST /strategies/validate`](../../app/api/autotrade/strategies/validate/route.ts#L19)
|
||||||
|
4. sessions: [`/sessions/start`](../../app/api/autotrade/sessions/start/route.ts#L21), [`/sessions/heartbeat`](../../app/api/autotrade/sessions/heartbeat/route.ts#L18), [`/sessions/stop`](../../app/api/autotrade/sessions/stop/route.ts#L27), [`/sessions/active`](../../app/api/autotrade/sessions/active/route.ts#L9)
|
||||||
|
5. 신호 생성: [`POST /signals/generate`](../../app/api/autotrade/signals/generate/route.ts#L41)
|
||||||
|
|
||||||
|
## 2-3) 워커(Node)
|
||||||
|
|
||||||
|
하는 일:
|
||||||
|
|
||||||
|
1. 주기적으로 Next API `/api/autotrade/worker/tick` 호출
|
||||||
|
2. heartbeat 끊긴 세션을 timeout 종료
|
||||||
|
3. 정리 결과 로그 출력
|
||||||
|
|
||||||
|
핵심 소스:
|
||||||
|
|
||||||
|
1. 워커 스크립트: [`autotrade-worker.mjs`](../../scripts/autotrade-worker.mjs)
|
||||||
|
2. 워커 API: [`POST /worker/tick`](../../app/api/autotrade/worker/tick/route.ts#L12)
|
||||||
|
3. 만료 정리 함수: [`sweepExpiredAutotradeSessions()`](../../app/api/autotrade/_shared.ts#L147)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) 가장 헷갈리는 개념 3개
|
||||||
|
|
||||||
|
## 3-1) 폴백 전략(fallback)
|
||||||
|
|
||||||
|
뜻:
|
||||||
|
|
||||||
|
1. AI를 못 쓰는 상황에서 쓰는 대체 규칙
|
||||||
|
2. 자동매매를 완전 중지하지 않고 보수적으로 유지
|
||||||
|
3. 애매하면 `hold`를 더 자주 반환
|
||||||
|
|
||||||
|
관련 소스:
|
||||||
|
|
||||||
|
1. 전략 폴백: [`createFallbackCompiledStrategy()`](../../lib/autotrade/strategy.ts#L16)
|
||||||
|
2. 신호 폴백: [`createFallbackSignalCandidate()`](../../lib/autotrade/strategy.ts#L36)
|
||||||
|
3. AI 호출: [`callOpenAiJson()`](../../lib/autotrade/openai.ts#L187)
|
||||||
|
|
||||||
|
## 3-2) heartbeat
|
||||||
|
|
||||||
|
뜻:
|
||||||
|
|
||||||
|
1. 브라우저가 Next 서버로 보내는 "세션 살아있음" 신호
|
||||||
|
2. 워커가 보내는 신호가 아님
|
||||||
|
|
||||||
|
## 3-3) worker tick
|
||||||
|
|
||||||
|
뜻:
|
||||||
|
|
||||||
|
1. 워커가 Next 서버로 보내는 "만료 세션 정리 요청"
|
||||||
|
2. heartbeat가 끊긴 세션을 timeout 종료
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3-4) 구독형 CLI 자동판단(신규)
|
||||||
|
|
||||||
|
뜻:
|
||||||
|
|
||||||
|
1. OpenAI API 키 대신 서버에 설치된 `gemini` 또는 `codex` CLI를 호출해 자동판단
|
||||||
|
2. 자동판단 결과(JSON)를 파싱해 전략/신호에 반영
|
||||||
|
3. CLI 호출 실패 또는 파싱 실패 시 규칙 기반으로 자동 폴백
|
||||||
|
|
||||||
|
UI에서 선택:
|
||||||
|
|
||||||
|
1. 자동매매 설정창에서 `구독형 CLI 엔진`을 `auto/codex/gemini` 중 선택
|
||||||
|
2. `codex` 또는 `gemini` 선택 시 공식 문서 기반 추천 모델 목록을 드롭다운으로 선택
|
||||||
|
3. 목록에 없는 최신 모델은 `직접 입력`으로 설정
|
||||||
|
|
||||||
|
모델 우선순위:
|
||||||
|
|
||||||
|
1. UI에서 선택한 모델(있을 때)
|
||||||
|
2. `AUTOTRADE_CODEX_MODEL` / `AUTOTRADE_GEMINI_MODEL`
|
||||||
|
3. `AUTOTRADE_SUBSCRIPTION_CLI_MODEL`
|
||||||
|
4. 각 CLI 기본 모델
|
||||||
|
|
||||||
|
환경변수:
|
||||||
|
|
||||||
|
```env
|
||||||
|
AUTOTRADE_AI_MODE=subscription_cli
|
||||||
|
AUTOTRADE_SUBSCRIPTION_CLI=auto
|
||||||
|
AUTOTRADE_SUBSCRIPTION_CLI_MODEL=
|
||||||
|
AUTOTRADE_CODEX_MODEL=
|
||||||
|
AUTOTRADE_GEMINI_MODEL=
|
||||||
|
AUTOTRADE_SUBSCRIPTION_CLI_TIMEOUT_MS=60000
|
||||||
|
AUTOTRADE_SUBSCRIPTION_CLI_DEBUG=0
|
||||||
|
AUTOTRADE_CODEX_COMMAND=
|
||||||
|
AUTOTRADE_GEMINI_COMMAND=
|
||||||
|
```
|
||||||
|
|
||||||
|
동작 우선순위:
|
||||||
|
|
||||||
|
1. `AUTOTRADE_SUBSCRIPTION_CLI=auto`면 codex -> gemini 순서로 시도
|
||||||
|
2. 모델 선택 우선순위는 `vendor 전용 모델` -> `AUTOTRADE_SUBSCRIPTION_CLI_MODEL` -> `CLI 기본 모델`
|
||||||
|
3. 둘 다 실패하면 fallback 규칙 신호 사용
|
||||||
|
4. 로그에 `attempts=codex:default:timeout`가 나오면 CLI 타임아웃이므로 `AUTOTRADE_SUBSCRIPTION_CLI_TIMEOUT_MS`를 더 크게 설정
|
||||||
|
5. 로그에 `attempts=codex:gpt-5-codex:error(...)`처럼 괄호가 붙으면 실제 실패 원인(stderr/spawn 에러)입니다.
|
||||||
|
|
||||||
|
어떤 CLI를 썼는지 확인:
|
||||||
|
|
||||||
|
1. 자동매매 로그에서 `신호 수신 [subscription_cli:codex:gpt-5-codex]` 또는 `신호 수신 [subscription_cli:gemini:flash]` 확인
|
||||||
|
2. Network 응답에서 `providerVendor` 확인
|
||||||
|
- `/api/autotrade/strategies/compile` 응답: `compiledStrategy.providerVendor`
|
||||||
|
- `/api/autotrade/signals/generate` 응답: `signal.providerVendor`
|
||||||
|
3. Network 응답에서 `providerModel` 확인
|
||||||
|
- `/api/autotrade/strategies/compile` 응답: `compiledStrategy.providerModel`
|
||||||
|
- `/api/autotrade/signals/generate` 응답: `signal.providerModel`
|
||||||
|
4. 파싱 실패 시 reason/summary에 `selected=vendor:model; attempts=...` 형태로 시도 결과 포함
|
||||||
|
|
||||||
|
`selected=none:default; attempts=codex:gpt-5-codex:error(...)`가 보이면:
|
||||||
|
|
||||||
|
1. `AUTOTRADE_SUBSCRIPTION_CLI_DEBUG=1`로 켜고 `npm run dev`를 재시작합니다.
|
||||||
|
2. Next 서버 콘솔에서 `[autotrade-cli]` 로그를 확인합니다.
|
||||||
|
3. `spawn:ENOENT`가 보이면 `AUTOTRADE_CODEX_COMMAND` 또는 `AUTOTRADE_GEMINI_COMMAND`에 CLI 절대경로를 넣습니다.
|
||||||
|
4. 예: `AUTOTRADE_CODEX_COMMAND=C:\\Users\\<계정>\\AppData\\Roaming\\npm\\codex.cmd`
|
||||||
|
|
||||||
|
모델 지정 예시:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Codex만 쓸 때
|
||||||
|
AUTOTRADE_SUBSCRIPTION_CLI=codex
|
||||||
|
AUTOTRADE_CODEX_MODEL=gpt-5-codex
|
||||||
|
|
||||||
|
# Gemini만 쓸 때
|
||||||
|
AUTOTRADE_SUBSCRIPTION_CLI=gemini
|
||||||
|
AUTOTRADE_GEMINI_MODEL=flash
|
||||||
|
|
||||||
|
# auto 모드에서 공통 모델 fallback만 쓸 때
|
||||||
|
AUTOTRADE_SUBSCRIPTION_CLI=auto
|
||||||
|
AUTOTRADE_SUBSCRIPTION_CLI_MODEL=auto
|
||||||
|
```
|
||||||
|
|
||||||
|
공식 문서:
|
||||||
|
|
||||||
|
1. Codex CLI 옵션(`--model`): <https://developers.openai.com/codex/cli>
|
||||||
|
2. OpenAI 모델 목록(`gpt-5-codex` 포함): <https://platform.openai.com/docs/models>
|
||||||
|
3. Gemini CLI 모델 선택: <https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/model.md>
|
||||||
|
4. Gemini CLI 모델 우선순위(`--model` > `GEMINI_MODEL`): <https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/model-routing.md>
|
||||||
|
|
||||||
|
모델 갱신 운영 런북:
|
||||||
|
|
||||||
|
1. 새 모델 출시 대응 절차: [`autotrade-model-catalog-runbook.md`](./autotrade-model-catalog-runbook.md)
|
||||||
|
|
||||||
|
관련 소스:
|
||||||
|
|
||||||
|
1. CLI 공급자: [`lib/autotrade/cli-provider.ts`](../../lib/autotrade/cli-provider.ts)
|
||||||
|
2. 전략 compile 라우트: [`/strategies/compile`](../../app/api/autotrade/strategies/compile/route.ts)
|
||||||
|
3. 신호 generate 라우트: [`/signals/generate`](../../app/api/autotrade/signals/generate/route.ts)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) 환경변수: 어디에 넣는지
|
||||||
|
|
||||||
|
## 4-1) 앱(Next.js 서버)
|
||||||
|
|
||||||
|
위치:
|
||||||
|
|
||||||
|
1. 로컬: `.env.local`
|
||||||
|
2. 운영: 배포 환경변수
|
||||||
|
|
||||||
|
필수:
|
||||||
|
|
||||||
|
```env
|
||||||
|
AUTOTRADE_WORKER_TOKEN=<랜덤시크릿>
|
||||||
|
OPENAI_API_KEY=<옵션, 없으면 폴백 동작>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4-2) 워커(Node/PM2)
|
||||||
|
|
||||||
|
위치:
|
||||||
|
|
||||||
|
1. PM2 실행 셸 환경변수
|
||||||
|
2. 서버 시스템 환경변수
|
||||||
|
|
||||||
|
필수:
|
||||||
|
|
||||||
|
```env
|
||||||
|
AUTOTRADE_WORKER_TOKEN=<앱과동일값>
|
||||||
|
AUTOTRADE_APP_URL=<Next서버URL>
|
||||||
|
AUTOTRADE_WORKER_POLL_MS=5000
|
||||||
|
```
|
||||||
|
|
||||||
|
중요:
|
||||||
|
|
||||||
|
1. `AUTOTRADE_WORKER_TOKEN`은 사용자별이 아니라 서비스별 시크릿
|
||||||
|
2. 앱과 워커가 같은 값을 써야 인증 통과
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) 실행 순서 (앱/워커 분리)
|
||||||
|
|
||||||
|
## 5-1) 로컬 개발
|
||||||
|
|
||||||
|
터미널 A:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
터미널 B:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AUTOTRADE_WORKER_TOKEN="<앱과같은값>" \
|
||||||
|
AUTOTRADE_APP_URL="http://127.0.0.1:3001" \
|
||||||
|
node scripts/autotrade-worker.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run dev
|
||||||
|
# 새 터미널
|
||||||
|
$env:AUTOTRADE_WORKER_TOKEN="<앱과같은값>"
|
||||||
|
$env:AUTOTRADE_APP_URL="http://127.0.0.1:3001"
|
||||||
|
$env:AUTOTRADE_WORKER_POLL_MS="5000"
|
||||||
|
npm run worker:autotrade
|
||||||
|
```
|
||||||
|
|
||||||
|
또는 `.env.local` 기반:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run worker:autotrade:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5-2) 운영(PM2)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export AUTOTRADE_WORKER_TOKEN="<앱과같은값>"
|
||||||
|
export AUTOTRADE_APP_URL="https://your-domain.com"
|
||||||
|
export AUTOTRADE_WORKER_POLL_MS="5000"
|
||||||
|
pm2 start scripts/pm2.autotrade-worker.config.cjs
|
||||||
|
pm2 logs autotrade-worker
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) end-to-end 흐름 (브라우저 -> 서버 -> 워커)
|
||||||
|
|
||||||
|
1. 브라우저: 설정 입력
|
||||||
|
2. 서버: `/strategies/compile` (AI 또는 폴백)
|
||||||
|
3. 서버: `/strategies/validate` (리스크 계산)
|
||||||
|
4. 서버: `/sessions/start`
|
||||||
|
5. 브라우저: 10초마다 `/sessions/heartbeat`
|
||||||
|
6. 브라우저: 주기적으로 `/signals/generate`
|
||||||
|
7. 브라우저: 리스크 게이트 통과 시 주문 API 호출
|
||||||
|
8. 브라우저: 중지 이벤트 시 `/sessions/stop`
|
||||||
|
9. 워커: `/worker/tick`로 heartbeat 만료 세션 정리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6-1) AI가 실제로 받는 판단 데이터
|
||||||
|
|
||||||
|
자동매매는 "자연어 프롬프트만" 보내는 구조가 아닙니다. 실행 중에는 아래 구조화된 데이터가 같이 전달됩니다.
|
||||||
|
|
||||||
|
1. 전략(compile 결과)
|
||||||
|
- `selectedTechniques`
|
||||||
|
- `confidenceThreshold`
|
||||||
|
- `maxDailyOrders`
|
||||||
|
- `cooldownSec`
|
||||||
|
- `maxOrderAmountRatio`
|
||||||
|
2. 시세 스냅샷(signal 요청 시)
|
||||||
|
- `symbol`
|
||||||
|
- `currentPrice`
|
||||||
|
- `changeRate`
|
||||||
|
- `open/high/low`
|
||||||
|
- `tradeVolume`
|
||||||
|
- `accumulatedVolume`
|
||||||
|
- `recentPrices`(최근 체결가 배열)
|
||||||
|
3. 서버 리스크 검증 결과
|
||||||
|
- AI 신호가 `buy/sell`이어도 리스크 게이트 미통과 시 주문 차단
|
||||||
|
|
||||||
|
즉, AI는 "현재 종목 + 현재가 + 가격 흐름 + 전략 제약"을 같이 받아 판단하고, 최종 주문은 리스크 게이트를 통과해야 실행됩니다.
|
||||||
|
|
||||||
|
관련 소스:
|
||||||
|
|
||||||
|
1. 스냅샷 구성: [`useAutotradeEngine.ts`](../../features/autotrade/hooks/useAutotradeEngine.ts)
|
||||||
|
2. 신호 route 검증: [`signals/generate/route.ts`](../../app/api/autotrade/signals/generate/route.ts)
|
||||||
|
3. 리스크 게이트: [`risk.ts`](../../lib/autotrade/risk.ts)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) 보안: 레이어별 핵심
|
||||||
|
|
||||||
|
## 7-1) 브라우저
|
||||||
|
|
||||||
|
1. KIS 민감정보는 세션 저장소(sessionStorage) 사용
|
||||||
|
2. 브라우저 종료 시 세션 저장소 제거
|
||||||
|
|
||||||
|
## 7-2) Next 서버
|
||||||
|
|
||||||
|
1. 자동매매 API는 사용자 인증 필요
|
||||||
|
2. 워커 API는 `x-autotrade-worker-token` 인증 필요
|
||||||
|
3. 민감정보 문자열 마스킹 처리
|
||||||
|
|
||||||
|
## 7-3) 워커
|
||||||
|
|
||||||
|
1. 토큰이 틀리면 401
|
||||||
|
2. 토큰은 코드 하드코딩 금지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) 역할별로 어디 보면 되는지
|
||||||
|
|
||||||
|
1. 기획/대표: 1, 2, 6, 7장
|
||||||
|
2. QA: 5, 6, 7장 + worker 문서 6, 7장
|
||||||
|
3. 개발: 2장 소스링크 + worker 문서 전체
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) 추가 문서
|
||||||
|
|
||||||
|
1. 워커 상세 운영: [`autotrade-worker-pm2.md`](./autotrade-worker-pm2.md)
|
||||||
269
common-docs/features/autotrade-worker-pm2.md
Normal file
269
common-docs/features/autotrade-worker-pm2.md
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
# 자동매매 워커 운영 가이드 (실행/배포 구조 이해용)
|
||||||
|
|
||||||
|
이 문서는 "앱을 실행하면 뭐가 어디서 도는지"를 먼저 설명하고, 그다음 실행 방법을 설명합니다.
|
||||||
|
|
||||||
|
## 0) 먼저 용어 정리
|
||||||
|
|
||||||
|
1. React 앱: 브라우저에서 보이는 UI (`/trade` 화면)
|
||||||
|
2. Next.js 서버: React 화면 제공 + API(`/api/*`) 처리
|
||||||
|
3. 워커(Node): 백그라운드에서 `/api/autotrade/worker/tick` 호출하는 별도 프로세스
|
||||||
|
|
||||||
|
중요:
|
||||||
|
|
||||||
|
1. React와 API는 보통 같은 Next 프로세스에서 동작합니다.
|
||||||
|
2. 워커는 Next와 별도 프로세스입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) 개발(local)에서 실제로 어디서 도는가
|
||||||
|
|
||||||
|
```text
|
||||||
|
내 PC
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 브라우저(Chrome) │
|
||||||
|
│ - /trade 화면 렌더링 │
|
||||||
|
│ - heartbeat 전송 (/sessions/heartbeat) │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│ http://127.0.0.1:3001
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 터미널 A: Next 개발 서버 (`npm run dev`) │
|
||||||
|
│ - React 페이지 제공 │
|
||||||
|
│ - /api/autotrade/* API 처리 │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────┘
|
||||||
|
▲
|
||||||
|
│ x-autotrade-worker-token
|
||||||
|
│
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 터미널 B: 워커 (`node scripts/autotrade-worker.mjs`) │
|
||||||
|
│ - /api/autotrade/worker/tick 주기 호출 │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
외부 클라우드 서비스
|
||||||
|
- Supabase(Auth/DB)
|
||||||
|
- KIS API
|
||||||
|
- OpenAI API(선택)
|
||||||
|
```
|
||||||
|
|
||||||
|
핵심:
|
||||||
|
|
||||||
|
1. 개발에서는 보통 프로세스 2개를 띄웁니다.
|
||||||
|
2. Next 1개 + Worker 1개
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) 운영(prod)에서 실제로 어디서 도는가
|
||||||
|
|
||||||
|
## 2-1) 패턴 A: 같은 Linux 서버에 Next + Worker
|
||||||
|
|
||||||
|
```text
|
||||||
|
사용자 브라우저
|
||||||
|
│ HTTPS
|
||||||
|
▼
|
||||||
|
[Linux 서버]
|
||||||
|
- Next 앱 프로세스 (웹 + API)
|
||||||
|
- Worker 프로세스 (PM2)
|
||||||
|
└─ 내부에서 /api/autotrade/worker/tick 호출
|
||||||
|
```
|
||||||
|
|
||||||
|
장점:
|
||||||
|
|
||||||
|
1. 구성 단순
|
||||||
|
2. 네트워크 경로 짧음
|
||||||
|
|
||||||
|
## 2-2) 패턴 B: Next는 플랫폼(Vercel 등), Worker는 별도 Linux
|
||||||
|
|
||||||
|
```text
|
||||||
|
사용자 브라우저 ──HTTPS──> Next 배포 플랫폼(웹+API)
|
||||||
|
▲
|
||||||
|
│ HTTPS + x-autotrade-worker-token
|
||||||
|
│
|
||||||
|
Linux Worker 서버(PM2)
|
||||||
|
```
|
||||||
|
|
||||||
|
장점:
|
||||||
|
|
||||||
|
1. 앱/워커 분리 운영 가능
|
||||||
|
2. 워커 자원 독립 관리 가능
|
||||||
|
|
||||||
|
주의:
|
||||||
|
|
||||||
|
1. 워커 서버에서 Next 도메인으로 접근 가능해야 함
|
||||||
|
2. 토큰/URL 설정을 양쪽에 정확히 맞춰야 함
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) 서버에서 "무엇이 돌아가는지" 체크표
|
||||||
|
|
||||||
|
| 구성요소 | 실제 실행 위치 | 프로세스 | 시작 명령 예시 | 역할 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| React UI | 사용자 브라우저 | Browser Tab | URL 접속 | 화면 렌더링, 사용자 입력 |
|
||||||
|
| Next 서버 | Linux/플랫폼 | Node(Next) | `npm run dev` 또는 `npm run start` | 웹 + `/api/autotrade/*` 처리 |
|
||||||
|
| Worker | Linux/Worker 서버 | Node Script(PM2) | `pm2 start scripts/pm2.autotrade-worker.config.cjs` | 만료 세션 정리 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) heartbeat와 worker/tick 차이
|
||||||
|
|
||||||
|
1. heartbeat
|
||||||
|
브라우저 -> Next 서버
|
||||||
|
세션 살아있음 알림
|
||||||
|
|
||||||
|
2. worker/tick
|
||||||
|
워커 -> Next 서버
|
||||||
|
heartbeat 끊긴 세션 정리 요청
|
||||||
|
|
||||||
|
즉:
|
||||||
|
|
||||||
|
1. heartbeat는 "상태 보고"
|
||||||
|
2. tick은 "청소 작업"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) 토큰/URL: 뭘 어떻게 넣어야 하나
|
||||||
|
|
||||||
|
## 5-1) `AUTOTRADE_WORKER_TOKEN`
|
||||||
|
|
||||||
|
뜻:
|
||||||
|
|
||||||
|
1. 사용자용 토큰 아님
|
||||||
|
2. 앱 서버와 워커 간 내부 인증 시크릿
|
||||||
|
3. 환경별(dev/staging/prod)로 1개 사용
|
||||||
|
|
||||||
|
생성 예시:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -hex 32
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5-2) `AUTOTRADE_APP_URL`
|
||||||
|
|
||||||
|
뜻:
|
||||||
|
|
||||||
|
1. 워커가 호출할 Next 서버 주소
|
||||||
|
|
||||||
|
예시:
|
||||||
|
|
||||||
|
1. 로컬: `http://127.0.0.1:3001`
|
||||||
|
2. 운영: `https://your-domain.com`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) 어디 파일/어디 시스템에 넣나
|
||||||
|
|
||||||
|
## 6-1) 앱(Next 서버)
|
||||||
|
|
||||||
|
위치:
|
||||||
|
|
||||||
|
1. 로컬: `.env.local`
|
||||||
|
2. 운영: 배포 환경변수
|
||||||
|
|
||||||
|
필수:
|
||||||
|
|
||||||
|
```env
|
||||||
|
AUTOTRADE_WORKER_TOKEN=<랜덤시크릿>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6-2) 워커(Node/PM2)
|
||||||
|
|
||||||
|
위치:
|
||||||
|
|
||||||
|
1. PM2 실행 셸 환경변수
|
||||||
|
2. 서버 시스템 환경변수
|
||||||
|
|
||||||
|
필수:
|
||||||
|
|
||||||
|
```env
|
||||||
|
AUTOTRADE_WORKER_TOKEN=<앱과동일값>
|
||||||
|
AUTOTRADE_APP_URL=<Next서버URL>
|
||||||
|
AUTOTRADE_WORKER_POLL_MS=5000
|
||||||
|
```
|
||||||
|
|
||||||
|
중요:
|
||||||
|
|
||||||
|
1. 앱/워커 토큰 값은 완전히 같아야 합니다.
|
||||||
|
2. 다르면 `/worker/tick`가 401로 실패합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) 실행 방법
|
||||||
|
|
||||||
|
## 7-1) 로컬 개발
|
||||||
|
|
||||||
|
터미널 A (Next):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
터미널 B (Worker):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AUTOTRADE_WORKER_TOKEN="<앱과같은값>" \
|
||||||
|
AUTOTRADE_APP_URL="http://127.0.0.1:3001" \
|
||||||
|
AUTOTRADE_WORKER_POLL_MS="5000" \
|
||||||
|
node scripts/autotrade-worker.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7-1-a) 로컬 개발 (Windows PowerShell)
|
||||||
|
|
||||||
|
터미널 A (Next):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
터미널 B (Worker):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:AUTOTRADE_WORKER_TOKEN="<앱과같은값>"
|
||||||
|
$env:AUTOTRADE_APP_URL="http://127.0.0.1:3001"
|
||||||
|
$env:AUTOTRADE_WORKER_POLL_MS="5000"
|
||||||
|
npm run worker:autotrade
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env.local` 값을 바로 쓰고 싶으면:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run worker:autotrade:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7-2) 운영 서버 (PM2)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i -g pm2
|
||||||
|
export AUTOTRADE_WORKER_TOKEN="<앱과같은값>"
|
||||||
|
export AUTOTRADE_APP_URL="https://your-domain.com"
|
||||||
|
export AUTOTRADE_WORKER_POLL_MS="5000"
|
||||||
|
pm2 start scripts/pm2.autotrade-worker.config.cjs
|
||||||
|
pm2 status
|
||||||
|
pm2 logs autotrade-worker
|
||||||
|
pm2 save
|
||||||
|
pm2 startup
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) 장애 시 빠른 점검
|
||||||
|
|
||||||
|
1. 워커 401
|
||||||
|
원인: 앱/워커 토큰 불일치
|
||||||
|
조치: `AUTOTRADE_WORKER_TOKEN` 동일화
|
||||||
|
|
||||||
|
2. fetch failed
|
||||||
|
원인: `AUTOTRADE_APP_URL` 오타, Next 미기동
|
||||||
|
조치: URL/앱 프로세스 확인
|
||||||
|
|
||||||
|
3. 세션이 안 정리됨
|
||||||
|
원인: heartbeat 정상 수신 중일 수 있음
|
||||||
|
조치: 브라우저 종료 후 TTL 경과 뒤 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) 관련 소스
|
||||||
|
|
||||||
|
1. 워커: [`scripts/autotrade-worker.mjs`](../../scripts/autotrade-worker.mjs)
|
||||||
|
2. PM2 설정: [`scripts/pm2.autotrade-worker.config.cjs`](../../scripts/pm2.autotrade-worker.config.cjs)
|
||||||
|
3. 워커 API: [`app/api/autotrade/worker/tick/route.ts`](../../app/api/autotrade/worker/tick/route.ts)
|
||||||
|
4. heartbeat API: [`app/api/autotrade/sessions/heartbeat/route.ts`](../../app/api/autotrade/sessions/heartbeat/route.ts)
|
||||||
|
5. 세션 만료 정리: [`app/api/autotrade/_shared.ts`](../../app/api/autotrade/_shared.ts#L147)
|
||||||
42
common-docs/features/trade-stock-sync.md
Normal file
42
common-docs/features/trade-stock-sync.md
Normal 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`
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
[계획 문서 경로]
|
||||||
|
- common-docs/improvement/plans/dev-plan-2026-02-26-autotrade-ai-mvp.md
|
||||||
|
|
||||||
|
[요구사항 요약]
|
||||||
|
- `features-autotrade-design.md`를 참고해 자동매매 기능을 실제 코드로 추가한다.
|
||||||
|
- 설계 항목 중 현재 코드베이스에서 바로 구현 가능한 범위와 불필요/보류 범위를 구분한다.
|
||||||
|
- "구독형 AI + 유명 기법"(OpenAI 기반 + ORB/VWAP/거래량/이평/갭)을 자동매매 시작 흐름에 반영한다.
|
||||||
|
- OpenAI API 외에도 서버에 설치된 Codex/Gemini CLI를 이용한 구독형 자동판단 경로를 추가한다.
|
||||||
|
- Windows 개발 환경에서 워커 실행 방법을 문서와 스크립트로 제공한다.
|
||||||
|
|
||||||
|
[확인 질문(필요 시 1~3개)]
|
||||||
|
- 없음(우선 MVP 범위로 구현 후 동작 가능한 형태를 제공)
|
||||||
|
|
||||||
|
[가정]
|
||||||
|
- 서버 DB(Supabase) 스키마를 이번 작업에서 새로 만들지 않고, 세션/로그는 서버 메모리 + 클라이언트 상태로 우선 구현한다.
|
||||||
|
- OpenAI 키(`OPENAI_API_KEY`)가 없으면 AI 추론은 휴리스틱 폴백(보수적 hold 중심)으로 동작한다.
|
||||||
|
- 자동매매는 트레이드 화면에서 선택된 종목 기준으로 우선 실행한다(멀티 종목 동시 엔진은 보류).
|
||||||
|
|
||||||
|
[추가/제외 판단]
|
||||||
|
- 즉시 추가:
|
||||||
|
- 자동매매 설정 팝업(UI): 프롬프트, 유명 기법 복수 선택, 투자금/손실한도(퍼센트+금액), 동의 체크
|
||||||
|
- 전략 컴파일/검증 API: `compile`, `validate`
|
||||||
|
- 런타임 세션 API: `start`, `heartbeat`, `stop`, `active`
|
||||||
|
- 브라우저 엔진 훅: 신호 평가, 리스크 게이트, 주문 실행, heartbeat, 중지 처리
|
||||||
|
- 실행 중 경고 배너/상태 카드
|
||||||
|
- 설정 도움말/추천 프리셋(초보/균형/공격) 추가
|
||||||
|
- 백엔드 워커 tick API + 리눅스 PM2 실행 스크립트/문서 추가
|
||||||
|
- 자동 세션 수명주기(start->heartbeat->stop) E2E 스크립트 추가
|
||||||
|
- 이번에 제외(보류):
|
||||||
|
- Supabase 테이블 5종 + 감사로그 영구 저장
|
||||||
|
- 온라인 전략 수집/카탈로그 검수 워크플로우 전체
|
||||||
|
- 멀티탭 리더 선출 lock + BroadcastChannel 완성형
|
||||||
|
- 4주 배포 계획/운영 대시보드/Sentry 통합
|
||||||
|
- AI 다중 제공자(OpenAI/Gemini/Claude) 동시 운영
|
||||||
|
|
||||||
|
[영향 범위]
|
||||||
|
- 수정:
|
||||||
|
- features/trade/components/TradeContainer.tsx
|
||||||
|
- .env.example
|
||||||
|
- utils/supabase/middleware.ts
|
||||||
|
- package.json
|
||||||
|
- common-docs/features/autotrade-usage-security-guide.md
|
||||||
|
- common-docs/features/autotrade-worker-pm2.md
|
||||||
|
- common-docs/improvement/plans/dev-plan-2026-02-26-autotrade-ai-mvp.md
|
||||||
|
- 추가:
|
||||||
|
- features/autotrade/types/autotrade.types.ts
|
||||||
|
- features/autotrade/stores/use-autotrade-engine-store.ts
|
||||||
|
- features/autotrade/hooks/useAutotradeEngine.ts
|
||||||
|
- features/autotrade/components/AutotradeControlPanel.tsx
|
||||||
|
- features/autotrade/components/AutotradeWarningBanner.tsx
|
||||||
|
- features/autotrade/apis/autotrade.api.ts
|
||||||
|
- app/api/autotrade/_shared.ts
|
||||||
|
- app/api/autotrade/strategies/compile/route.ts
|
||||||
|
- app/api/autotrade/strategies/validate/route.ts
|
||||||
|
- app/api/autotrade/sessions/start/route.ts
|
||||||
|
- app/api/autotrade/sessions/heartbeat/route.ts
|
||||||
|
- app/api/autotrade/sessions/stop/route.ts
|
||||||
|
- app/api/autotrade/sessions/active/route.ts
|
||||||
|
- app/api/autotrade/signals/generate/route.ts
|
||||||
|
- app/api/autotrade/worker/tick/route.ts
|
||||||
|
- lib/autotrade/risk.ts
|
||||||
|
- lib/autotrade/strategy.ts
|
||||||
|
- lib/autotrade/openai.ts
|
||||||
|
- lib/autotrade/cli-provider.ts
|
||||||
|
- scripts/autotrade-session-e2e.mjs
|
||||||
|
- scripts/autotrade-worker.mjs
|
||||||
|
- scripts/pm2.autotrade-worker.config.cjs
|
||||||
|
- common-docs/features/autotrade-worker-pm2.md
|
||||||
|
- 삭제:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
[구현 단계]
|
||||||
|
- [x] 1. 자동매매 타입/리스크 계산 유틸/AI-폴백 전략 컴파일 로직 추가
|
||||||
|
- 근거: `features/autotrade/types/autotrade.types.ts`, `lib/autotrade/risk.ts`, `lib/autotrade/strategy.ts`, `lib/autotrade/openai.ts`
|
||||||
|
- [x] 2. 자동매매 API 라우트(`compile/validate/start/heartbeat/stop/active/signal`) 구현
|
||||||
|
- 근거: `app/api/autotrade/**/route.ts`, `app/api/autotrade/_shared.ts`
|
||||||
|
- [x] 3. 클라이언트 스토어/엔진 훅 구현(상태, heartbeat, 주문 실행, 중지)
|
||||||
|
- 근거: `features/autotrade/stores/use-autotrade-engine-store.ts`, `features/autotrade/hooks/useAutotradeEngine.ts`, `features/autotrade/apis/autotrade.api.ts`
|
||||||
|
- [x] 4. 트레이드 화면에 설정 패널/실행 경고 배너 통합
|
||||||
|
- 근거: `features/autotrade/components/AutotradeControlPanel.tsx`, `features/autotrade/components/AutotradeWarningBanner.tsx`, `features/trade/components/TradeContainer.tsx`
|
||||||
|
- [x] 5. 문서/환경변수(.env.example) 반영 및 계획 체크 업데이트
|
||||||
|
- 근거: `.env.example`, 본 계획 문서 갱신
|
||||||
|
- [x] 6. 설정 팝업 입력값 설명 강화 + 추천 프리셋(초보/균형/공격) 추가
|
||||||
|
- 근거: `features/autotrade/components/AutotradeControlPanel.tsx`
|
||||||
|
- [x] 7. 백엔드 워커 tick API 및 PM2 운영 스크립트/문서 추가
|
||||||
|
- 근거: `app/api/autotrade/worker/tick/route.ts`, `scripts/autotrade-worker.mjs`, `scripts/pm2.autotrade-worker.config.cjs`, `common-docs/features/autotrade-worker-pm2.md`
|
||||||
|
- [x] 8. 자동매매 세션 수명주기 E2E 스크립트 추가 및 실행
|
||||||
|
- 근거: `scripts/autotrade-session-e2e.mjs`, `npm run test:autotrade:lifecycle` PASS
|
||||||
|
- [x] 9. 구독형 CLI 자동판단 모드 추가(codex/gemini CLI)
|
||||||
|
- 근거: `lib/autotrade/cli-provider.ts`, `app/api/autotrade/strategies/compile/route.ts`, `app/api/autotrade/signals/generate/route.ts`, `features/autotrade/components/AutotradeControlPanel.tsx`
|
||||||
|
- [x] 10. Windows 개발 워커 실행 경로 추가
|
||||||
|
- 근거: `package.json(worker:autotrade:dev)`, `common-docs/features/autotrade-worker-pm2.md`, `common-docs/features/autotrade-usage-security-guide.md`
|
||||||
|
|
||||||
|
[사용할 MCP/Skills]
|
||||||
|
- MCP: next-devtools(nextjs_index/nextjs_call), playwright(스모크), shell_command
|
||||||
|
- Skills: dev-auto-pipeline, dev-plan-writer, dev-mcp-implementation, nextjs-app-router-patterns, vercel-react-best-practices, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker
|
||||||
|
|
||||||
|
[참조 문서(common-docs)]
|
||||||
|
- common-docs/ui/GLOBAL_ALERT_SYSTEM.md
|
||||||
|
- common-docs/api-reference/kis_api_reference.md (주문 연동 시 기존 패턴 준수)
|
||||||
|
- common-docs/api-reference/kis-error-code-reference.md (에러 표현 패턴 유지)
|
||||||
|
- 사용자 지정 기획 입력: common-docs/features-autotrade-design.md
|
||||||
|
|
||||||
|
[주석/문서 반영 계획]
|
||||||
|
- 함수 주석: [목적]/[사용처]/[데이터 흐름] 중심으로 핵심 흐름만 보강
|
||||||
|
- 상태 주석: 자동매매 상태 변경이 화면에 미치는 영향 위주
|
||||||
|
- 복잡 로직/핸들러: 신호 생성 -> 리스크 검증 -> 주문 실행 단계 주석
|
||||||
|
- JSX 구역 주석: 설정 패널/경고 배너/상태 카드 구역 분리
|
||||||
|
|
||||||
|
[리스크/회귀 포인트]
|
||||||
|
- 주문 API 호출 빈도 과다 시 중복 주문 위험
|
||||||
|
- 브라우저 종료 시 stop beacon 실패 가능성
|
||||||
|
- AI 출력 포맷 불안정 시 잘못된 신호 처리 위험
|
||||||
|
- 기존 수동 주문 UX와 충돌(버튼/상태 동시 사용)
|
||||||
|
|
||||||
|
[검증 계획]
|
||||||
|
- [x] 1. `npm run lint` 통과
|
||||||
|
- 근거: ESLint 에러/경고 정리 후 재실행 통과
|
||||||
|
- [x] 2. `npm run build` 통과
|
||||||
|
- 근거: Next.js 16.1.6 프로덕션 빌드 성공, 신규 `/api/autotrade/*` 라우트 포함 확인
|
||||||
|
- [x] 3. Playwright 스모크: `/trade` 자동매매 설정 패널 오픈 + 도움말/추천 프리셋 입력 반영 확인
|
||||||
|
- 근거: `자동매매 설정` 모달 오픈, 쉬운 설명 문구 노출, `초보 추천` 클릭 시 수치 자동 반영 확인, 콘솔 error 없음
|
||||||
|
- [x] 4. start -> heartbeat -> stop 상태 전환 검증
|
||||||
|
- 근거: `npm run test:autotrade:lifecycle` PASS (`start -> heartbeat -> active -> stop -> active(null)`)
|
||||||
|
|
||||||
|
[진행 로그]
|
||||||
|
- 2026-02-26: 초안 작성. 설계서 기준 MVP 범위(즉시 구현/보류) 확정.
|
||||||
|
- 2026-02-26: 자동매매 MVP 구현 완료. 타입/유틸/API/스토어/엔진/트레이드 화면 통합 및 `.env.example` 갱신.
|
||||||
|
- 2026-02-26: 검증 완료(`npm run lint`, `npm run build`, Playwright 스모크). 로그인+KIS 인증 기반 수동 E2E는 남은 확인 항목으로 기록.
|
||||||
|
- 2026-02-26: 설정값 도움말/추천 프리셋(초보/균형/공격) 추가로 입력 이해도 개선.
|
||||||
|
- 2026-02-26: 워커 tick API + PM2 운영 스크립트/문서 추가, `worker:autotrade:once` 정상 동작 확인.
|
||||||
|
- 2026-02-26: 수명주기 자동 검증 스크립트(`test:autotrade:lifecycle`) 통과로 검증계획 4 완료.
|
||||||
|
- 2026-02-26: 구독형 CLI 자동판단 모드(`subscription_cli`) 추가. OpenAI 미사용 환경에서 gemini/codex CLI 호출 후 JSON 파싱, 실패 시 규칙 기반 폴백하도록 리팩토링.
|
||||||
|
- 2026-02-26: Windows PowerShell 기준 워커 실행 방법(환경변수 + `worker:autotrade(:dev)`) 문서화.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# 자동매매 가용자산 0원 차단 보완 계획
|
||||||
|
|
||||||
|
[계획 문서 경로]
|
||||||
|
- common-docs/improvement/plans/dev-plan-2026-02-26-autotrade-cash-balance-fix.md
|
||||||
|
|
||||||
|
[요구사항 요약]
|
||||||
|
- 자동매매 검증에서 `가용 자산 0원`으로 차단되는 문제를 보완한다.
|
||||||
|
- `내 계좌 기준`으로 매수가능금액을 추가 조회해 검증 금액에 반영한다.
|
||||||
|
- 기존 리스크 검증/주문 흐름은 유지한다.
|
||||||
|
|
||||||
|
[가정]
|
||||||
|
- KIS 인증/계좌번호는 이미 설정되어 있다.
|
||||||
|
- selectedStock의 종목코드와 가격 정보는 자동매매 시작 시점에 확보 가능하다.
|
||||||
|
|
||||||
|
[영향 범위]
|
||||||
|
- 수정: features/autotrade/hooks/useAutotradeEngine.ts
|
||||||
|
- 수정: features/trade/apis/kis-stock.api.ts
|
||||||
|
- 수정: features/trade/types/trade.types.ts
|
||||||
|
- 수정: lib/kis/trade.ts
|
||||||
|
- 추가: app/api/kis/domestic/orderable-cash/route.ts
|
||||||
|
|
||||||
|
[구현 단계]
|
||||||
|
- [x] 1. KIS 매수가능금액 조회 서버 라우트 추가
|
||||||
|
- [x] 2. 프론트 API 클라이언트/타입 추가
|
||||||
|
- [x] 3. 자동매매 prepareStrategy에서 cashBalance 0원 보정 로직 추가
|
||||||
|
- [x] 4. 로그/주석 보강
|
||||||
|
|
||||||
|
[사용할 MCP/Skills]
|
||||||
|
- Skills: dev-auto-pipeline, dev-mcp-implementation, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker
|
||||||
|
|
||||||
|
[참조 문서(common-docs)]
|
||||||
|
- common-docs/api-reference/kis_api_reference.md
|
||||||
|
- common-docs/api-reference/kis-error-code-reference.md
|
||||||
|
|
||||||
|
[리스크/회귀 포인트]
|
||||||
|
- 매수가능금액 조회 실패 시 기존 cashBalance만 사용하도록 폴백 필요
|
||||||
|
- 종목가격이 0 또는 비정상일 때 조회 파라미터 보정 필요
|
||||||
|
|
||||||
|
[검증 계획]
|
||||||
|
- [x] 1. lint 통과
|
||||||
|
- [x] 2. build 통과
|
||||||
|
- [ ] 3. autotrade smoke 테스트 통과
|
||||||
|
|
||||||
|
[진행 로그]
|
||||||
|
- 2026-02-26: 계획 문서 생성
|
||||||
|
- 2026-02-26: `/api/kis/domestic/orderable-cash` 라우트 및 `executeInquireOrderableCash` 구현
|
||||||
|
- 2026-02-26: 자동매매 `prepareStrategy`에서 cashBalance 0원 시 매수가능금액 보정 로직 반영
|
||||||
|
- 2026-02-26: `npx eslint ...` 통과
|
||||||
|
- 2026-02-26: `npm run build` 통과
|
||||||
|
- 2026-02-26: smoke 테스트는 현재 3001 실행 프로세스가 dev bypass를 허용하지 않는 환경으로 로그인 필요 응답 확인(추가 환경 정리 후 재실행 필요)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 자동매매 CLI 모델 선택 + AI 입력 데이터 흐름 보강 계획
|
||||||
|
|
||||||
|
## [계획 문서 경로]
|
||||||
|
- `common-docs/improvement/plans/dev-plan-2026-02-26-autotrade-cli-model-selection.md`
|
||||||
|
|
||||||
|
## [요구사항 요약]
|
||||||
|
- 자동매매가 AI 판단 시 어떤 데이터를 전달하는지 쉽게 설명한다.
|
||||||
|
- Codex/Gemini CLI 모델을 공식 옵션 기준으로 선택 가능하게 만든다.
|
||||||
|
- 로그/응답에서 실제 사용된 vendor/model을 확인 가능하게 만든다.
|
||||||
|
|
||||||
|
## [가정]
|
||||||
|
- 구독형 CLI는 서버(개발/운영)에 설치되어 있고 로그인/인증이 완료되어 있다.
|
||||||
|
- 모델 선택은 UI 입력보다 서버 환경변수 방식이 운영상 안전하다.
|
||||||
|
|
||||||
|
## [영향 범위]
|
||||||
|
- 수정: `lib/autotrade/cli-provider.ts`
|
||||||
|
- 수정: `app/api/autotrade/strategies/compile/route.ts`
|
||||||
|
- 수정: `app/api/autotrade/signals/generate/route.ts`
|
||||||
|
- 수정: `features/autotrade/types/autotrade.types.ts`
|
||||||
|
- 수정: `features/autotrade/hooks/useAutotradeEngine.ts`
|
||||||
|
- 수정: `.env.example`
|
||||||
|
- 수정: `common-docs/features/autotrade-usage-security-guide.md`
|
||||||
|
- 수정: `common-docs/features/autotrade-prompt-flow-guide.md`
|
||||||
|
|
||||||
|
## [구현 단계]
|
||||||
|
- [x] 1. CLI 실행 인자에 vendor별 모델 선택 환경변수를 반영한다. (근거: `lib/autotrade/cli-provider.ts`)
|
||||||
|
- [x] 2. compile/signal 응답에 `providerModel`을 포함해 추적 가능하게 만든다. (근거: `app/api/autotrade/strategies/compile/route.ts`, `app/api/autotrade/signals/generate/route.ts`)
|
||||||
|
- [x] 3. 런타임 로그에 vendor/model을 함께 노출한다. (근거: `features/autotrade/hooks/useAutotradeEngine.ts`)
|
||||||
|
- [x] 4. AI 입력 데이터(시세/전략) 흐름 설명을 문서에 보강한다. (근거: `common-docs/features/autotrade-usage-security-guide.md`, `common-docs/features/autotrade-prompt-flow-guide.md`)
|
||||||
|
|
||||||
|
## [사용할 MCP/Skills]
|
||||||
|
- Skills: `dev-auto-pipeline`, `dev-mcp-implementation`, `dev-refactor-polish`, `dev-test-gate`, `dev-plan-completion-checker`
|
||||||
|
- MCP: 없음(로컬 코드 수정 + 공식 문서 웹 근거 활용)
|
||||||
|
|
||||||
|
## [참조 문서(common-docs)]
|
||||||
|
- `common-docs/features/trade-stock-sync.md` (참고만, 변경 없음)
|
||||||
|
- `common-docs/ui/GLOBAL_ALERT_SYSTEM.md` (참고만, 변경 없음)
|
||||||
|
|
||||||
|
## [주석/문서 반영 계획]
|
||||||
|
- 함수 주석: CLI 모델 선택 우선순위와 데이터 흐름 주석 보강
|
||||||
|
- 상태/로그 주석: vendor/model 로그 의미를 한 줄로 명시
|
||||||
|
- 흐름 문서: UI -> 훅 -> API -> route -> provider 단계 유지
|
||||||
|
|
||||||
|
## [리스크/회귀 포인트]
|
||||||
|
- Codex CLI 모델명이 환경과 불일치하면 CLI 실패 후 fallback으로 전환될 수 있다.
|
||||||
|
- 응답 스키마 필드 추가(`providerModel`)가 프론트 타입과 불일치하면 TS 오류가 날 수 있다.
|
||||||
|
|
||||||
|
## [검증 계획]
|
||||||
|
- [x] 1. 변경 파일 eslint 검사 통과 (결과: 코드 파일 오류 없음, md 파일은 lint 대상 아님 경고)
|
||||||
|
- [x] 2. `npm run build` 통과
|
||||||
|
- [x] 3. 문서의 환경변수/확인 절차가 실제 로그 포맷과 일치
|
||||||
|
|
||||||
|
## [진행 로그]
|
||||||
|
- 2026-02-26: 계획 문서 생성
|
||||||
|
- 2026-02-26: CLI 모델 선택 환경변수(`AUTOTRADE_CODEX_MODEL`, `AUTOTRADE_GEMINI_MODEL`, `AUTOTRADE_SUBSCRIPTION_CLI_MODEL`) 반영
|
||||||
|
- 2026-02-26: provider vendor/model 추적값 응답/로그 반영
|
||||||
|
- 2026-02-26: AI 입력 데이터(시세 스냅샷/전략 제약) 설명 문서 보강
|
||||||
|
- 2026-02-26: `npx eslint` + `npm run build` 검증 완료
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 자동매매 모델 선택 + 대시보드 잔고/매도 UX 개선 계획
|
||||||
|
|
||||||
|
## [계획 문서 경로]
|
||||||
|
- `common-docs/improvement/plans/dev-plan-2026-02-26-autotrade-dashboard-ux-cli-models.md`
|
||||||
|
|
||||||
|
## [요구사항 요약]
|
||||||
|
- AI 판단 입력 데이터(시세 스냅샷)가 무엇인지 쉽게 설명한다.
|
||||||
|
- 자동매매 UI에서 구독형 CLI vendor/model을 선택할 수 있게 개선한다.
|
||||||
|
- 대시보드 잔고 표시(총자산/순자산) 혼동을 줄이고, 매도 UX에 매도가능수량 정보를 보강한다.
|
||||||
|
- 보유종목 잔존(전량 매도 후 표시) 문제를 점검하고 수정한다.
|
||||||
|
- 자동매매 리스크 요약 문구를 초보자 기준으로 이해 가능하게 바꾼다.
|
||||||
|
|
||||||
|
## [가정]
|
||||||
|
- 구독형 CLI 모델 목록은 "공식 문서 기준 추천 프리셋 + 직접 입력" 방식이 운영 안정성에 유리하다.
|
||||||
|
- KIS 주식잔고조회 output1의 `ord_psbl_qty`(매도가능수량)를 우선 사용한다.
|
||||||
|
|
||||||
|
## [영향 범위]
|
||||||
|
- 수정: `features/autotrade/types/autotrade.types.ts`
|
||||||
|
- 수정: `lib/autotrade/strategy.ts`
|
||||||
|
- 수정: `features/autotrade/apis/autotrade.api.ts`
|
||||||
|
- 수정: `features/autotrade/hooks/useAutotradeEngine.ts`
|
||||||
|
- 수정: `features/autotrade/components/AutotradeControlPanel.tsx`
|
||||||
|
- 수정: `app/api/autotrade/strategies/compile/route.ts`
|
||||||
|
- 수정: `app/api/autotrade/signals/generate/route.ts`
|
||||||
|
- 수정: `lib/autotrade/cli-provider.ts`
|
||||||
|
- 수정: `lib/kis/dashboard.ts`
|
||||||
|
- 수정: `features/dashboard/types/dashboard.types.ts`
|
||||||
|
- 수정: `features/dashboard/components/StatusHeader.tsx`
|
||||||
|
- 수정: `features/dashboard/components/HoldingsList.tsx`
|
||||||
|
- 수정: `features/trade/components/TradeContainer.tsx`
|
||||||
|
- 수정: `features/trade/components/order/OrderForm.tsx`
|
||||||
|
- 수정: `common-docs/features/autotrade-usage-security-guide.md`
|
||||||
|
- 수정: `common-docs/features/autotrade-prompt-flow-guide.md`
|
||||||
|
|
||||||
|
## [구현 단계]
|
||||||
|
- [x] 1. 자동매매 setup form에 CLI vendor/model 선택 필드를 추가한다.
|
||||||
|
- [x] 2. compile/signal API 요청에 vendor/model 오버라이드를 전달하고 라우트/CLI provider에서 반영한다.
|
||||||
|
- [x] 3. 공식 문서 기반 모델 프리셋(코덱스/제미나이) + 직접입력 UX를 패널에 추가한다.
|
||||||
|
- [x] 4. 대시보드 잔고 파싱에서 수량 0 보유종목 제거/매도가능수량 필드를 반영한다.
|
||||||
|
- [x] 5. 상단 자산 카드 라벨/표시 순서를 총자산 중심으로 개선한다.
|
||||||
|
- [x] 6. 주문 패널 매도 탭에서 매도가능수량 기반 가이드/검증을 추가한다.
|
||||||
|
- [x] 7. 자동매매 리스크 요약 문구를 쉬운 용어로 바꾸고 입력값 대비 계산 근거를 함께 노출한다.
|
||||||
|
- [x] 8. 문서(사용 가이드/흐름 가이드)에 스냅샷 필드 설명과 모델 선택 기준을 반영한다.
|
||||||
|
|
||||||
|
## [사용한 공식 문서]
|
||||||
|
- OpenAI Models: <https://platform.openai.com/docs/models>
|
||||||
|
- OpenAI Codex CLI: <https://developers.openai.com/codex/cli>
|
||||||
|
- Gemini CLI model selection: <https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/model.md>
|
||||||
|
- Gemini CLI model routing precedence: <https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/model-routing.md>
|
||||||
|
- KIS 매도가능수량조회 경로 참고: `common-docs/api-reference/kis_api_reference.md`, `.tmp/open-trading-api/examples_llm/domestic_stock/inquire_psbl_sell/inquire_psbl_sell.py`
|
||||||
|
|
||||||
|
## [리스크/회귀 포인트]
|
||||||
|
- UI 필드 증가로 기존 자동매매 설정 저장/반영 흐름이 깨질 수 있음.
|
||||||
|
- 모델명을 강제로 지정했을 때 vendor와 호환되지 않으면 CLI 실패 후 fallback으로 전환될 수 있음.
|
||||||
|
- 보유종목 필터링 조건이 과도하면 실제 보유 종목이 누락될 수 있음.
|
||||||
|
|
||||||
|
## [검증 계획]
|
||||||
|
- [x] 1. 변경 파일 eslint 통과
|
||||||
|
- [x] 2. `npm run build` 통과
|
||||||
|
- [x] 3. 대시보드에서 수량 0 종목 미노출 로직 반영 확인 (`lib/kis/dashboard.ts` 수량 0 필터)
|
||||||
|
- [x] 4. 매도 탭에서 매도가능수량 초과 입력 차단 로직 반영 확인 (`OrderForm.tsx`)
|
||||||
|
- [x] 5. 자동매매 로그에 vendor/model 노출 유지 확인 (`useAutotradeEngine.ts`)
|
||||||
|
|
||||||
|
## [진행 로그]
|
||||||
|
- 2026-02-26: 계획 문서 생성
|
||||||
|
- 2026-02-26: 자동매매 설정창에 구독형 CLI vendor/model 선택 UI 추가
|
||||||
|
- 2026-02-26: compile/signal route와 CLI provider에 vendor/model override 반영
|
||||||
|
- 2026-02-26: 대시보드 잔고 파싱에 `ord_psbl_qty` 반영, 수량 0 종목 필터링 적용
|
||||||
|
- 2026-02-26: StatusHeader 총자산 중심 표기 개편, 매도 UX(매도가능수량 표시/검증) 개선
|
||||||
|
- 2026-02-26: 리스크 요약 문구를 쉬운 용어로 교체, 스냅샷/모델선택 문서 보강
|
||||||
|
- 2026-02-26: `npx eslint` 및 `npm run build` 통과
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
[계획 문서 경로]
|
||||||
|
- common-docs/improvement/plans/dev-plan-2026-02-26-market-indices-display.md
|
||||||
|
|
||||||
|
[요구사항 요약]
|
||||||
|
- 메인 레이아웃의 헤더에 KOSPI 및 KOSDAQ 지수를 표시한다.
|
||||||
|
- 지수에는 현재가, 전일 대비 등락, 등락률이 포함되어야 한다.
|
||||||
|
- 데이터는 30초마다 자동으로 새로고침되어야 한다.
|
||||||
|
|
||||||
|
[가정]
|
||||||
|
- 사용자는 로그인 상태이며 KIS API 키가 설정되어 있다고 가정한다.
|
||||||
|
- `lib/kis/dashboard.ts`의 `getDomesticDashboardIndices` 함수가 정상 동작한다고 가정한다.
|
||||||
|
|
||||||
|
[영향 범위]
|
||||||
|
- 수정:
|
||||||
|
- `features/layout/components/header.tsx`: `MarketIndices` 컴포넌트를 추가하고 레이아웃을 조정.
|
||||||
|
- 추가:
|
||||||
|
- `app/api/kis/indices/route.ts`: KIS 지수 데이터를 조회하는 새로운 API 라우트.
|
||||||
|
- `features/layout/stores/market-indices-store.ts`: 지수 데이터 상태 관리를 위한 Zustand 스토어.
|
||||||
|
- `features/layout/hooks/use-market-indices.ts`: 지수 데이터를 가져오는 커스텀 훅.
|
||||||
|
- `features/layout/components/market-indices.tsx`: 지수 정보를 표시하는 UI 컴포넌트.
|
||||||
|
- 삭제:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
[구현 단계]
|
||||||
|
- [x] 1. KIS 지수 API 라우트 생성 (`app/api/kis/indices/route.ts`): `getDomesticDashboardIndices` 함수를 사용하여 KOSPI, KOSDAQ 지수 정보를 반환하는 GET 엔드포인트를 구현.
|
||||||
|
- [x] 2. 상태 관리 스토어 생성 (`features/layout/stores/market-indices-store.ts`): 지수 데이터, 로딩 상태, 에러 상태를 관리하기 위한 Zustand 스토어를 생성.
|
||||||
|
- [x] 3. 커스텀 훅 생성 (`features/layout/hooks/use-market-indices.ts`): 위에서 만든 API 라우트를 호출하고, 스토어의 상태를 업데이트하는 `useMarketIndices` 훅을 구현.
|
||||||
|
- [x] 4. UI 컴포넌트 생성 (`features/layout/components/market-indices.tsx`): `useMarketIndices` 훅을 사용하여 지수 정보를 받아와 화면에 표시하는 컴포넌트를 생성. 30초마다 데이터를 폴링하는 로직을 포함.
|
||||||
|
- [x] 5. 헤더에 컴포넌트 추가 (`features/layout/components/header.tsx`): 생성된 `MarketIndices` 컴포넌트를 헤더 중앙에 추가하고, 로그인 및 `blendWithBackground` 상태에 따라 노출 여부를 제어.
|
||||||
|
|
||||||
|
[사용할 MCP/Skills]
|
||||||
|
- MCP: 없음
|
||||||
|
- Skills: `dev-plan-writer`, `dev-mcp-implementation`, `dev-refactor-polish`, `dev-test-gate`, `dev-plan-completion-checker`
|
||||||
|
|
||||||
|
[참조 문서(common-docs)]
|
||||||
|
- `common-docs/api-reference/kis_api_reference.md`
|
||||||
|
|
||||||
|
[주석/문서 반영 계획]
|
||||||
|
- 각 파일 상단에 파일의 목적과 역할을 설명하는 JSDoc 주석을 추가.
|
||||||
|
- 주요 함수에 파라미터와 반환 값, 역할을 설명하는 주석을 추가.
|
||||||
|
|
||||||
|
[리스크/회귀 포인트]
|
||||||
|
- KIS API 호출 실패 시 에러 처리가 적절히 이루어지지 않으면 UI가 깨지거나 오류 메시지가 표시되지 않을 수 있다.
|
||||||
|
- 자동 새로고침 로직이 메모리 누수를 일으키지 않도록 `useEffect`의 cleanup 함수를 정확히 구현해야 한다.
|
||||||
|
|
||||||
|
[검증 계획]
|
||||||
|
- [ ] 1. **API 라우트 검증**: 브라우저나 API 테스트 도구로 `/api/kis/indices`를 직접 호출하여 정상적인 JSON 응답(지수 데이터, fetchedAt)이 오는지 확인.
|
||||||
|
- [ ] 2. **UI 초기 로딩 검증**: 페이지 로드 시 `MarketIndices` 컴포넌트 영역에 스켈레톤 UI가 먼저 표시되는지 확인.
|
||||||
|
- [ ] 3. **UI 데이터 표시 검증**: 데이터 로딩 완료 후 KOSPI, KOSDAQ 지수 정보(현재가, 등락, 등락률)가 헤더에 정상적으로 표시되는지 확인. 등락에 따라 색상(빨강/파랑)이 올바르게 적용되는지 확인.
|
||||||
|
- [ ] 4. **UI 자동 새로고침 검증**: 약 30초가 지난 후 `fetchedAt` 시간이 갱신되며 데이터가 새로고침되는지 네트워크 탭과 화면 표시를 통해 확인.
|
||||||
|
- [ ] 5. **로그아웃/비로그인 상태 검증**: 로그아웃하거나 비로그인 상태로 접속했을 때, 지수 컴포넌트가 헤더에 표시되지 않는지 확인.
|
||||||
|
- [ ] 6. **홈 랜딩 페이지 검증**: `blendWithBackground` prop이 `true`로 설정된 홈 랜딩 페이지에서 지수 컴포넌트가 표시되지 않는지 확인.
|
||||||
|
|
||||||
|
[진행 로그]
|
||||||
|
- 2026-02-26: 계획 문서 작성 및 기능 구현 완료.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
[계획 문서 경로]
|
||||||
|
- common-docs/improvement/plans/dev-plan-2026-03-04-dashboard-market-hub-and-orderbook-rate.md
|
||||||
|
|
||||||
|
[요구사항 요약]
|
||||||
|
- 호가창 각 호가 행에 기준가 대비 퍼센트(등락률)를 추가 표시한다.
|
||||||
|
- /dashboard 안에서 내 종목/내 재산/주문내역 같은 개인 자산 정보를 별도 메뉴(탭)로 분리한다.
|
||||||
|
- /dashboard 메인 화면에는 급등주식, 인기종목, 주요 뉴스와 추가 시장 정보 카드를 배치한다.
|
||||||
|
|
||||||
|
[가정]
|
||||||
|
- "메뉴를 하나 새로"는 /dashboard 내부 탭 메뉴(시장 탭/내 자산 탭) 추가로 해석한다.
|
||||||
|
- 기존 KIS 인증/세션 헤더 체계는 유지하고, 신규 데이터도 동일 헤더로 조회한다.
|
||||||
|
- 인기종목은 거래량 기준 상위(필요 시 거래대금 기준 포함)로 제공한다.
|
||||||
|
|
||||||
|
[영향 범위]
|
||||||
|
- 수정:
|
||||||
|
- features/trade/components/orderbook/orderbook-utils.ts
|
||||||
|
- features/trade/components/orderbook/orderbook-sections.tsx
|
||||||
|
- features/dashboard/types/dashboard.types.ts
|
||||||
|
- features/dashboard/apis/dashboard.api.ts
|
||||||
|
- features/dashboard/hooks/use-dashboard-data.ts
|
||||||
|
- features/dashboard/components/DashboardContainer.tsx
|
||||||
|
- lib/kis/dashboard.ts
|
||||||
|
- 추가:
|
||||||
|
- app/api/kis/domestic/market-hub/route.ts
|
||||||
|
- features/dashboard/components/MarketHubSection.tsx
|
||||||
|
- 삭제:
|
||||||
|
- features/dashboard/hooks/use-market-movers-alert.ts
|
||||||
|
|
||||||
|
[구현 단계]
|
||||||
|
- [x] 1. 호가창 퍼센트 표시 로직 추가: 기준가 대비 등락률 계산 유틸을 만들고 호가 행 UI에 퍼센트를 노출한다. (`features/trade/components/orderbook/orderbook-utils.ts`, `features/trade/components/orderbook/orderbook-sections.tsx`)
|
||||||
|
- [x] 2. 대시보드 시장 허브 API 추가: 급등주식/거래량 상위/뉴스(및 보조 지표)를 KIS에서 조회해 단일 응답으로 반환한다. (`lib/kis/dashboard.ts`, `app/api/kis/domestic/market-hub/route.ts`)
|
||||||
|
- [x] 3. 대시보드 데이터 훅 확장: 기존 balance/indices/activity에 market-hub 데이터를 병렬 조회하고 에러 상태를 분리 관리한다. (`features/dashboard/hooks/use-dashboard-data.ts`, `features/dashboard/apis/dashboard.api.ts`, `features/dashboard/types/dashboard.types.ts`)
|
||||||
|
- [x] 4. /dashboard 메뉴 분리: "시장" 탭과 "내 자산" 탭을 만들고 개인 자산 컴포넌트를 "내 자산" 탭으로 이동한다. (`features/dashboard/components/DashboardContainer.tsx`)
|
||||||
|
- [x] 5. 시장 탭 구성: 급등주식, 인기종목, 주요 뉴스, 추가 정보(시장 폭/업다운 카운트)를 카드로 구성한다. (`features/dashboard/components/MarketHubSection.tsx`)
|
||||||
|
|
||||||
|
[사용할 MCP/Skills]
|
||||||
|
- MCP: next-devtools(런타임 점검), web search(요구사항의 검색 반영)
|
||||||
|
- Skills: dev-auto-pipeline, dev-plan-writer, dev-mcp-implementation, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker, nextjs-app-router-patterns, vercel-react-best-practices
|
||||||
|
|
||||||
|
[참조 문서(common-docs)]
|
||||||
|
- common-docs/api-reference/openapi_all.xlsx
|
||||||
|
- common-docs/api-reference/kis_api_reference.md
|
||||||
|
- common-docs/api-reference/kis-error-code-reference.md
|
||||||
|
- common-docs/features/trade-stock-sync.md
|
||||||
|
- common-docs/ui/GLOBAL_ALERT_SYSTEM.md
|
||||||
|
|
||||||
|
[주석/문서 반영 계획]
|
||||||
|
- 함수 주석: [목적]/[사용처]/[데이터 흐름] 중심으로 유지한다.
|
||||||
|
- 상태 주석: market-hub 로딩/오류 상태가 화면에 미치는 영향 한 줄 주석을 추가한다.
|
||||||
|
- 복잡 로직: 시장 허브 응답 정규화는 1,2,3 단계 주석으로 분해한다.
|
||||||
|
|
||||||
|
[리스크/회귀 포인트]
|
||||||
|
- KIS 순위/뉴스 API는 파라미터 조합에 따라 빈 응답이 나올 수 있어 폴백 파라미터가 필요하다.
|
||||||
|
- 신규 시장 API 실패 시에도 기존 내 자산 탭은 정상 동작해야 한다.
|
||||||
|
- 호가 퍼센트 표시가 모바일에서 줄바꿈/폭 깨짐을 유발할 수 있어 반응형 폭 점검이 필요하다.
|
||||||
|
|
||||||
|
[검증 계획]
|
||||||
|
- [x] 1. lint: 타입/린트 오류 없이 통과하는지 확인. (`npm run lint` 통과)
|
||||||
|
- [x] 2. build: Next.js 프로덕션 빌드가 통과하는지 확인. (`npm run build` 통과)
|
||||||
|
- [x] 3. 런타임: /dashboard 진입 후 시장 탭/내 자산 탭 전환이 정상 동작하는지 확인. (Playwright MCP에서 탭 전환 및 화면 반영 확인)
|
||||||
|
- [x] 4. 런타임: 시장 탭에서 급등/인기/뉴스 카드가 실패 시에도 개별 에러 안내로 안전하게 렌더링되는지 확인. (Playwright MCP route abort로 `/api/kis/domestic/market-hub` 실패 주입 후 `Failed to fetch` + 빈 카드 안전 렌더링 확인)
|
||||||
|
- [x] 5. 런타임: /trade 호가창에서 각 가격 행에 퍼센트가 표시되는지 확인. (Playwright MCP로 `/dashboard` 종목 클릭 이동 후 일반호가 행 `±x.xx%` 표기 확인)
|
||||||
|
|
||||||
|
[진행 로그]
|
||||||
|
- 2026-03-04: 계획 문서 작성.
|
||||||
|
- 2026-03-04: 구현 1~5 완료, `npm run lint`/`npm run build` 통과.
|
||||||
|
- 2026-03-04: 브라우저 스모크 실행 시 `/dashboard`, `/trade`, `/settings`가 비로그인 상태에서 `/login`으로 리다이렉트되는 동작 확인.
|
||||||
|
- 2026-03-04: 급등주 미노출 대응(등락률 API 파라미터 폴백 + 거래량 기반 폴백) 적용.
|
||||||
|
- 2026-03-04: 급락주 데이터 및 급등/급락 주기 알림(60초 갱신 + 3분 쿨다운 모달) 추가.
|
||||||
|
- 2026-03-04: 요청 반영으로 급등/급락 전역 모달 알림 훅 제거.
|
||||||
|
- 2026-03-04: KIS 문서/코드 기준 급등·급락 웹소켓 수신 가능성 검토 완료(순위는 REST, WS는 종목 체결/호가 중심).
|
||||||
|
- 2026-03-04: Playwright MCP로 `/dashboard` 시장/내 자산 탭 전환 정상 동작 재검증 완료.
|
||||||
|
- 2026-03-04: Playwright MCP route abort 주입으로 시장 허브 API 실패 시 에러 안내/빈 상태 카드 안전 렌더링 확인.
|
||||||
|
- 2026-03-04: Playwright MCP로 급등/급락/인기/거래대금 카드 종목 클릭 시 `/trade` 이동 및 선택 종목 반영 확인.
|
||||||
|
- 2026-03-04: Playwright MCP로 `/trade` 일반호가 각 가격 행의 퍼센트(등락률) 표기 확인.
|
||||||
|
|
||||||
|
[계획 대비 완료체크]
|
||||||
|
- 완료: 구현 1~5, 검증 1~5
|
||||||
|
- 부분 완료: 없음
|
||||||
|
- 미완료: 없음
|
||||||
|
- 최종 판정: 배포 가능
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
[계획 문서 경로]
|
||||||
|
- common-docs/improvement/plans/dev-plan-2026-03-04-dashboard-modern-brand-layout-refresh.md
|
||||||
|
|
||||||
|
[요구사항 요약]
|
||||||
|
- /dashboard를 모던하고 현대적인 느낌으로 재배치한다.
|
||||||
|
- 브랜드 컬러(brand 토큰)를 적극 활용해 UI 일관성을 높인다.
|
||||||
|
- 핵심 정보를 한눈에 확인할 수 있도록 정보 우선순위를 재정렬한다.
|
||||||
|
|
||||||
|
[가정]
|
||||||
|
- 기존 정보 구조(시장 탭/내 자산 탭, API/데이터 모델)는 유지하고 UI/레이아웃 중심으로 개선한다.
|
||||||
|
- 기존 브랜드 토큰(--brand-*)을 재사용해 전체 앱 디자인 언어와 일관성을 맞춘다.
|
||||||
|
|
||||||
|
[영향 범위]
|
||||||
|
- 수정:
|
||||||
|
- features/dashboard/components/DashboardContainer.tsx
|
||||||
|
- features/dashboard/components/StatusHeader.tsx
|
||||||
|
- features/dashboard/components/MarketSummary.tsx
|
||||||
|
- features/dashboard/components/MarketHubSection.tsx
|
||||||
|
- features/dashboard/components/HoldingsList.tsx
|
||||||
|
- features/dashboard/components/StockDetailPreview.tsx
|
||||||
|
- features/dashboard/components/ActivitySection.tsx
|
||||||
|
|
||||||
|
[구현 단계]
|
||||||
|
- [x] 1. 대시보드 컨테이너 재배치: 상단 브랜드 히어로/상태 칩 추가, 탭 인터랙션 스타일 강화, 탭별 레이아웃 재정렬.
|
||||||
|
- [x] 2. 자산 헤더 리디자인: 총자산/손익 중심 카드 + 연결 상태/액션 패널 + 핵심 지표 4분할 구성.
|
||||||
|
- [x] 3. 시장 지수 카드 리디자인: 실시간 상태 배지, 지수 카드 시각 톤 강화, 카드 대비 개선.
|
||||||
|
- [x] 4. 시장 허브 리디자인: 급등/급락/인기/거래대금 2x2 구성 및 뉴스 가독성 개선.
|
||||||
|
- [x] 5. 자산 하위 카드 톤 정렬: 보유종목/선택종목/활동내역 카드 스타일 일관화 및 탭 버튼 강조.
|
||||||
|
- [x] 6. 사후 버그 수정: 시장 지수 배지의 실시간 상태 판정을 상단 상태칩과 동일 기준으로 통일.
|
||||||
|
- [x] 7. 사후 버그 수정: Next.js `scroll-behavior` 경고 제거를 위한 루트 html 속성 보완.
|
||||||
|
|
||||||
|
[리스크/회귀 포인트]
|
||||||
|
- 카드 높이/스크롤 높이 조정으로 모바일에서 콘텐츠 길이 체감이 달라질 수 있다.
|
||||||
|
- 탭 스타일 커스터마이징이 다크 모드 대비에 영향을 줄 수 있다.
|
||||||
|
|
||||||
|
[검증 계획]
|
||||||
|
- [x] 1. lint: `npm run lint` 통과.
|
||||||
|
- [x] 2. build: `npm run build` 통과.
|
||||||
|
- [x] 3. 런타임: 로그인 상태에서 /dashboard 시각적 배치/반응형 확인. (Playwright로 데스크톱/모바일, 탭 전환, 메인 왕복 동선 확인)
|
||||||
|
- [x] 4. 런타임: 브라우저 콘솔 경고/오류 확인. (`warning`/`error` 비어있음)
|
||||||
|
- [x] 5. 런타임: API 네트워크 응답 확인. (`/api/kis/domestic/indices`, `/api/kis/domestic/market-hub`, `/api/kis/ws/approval` 모두 200)
|
||||||
|
|
||||||
|
[진행 로그]
|
||||||
|
- 2026-03-04: 대시보드 모던 UI 재배치 구현 완료.
|
||||||
|
- 2026-03-04: `npm run lint` 통과.
|
||||||
|
- 2026-03-04: `npm run build` 통과.
|
||||||
|
- 2026-03-04: Playwright로 `/dashboard` 접근 시 `/login` 리다이렉트 동작 및 모바일 뷰포트(390x844) 렌더링 확인.
|
||||||
|
- 2026-03-04: 시장 지수 배지 상태 문구 불일치(실시간 미연결 vs 수신중) 수정.
|
||||||
|
- 2026-03-04: `app/layout.tsx`에 `data-scroll-behavior=\"smooth\"` 추가로 Next 경고 제거.
|
||||||
|
- 2026-03-04: Playwright 재검증(데스크톱/모바일, 로고->메인, 메인->대시보드, 자산 탭 전환) 완료.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
[계획 문서 경로]
|
||||||
|
- common-docs/improvement/plans/dev-plan-2026-03-05-autotrade-ai-context-layout-boxrange.md
|
||||||
|
|
||||||
|
[요구사항 요약]
|
||||||
|
- 자동매매 신호 생성 시 AI 판단 입력 데이터를 늘린다.
|
||||||
|
- 자동매매 설정창 높이 문제를 해결하고 레이아웃을 더 간결하게 정리한다.
|
||||||
|
- 유명기법을 선택하지 않아도 자동매매가 동작하도록 기본 동작을 완화한다.
|
||||||
|
- "당일 상승 후 박스권 횡보 단타" 기법을 새로 추가한다.
|
||||||
|
|
||||||
|
[가정]
|
||||||
|
- "유명기법 미선택 허용"은 시작 자체 허용 + 서버에서 기본 기법 자동 적용으로 해석한다.
|
||||||
|
- 박스권 단타 기법은 fallback 엔진(규칙 기반)에서 즉시 동작하도록 우선 구현한다.
|
||||||
|
- AI/CLI 모드에도 동일한 추가 스냅샷 데이터를 전달해 판단 품질을 함께 높인다.
|
||||||
|
|
||||||
|
[영향 범위]
|
||||||
|
- 수정:
|
||||||
|
- features/autotrade/types/autotrade.types.ts
|
||||||
|
- features/autotrade/components/AutotradeControlPanel.tsx
|
||||||
|
- features/autotrade/hooks/useAutotradeEngine.ts
|
||||||
|
- features/autotrade/apis/autotrade.api.ts
|
||||||
|
- lib/autotrade/strategy.ts
|
||||||
|
- lib/autotrade/openai.ts
|
||||||
|
- app/api/autotrade/strategies/compile/route.ts
|
||||||
|
- app/api/autotrade/signals/generate/route.ts
|
||||||
|
- 추가:
|
||||||
|
- 없음
|
||||||
|
- 삭제:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
[구현 단계]
|
||||||
|
- [x] 1. 타입/스키마 확장: 자동매매 스냅샷에 체결/호가/파생 지표 필드를 추가하고 클라이언트/서버 타입을 동기화했다. (`features/autotrade/types/autotrade.types.ts`, `features/autotrade/apis/autotrade.api.ts`, `app/api/autotrade/signals/generate/route.ts`)
|
||||||
|
- [x] 2. AI 입력 데이터 확장: `useAutotradeEngine`에서 추가 지표를 계산해 signal API로 전달하고, OpenAI 프롬프트 안내 문구를 업데이트했다. (`features/autotrade/hooks/useAutotradeEngine.ts`, `lib/autotrade/openai.ts`, `features/autotrade/components/AutotradeControlPanel.tsx`)
|
||||||
|
- [x] 3. 유명기법 미선택 허용: 시작 버튼 조건/사전 검증 제한을 완화하고, compile 라우트에서 기본 기법 자동 적용을 넣었다. (`features/autotrade/components/AutotradeControlPanel.tsx`, `features/autotrade/hooks/useAutotradeEngine.ts`, `app/api/autotrade/strategies/compile/route.ts`)
|
||||||
|
- [x] 4. 박스권 단타 기법 추가: 기법 목록에 항목을 추가하고 fallback 신호 로직에 박스권 왕복 단타 판단을 구현했다. (`features/autotrade/types/autotrade.types.ts`, `lib/autotrade/strategy.ts`)
|
||||||
|
- [x] 5. 자동매매창 레이아웃 개선: 모달 높이 잘림을 없애고(내부 스크롤), 섹션 구조를 간결화했다. (`features/autotrade/components/AutotradeControlPanel.tsx`)
|
||||||
|
|
||||||
|
[사용할 MCP/Skills]
|
||||||
|
- MCP: shell_command, apply_patch, playwright
|
||||||
|
- Skills: dev-auto-pipeline, dev-plan-writer, dev-mcp-implementation, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker, nextjs-app-router-patterns, vercel-react-best-practices
|
||||||
|
|
||||||
|
[참조 문서(common-docs)]
|
||||||
|
- common-docs/api-reference/openapi_all.xlsx
|
||||||
|
- common-docs/api-reference/kis_api_reference.md
|
||||||
|
- common-docs/api-reference/kis-error-code-reference.md
|
||||||
|
- common-docs/features/trade-stock-sync.md
|
||||||
|
- common-docs/ui/GLOBAL_ALERT_SYSTEM.md
|
||||||
|
|
||||||
|
[주석/문서 반영 계획]
|
||||||
|
- 데이터 흐름 주석: "입력 데이터 확장" 구간에 [Step 1]/[Step 2]를 추가한다.
|
||||||
|
- UI 주석: 모달 섹션을 상단 요약/설정 본문/하단 액션으로 분리해 가독성을 유지한다.
|
||||||
|
- 박스권 기법 주석: 조건(상승폭, 박스 범위, 상하단 근접)과 신호 방향을 한글로 명확히 남긴다.
|
||||||
|
|
||||||
|
[리스크/회귀 포인트]
|
||||||
|
- 스냅샷 필드 확장 시 signal 라우트 zod 스키마 불일치가 발생할 수 있다.
|
||||||
|
- 유명기법 미선택 허용 이후에도 과도한 신호가 나오지 않게 fallback 신호 품질을 확인해야 한다.
|
||||||
|
- 설정 모달 레이아웃 변경 시 모바일에서 버튼 접근/스크롤 충돌이 생길 수 있다.
|
||||||
|
|
||||||
|
[검증 계획]
|
||||||
|
- [x] 1. lint: 타입/린트 오류 없이 통과 (`npm run lint` 통과)
|
||||||
|
- [x] 2. build: 프로덕션 빌드 통과 (`npm run build` 통과)
|
||||||
|
- [x] 3. 동작: 기법 미선택 허용 코드 경로 확인 (`canStartAutotrade` 조건 완화, `prepareStrategy` 필수 체크 제거, compile 기본 기법 자동 적용)
|
||||||
|
- [x] 4. 동작: 박스권 단타 기법이 목록/enum/fallback 로직에 반영됨을 코드 경로 확인
|
||||||
|
- [x] 5. 동작: 설정 화면 스모크에서 신규 체크박스/설정 UI 접근 및 콘솔 치명 오류 없음 확인 (Playwright). 자동매매 설정 모달은 KIS 미연결 환경으로 직접 실행 검증은 제한
|
||||||
|
|
||||||
|
[진행 로그]
|
||||||
|
- 2026-03-05: 계획 문서 작성.
|
||||||
|
- 2026-03-05: 자동매매 스냅샷 확장(체결/호가/파생 지표) 및 signal API 스키마 동기화 완료.
|
||||||
|
- 2026-03-05: 유명기법 미선택 허용(기본 기법 자동 적용) 반영 완료.
|
||||||
|
- 2026-03-05: "상승 후 박스권 단타" 기법 추가 및 fallback 신호 로직 구현 완료.
|
||||||
|
- 2026-03-05: 자동매매 설정 모달 레이아웃 간소화/높이 잘림 개선(내부 스크롤) 적용.
|
||||||
|
- 2026-03-05: `npm run lint`, `npm run build` 통과.
|
||||||
|
- 2026-03-05: Playwright 스모크(`/trade`, `/dashboard`, `/settings`) 확인, 콘솔 치명 오류 없음.
|
||||||
|
|
||||||
|
[계획 대비 완료체크]
|
||||||
|
- 완료: 구현 1~5, 검증 1~5
|
||||||
|
- 부분 완료: 없음
|
||||||
|
- 미완료: 없음
|
||||||
|
- 최종 판정: 배포 가능
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
[계획 문서 경로]
|
||||||
|
- common-docs/improvement/plans/dev-plan-2026-03-05-autotrade-observability-momentum-scalp.md
|
||||||
|
|
||||||
|
[요구사항 요약]
|
||||||
|
- 자동매매 로그에 AI 진행상태/응답 근거/주요 수치가 보이도록 개선한다.
|
||||||
|
- 자동매매에서 AI로 보내는 데이터 항목을 코드 기준으로 명확히 보여준다(로그/설명 근거 강화).
|
||||||
|
- 1분봉 상승구간 단타(눌림-재돌파) 기법을 새로 추가한다.
|
||||||
|
|
||||||
|
[가정]
|
||||||
|
- "좋은 데이터 로그"는 사용자 화면에서 즉시 확인 가능한 런타임 로그 품질 개선(단계, 공급자, 핵심 수치, AI 근거)으로 해석한다.
|
||||||
|
- AI 전송 데이터 "상세" 요구는 코드 반영(진단 로그) + 최종 보고에서 필드 목록/흐름 설명으로 충족한다.
|
||||||
|
- 신규 전략은 규칙 기반 fallback에서 즉시 동작하고, OpenAI/CLI 프롬프트 가이드에도 동일 기법명을 반영한다.
|
||||||
|
|
||||||
|
[영향 범위]
|
||||||
|
- 수정:
|
||||||
|
- features/autotrade/types/autotrade.types.ts
|
||||||
|
- features/autotrade/stores/use-autotrade-engine-store.ts
|
||||||
|
- features/autotrade/hooks/useAutotradeEngine.ts
|
||||||
|
- features/autotrade/components/AutotradeControlPanel.tsx
|
||||||
|
- lib/autotrade/strategy.ts
|
||||||
|
- lib/autotrade/openai.ts
|
||||||
|
- app/api/autotrade/signals/generate/route.ts (필요 시)
|
||||||
|
- common-docs/improvement/plans/dev-plan-2026-03-05-autotrade-observability-momentum-scalp.md
|
||||||
|
- 추가:
|
||||||
|
- 없음
|
||||||
|
- 삭제:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
[구현 단계]
|
||||||
|
- [x] 1. 로그 타입 확장: 런타임 로그에 단계(stage)와 상세 데이터(detail)를 담을 수 있게 타입/스토어를 확장한다. (`features/autotrade/types/autotrade.types.ts`, `features/autotrade/stores/use-autotrade-engine-store.ts`)
|
||||||
|
- [x] 2. 엔진 로그 강화: compile/signal/risk/order 흐름에서 "요청 전송", "AI 응답", "주문 차단/실행"을 구조화 로그로 남긴다. (`features/autotrade/hooks/useAutotradeEngine.ts`)
|
||||||
|
- [x] 3. 로그 UI 개선: 상단 최근 로그 영역에서 단계/레벨/상세 데이터를 읽기 쉽게 표시한다. (`features/autotrade/components/AutotradeControlPanel.tsx`)
|
||||||
|
- [x] 4. 상승구간 단타 기법 추가: 기법 enum/옵션 추가 + fallback 로직(추세 필터, 눌림 구간, 재돌파, 거래량 확인) 구현. (`features/autotrade/types/autotrade.types.ts`, `lib/autotrade/strategy.ts`)
|
||||||
|
- [x] 5. OpenAI 가이드 반영: 신규 기법 설명과 판단 제약을 프롬프트에 반영한다. (`lib/autotrade/openai.ts`)
|
||||||
|
|
||||||
|
[사용할 MCP/Skills]
|
||||||
|
- MCP: shell_command, apply_patch, tavily-remote, playwright
|
||||||
|
- Skills: dev-auto-pipeline, dev-plan-writer, dev-mcp-implementation, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker, vercel-react-best-practices
|
||||||
|
|
||||||
|
[참조 문서(common-docs)]
|
||||||
|
- common-docs/api-reference/openapi_all.xlsx
|
||||||
|
- common-docs/api-reference/kis_api_reference.md
|
||||||
|
- common-docs/api-reference/kis-error-code-reference.md
|
||||||
|
- common-docs/features/trade-stock-sync.md
|
||||||
|
- common-docs/ui/GLOBAL_ALERT_SYSTEM.md
|
||||||
|
|
||||||
|
[외부 근거(전략 설계)]
|
||||||
|
- Investopedia Flag Pattern: 상승 추세 + 조정 중 거래량 축소 + 돌파 시 거래량 확인
|
||||||
|
- Investopedia Low Volume Pullback: 저거래량 눌림 후 추세 재개 확률
|
||||||
|
- Fidelity Technical Analysis(학습 PDF): 이동평균 기반 추세/눌림 해석, 거래량/모멘텀 보조 확인
|
||||||
|
|
||||||
|
[주석/문서 반영 계획]
|
||||||
|
- 함수 주석: [목적]/[데이터 흐름] 유지
|
||||||
|
- 상태 주석: 로그 stage/detail 도입 영향 표시
|
||||||
|
- 복잡 로직: 신규 상승구간 단타 판단 함수를 [Step 1~3] 주석으로 분리
|
||||||
|
- JSX 구역 주석: 로그 카드 영역을 단계/상세 구분 렌더링으로 분리
|
||||||
|
|
||||||
|
[리스크/회귀 포인트]
|
||||||
|
- 로그 데이터가 과도하면 UI 가독성이 저하될 수 있어 길이 제한/요약이 필요하다.
|
||||||
|
- 신규 전략이 기존 박스권 단타와 동시에 점수를 높여 과매수 신호가 늘 수 있어 임계값을 보수적으로 둔다.
|
||||||
|
- 타입 확장 시 기존 appendLog 호출과의 호환성을 유지해야 한다.
|
||||||
|
|
||||||
|
[검증 계획]
|
||||||
|
- [x] 1. lint: 타입/린트 오류 없는지 확인 (`npm run lint` 통과)
|
||||||
|
- [x] 2. build: Next 빌드 통과 확인 (`npm run build` 통과)
|
||||||
|
- [x] 3. 동작: 자동매매 로그에 stage/detail이 표시되는지 코드 경로/UI 확인 (`useAutotradeEngine` 로그 작성 + `AutotradeControlPanel` 렌더 반영)
|
||||||
|
- [x] 4. 동작: 신규 기법이 목록과 fallback 로직에 반영됐는지 확인 (`intraday_breakout_scalp` enum/옵션/룰 추가)
|
||||||
|
- [x] 5. 동작: /trade 화면 스모크에서 콘솔 오류 없이 렌더링 확인 (Playwright). 비로그인 환경으로 `/login` 리다이렉트 확인, 콘솔 error 없음
|
||||||
|
|
||||||
|
[진행 로그]
|
||||||
|
- 2026-03-05: 계획 문서 작성.
|
||||||
|
- 2026-03-05: 런타임 로그 구조(stage/detail) 확장 및 로그 UI 상세표시 반영.
|
||||||
|
- 2026-03-05: 로그 UI를 기본 접힘(차트 가림 최소화) + 쉬운 문장 요약 + 개발자 상세 토글 + 라이브 커서 표시로 개선.
|
||||||
|
- 2026-03-05: AI 신호 요청/응답/리스크게이트/주문실행 흐름 구조화 로그 반영.
|
||||||
|
- 2026-03-05: 상승구간 눌림-재돌파 단타(`intraday_breakout_scalp`) 기법 추가.
|
||||||
|
- 2026-03-05: AI 신호 사유 한글 강제(프롬프트 + 서버 후처리) 반영.
|
||||||
|
- 2026-03-05: 상단 예산 카드에 검증 전 입력 기준 예산 표시 추가.
|
||||||
|
- 2026-03-05: 신호 API 호출을 in-flight 순차 처리로 변경(이전 응답 완료 전 재호출 차단).
|
||||||
|
- 2026-03-05: 상단 로그를 `입력 -> 답변` 1쌍 고정 표시로 개선(응답 대기 상태 포함).
|
||||||
|
- 2026-03-05: `npm run lint`, `npm run build` 통과.
|
||||||
|
- 2026-03-05: Playwright 스모크(`/trade`, `/dashboard`, `/settings`) 실행, 비로그인 리다이렉트 경로에서 콘솔 error 없음.
|
||||||
|
|
||||||
|
[계획 대비 완료체크]
|
||||||
|
- 완료: 구현 1~5, 검증 1~5
|
||||||
|
- 부분 완료: 없음
|
||||||
|
- 미완료: 없음
|
||||||
|
- 최종 판정: 배포 가능
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
[계획 문서 경로]
|
||||||
|
- common-docs/improvement/plans/dev-plan-2026-03-05-autotrade-risk-input-simplify.md
|
||||||
|
|
||||||
|
[요구사항 요약]
|
||||||
|
- 자동매매 투자금/손실 설정의 기존 복잡한 계산 로직(작은 값 선택)을 제거한다.
|
||||||
|
- 사용자가 입력한 투자금 금액/손실 금액이 실제 거래 기준으로 직접 반영되게 한다.
|
||||||
|
- 퍼센트 입력은 유지하되, 이해하기 쉬운 기준(경고/참고)으로 단순화한다.
|
||||||
|
|
||||||
|
[가정]
|
||||||
|
- "로직 없애고"는 `min(퍼센트 계산값, 금액)` 기반 자동 축소 로직 제거로 해석한다.
|
||||||
|
- 실제 주문 예산은 `투자금 금액(allocationAmount)` 그대로 사용한다.
|
||||||
|
- 자동중지 손실선은 `손실 금액(dailyLossAmount)` 그대로 사용한다.
|
||||||
|
- 퍼센트 입력값은 유지하고, 금액과 충돌 시 차단 대신 경고로 안내한다.
|
||||||
|
|
||||||
|
[영향 범위]
|
||||||
|
- 수정:
|
||||||
|
- lib/autotrade/risk.ts
|
||||||
|
- features/autotrade/components/AutotradeControlPanel.tsx
|
||||||
|
- common-docs/improvement/plans/dev-plan-2026-03-05-autotrade-risk-input-simplify.md
|
||||||
|
- 추가:
|
||||||
|
- 없음
|
||||||
|
- 삭제:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
[구현 단계]
|
||||||
|
- [x] 1. 리스크 계산식 단순화: 실적용 투자금/손실한도를 입력 금액 그대로 쓰도록 변경했다. (`lib/autotrade/risk.ts`)
|
||||||
|
- [x] 2. 퍼센트 해석 단순화: 퍼센트는 참고 기준 경고로만 반영했다. (`lib/autotrade/risk.ts`, `app/api/autotrade/strategies/validate/route.ts`)
|
||||||
|
- [x] 3. UI 문구 정리: "중 작은 값" 설명을 제거하고 "입력값 직접 적용"으로 변경했다. (`features/autotrade/components/AutotradeControlPanel.tsx`)
|
||||||
|
- [x] 4. 리스크 요약 카드 문구를 새 계산식에 맞게 정리했다. (`features/autotrade/components/AutotradeControlPanel.tsx`)
|
||||||
|
|
||||||
|
[사용할 MCP/Skills]
|
||||||
|
- MCP: shell_command, apply_patch
|
||||||
|
- Skills: dev-auto-pipeline, dev-plan-writer, dev-mcp-implementation, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker
|
||||||
|
|
||||||
|
[참조 문서(common-docs)]
|
||||||
|
- common-docs/api-reference/openapi_all.xlsx
|
||||||
|
- common-docs/api-reference/kis_api_reference.md
|
||||||
|
- common-docs/api-reference/kis-error-code-reference.md
|
||||||
|
- common-docs/features/trade-stock-sync.md
|
||||||
|
- common-docs/ui/GLOBAL_ALERT_SYSTEM.md
|
||||||
|
|
||||||
|
[리스크/회귀 포인트]
|
||||||
|
- 기존보다 공격적으로 주문이 나갈 수 있어(자동 축소 제거) 금액 입력 검증이 중요하다.
|
||||||
|
- 퍼센트 필드가 무의미하게 보이지 않도록 경고 기준 문구를 명확히 해야 한다.
|
||||||
|
|
||||||
|
[검증 계획]
|
||||||
|
- [x] 1. lint 통과 (`npm run lint` 통과)
|
||||||
|
- [x] 2. build 통과 (`npm run build` 통과)
|
||||||
|
- [x] 3. 코드 경로 확인: 주문 수량 계산에 쓰이는 `effectiveAllocationAmount`가 입력 금액 기준으로 세팅됨 확인 (`lib/autotrade/risk.ts` -> `features/autotrade/hooks/useAutotradeEngine.ts` -> `resolveOrderQuantity`)
|
||||||
|
- [x] 4. UI 문구 확인: "작은 값" 문구 제거 확인 (`features/autotrade/components/AutotradeControlPanel.tsx`)
|
||||||
|
|
||||||
|
[진행 로그]
|
||||||
|
- 2026-03-05: 계획 문서 작성.
|
||||||
|
- 2026-03-05: 투자금/손실 계산 로직을 입력 금액 직접 적용 방식으로 단순화.
|
||||||
|
- 2026-03-05: 퍼센트 필드를 참고 경고용으로 전환하고 검증 스키마를 nonnegative로 완화.
|
||||||
|
- 2026-03-05: 자동매매 설정/리스크 요약 문구를 새 계산식 기준으로 업데이트.
|
||||||
|
- 2026-03-05: `npm run lint`, `npm run build` 통과.
|
||||||
|
|
||||||
|
[계획 대비 완료체크]
|
||||||
|
- 완료: 구현 1~4, 검증 1~4
|
||||||
|
- 부분 완료: 없음
|
||||||
|
- 미완료: 없음
|
||||||
|
- 최종 판정: 배포 가능
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
[계획 문서 경로]
|
||||||
|
- common-docs/improvement/plans/dev-plan-2026-03-05-kis-remember-credentials-checkbox.md
|
||||||
|
|
||||||
|
[요구사항 요약]
|
||||||
|
- 설정 화면에서 앱토큰(앱키), 앱시크릿키, 계좌번호에 대해 "기억하기" 체크박스를 제공한다.
|
||||||
|
- 체크한 항목만 브라우저 재시작 후에도 복원되도록 로컬 저장을 추가한다.
|
||||||
|
- 기존 KIS 검증/계좌인증 동작은 그대로 유지한다.
|
||||||
|
|
||||||
|
[가정]
|
||||||
|
- 사용자 요청의 "앱토큰"은 현재 화면 필드명 기준 "앱키(appKey)"로 해석한다.
|
||||||
|
- "기억하기"는 장기 저장(localStorage), 미체크는 저장하지 않음으로 해석한다.
|
||||||
|
- 기존 세션값이 있으면(이미 입력/검증된 상태) 기억값 자동 복원으로 덮어쓰지 않는다.
|
||||||
|
|
||||||
|
[영향 범위]
|
||||||
|
- 수정:
|
||||||
|
- features/settings/components/KisAuthForm.tsx
|
||||||
|
- features/settings/components/KisProfileForm.tsx
|
||||||
|
- features/layout/components/user-menu.tsx
|
||||||
|
- features/auth/components/session-manager.tsx
|
||||||
|
- 추가:
|
||||||
|
- features/settings/lib/kis-remember-storage.ts
|
||||||
|
- 삭제:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
[구현 단계]
|
||||||
|
- [x] 1. 기억하기 저장 유틸 추가: 앱키/앱시크릿/계좌별 체크 상태/값을 localStorage로 읽기/쓰기/삭제하는 공통 함수를 만들었다. (`features/settings/lib/kis-remember-storage.ts`)
|
||||||
|
- [x] 2. 앱키/앱시크릿 체크박스 UI 추가: 인증 폼에 2개 체크박스를 추가하고, 체크 여부에 따라 자동 저장/삭제를 연결했다. (`features/settings/components/KisAuthForm.tsx`)
|
||||||
|
- [x] 3. 계좌번호 체크박스 UI 추가: 계좌 인증 폼에 체크박스를 추가하고 동일한 저장/복원 흐름을 연결했다. (`features/settings/components/KisProfileForm.tsx`)
|
||||||
|
- [x] 4. 로그아웃/세션만료 시 정리 연동: 기존 세션 정리 루틴에 기억값 키를 포함해 민감 정보가 남지 않게 했다. (`features/layout/components/user-menu.tsx`, `features/auth/components/session-manager.tsx`)
|
||||||
|
|
||||||
|
[사용할 MCP/Skills]
|
||||||
|
- MCP: shell_command(코드 탐색/수정), apply_patch(파일 수정)
|
||||||
|
- Skills: dev-auto-pipeline, dev-plan-writer, dev-mcp-implementation, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker, nextjs-app-router-patterns, vercel-react-best-practices
|
||||||
|
|
||||||
|
[참조 문서(common-docs)]
|
||||||
|
- common-docs/api-reference/openapi_all.xlsx
|
||||||
|
- common-docs/api-reference/kis_api_reference.md
|
||||||
|
- common-docs/api-reference/kis-error-code-reference.md
|
||||||
|
- common-docs/features/trade-stock-sync.md
|
||||||
|
- common-docs/ui/GLOBAL_ALERT_SYSTEM.md
|
||||||
|
|
||||||
|
[주석/문서 반영 계획]
|
||||||
|
- 상태 주석: 기억하기 체크 상태가 입력 필드 자동복원/저장에 미치는 영향을 한 줄 주석으로 추가한다.
|
||||||
|
- 복잡 로직: "초기 복원"과 "변경 저장"을 [Step 1], [Step 2] 주석으로 분리한다.
|
||||||
|
- JSX 구역 주석: 입력/체크박스 구역을 나눠 화면 구조를 더 쉽게 읽게 유지한다.
|
||||||
|
|
||||||
|
[리스크/회귀 포인트]
|
||||||
|
- 체크박스 초기화 시 하이드레이션 타이밍 차이로 깜빡임이 생길 수 있다.
|
||||||
|
- store 입력 setter 호출은 인증 상태를 리셋하므로, 복원 시 기존 세션값을 덮어쓰지 않도록 조건이 필요하다.
|
||||||
|
- 민감값 장기 저장 정책 변경이므로 로그아웃 시 정리 누락이 없어야 한다.
|
||||||
|
|
||||||
|
[검증 계획]
|
||||||
|
- [x] 1. lint: 타입/린트 오류 없이 통과했다. (`npm run lint` 통과)
|
||||||
|
- [x] 2. build: Next.js 프로덕션 빌드가 통과했다. (`npm run build` 통과)
|
||||||
|
- [x] 3. 동작: 체크박스/저장 로직을 코드 경로로 검증했다. (기억하기 on/off -> `setKisRememberEnabled` -> `setRememberedKisValue`)
|
||||||
|
- [x] 4. 동작: 복원 로직을 코드 경로로 검증했다. (`hasHydrated` 이후 입력값 비어 있을 때만 `getRememberedKisValue` 복원)
|
||||||
|
- [x] 5. 동작: 로그아웃/세션만료 시 기억값 정리 키 포함을 반영했다. (`SESSION_RELATED_STORAGE_KEYS`에 `KIS_REMEMBER_LOCAL_STORAGE_KEYS` 추가)
|
||||||
|
|
||||||
|
[진행 로그]
|
||||||
|
- 2026-03-05: 계획 문서 작성.
|
||||||
|
- 2026-03-05: 구현 1~4 완료 (기억하기 체크박스 + localStorage 유틸 + 세션 정리 키 반영).
|
||||||
|
- 2026-03-05: `npm run lint`, `npm run build` 통과.
|
||||||
|
- 2026-03-05: Playwright 스모크에서 `/settings` 접근 시 `/login` 리다이렉트 및 콘솔 치명 오류 없음 확인(인증 미보유로 설정 폼 직접 상호작용은 환경상 제한).
|
||||||
|
|
||||||
|
[계획 대비 완료체크]
|
||||||
|
- 완료: 구현 1~4, 검증 1~5
|
||||||
|
- 부분 완료: 없음
|
||||||
|
- 미완료: 없음
|
||||||
|
- 최종 판정: 배포 가능
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# [계획 문서]
|
||||||
|
- 경로: `common-docs/improvement/plans/dev-plan-2026-03-05-trade-chart-timeframes-and-history.md`
|
||||||
|
|
||||||
|
## [요구사항 요약]
|
||||||
|
- 차트 표시/상호작용을 개선한다. (공식 문서 기준 반영)
|
||||||
|
- 분봉 옵션에 5분/10분/15분을 추가한다.
|
||||||
|
- 1시간봉 과거 데이터가 짧게 보이는 원인을 수정한다.
|
||||||
|
|
||||||
|
## [가정]
|
||||||
|
- 기존 차트 라이브러리는 `lightweight-charts@5.1.0`을 유지한다.
|
||||||
|
- KIS 분봉 API는 당일/일별 분봉 API를 조합해 과거 데이터를 이어 붙인다.
|
||||||
|
- UI 레이아웃 전체 재설계보다 차트 영역 중심 개선을 우선한다.
|
||||||
|
|
||||||
|
## [영향 범위]
|
||||||
|
- 수정: `features/trade/types/trade.types.ts`
|
||||||
|
- 수정: `features/trade/components/chart/stock-line-chart-meta.ts`
|
||||||
|
- 수정: `features/trade/components/chart/chart-utils.ts`
|
||||||
|
- 수정: `features/trade/components/chart/StockLineChart.tsx`
|
||||||
|
- 수정: `lib/kis/domestic-helpers.ts`
|
||||||
|
- 수정: `app/api/kis/domestic/chart/route.ts`
|
||||||
|
|
||||||
|
## [구현 단계]
|
||||||
|
- [x] 1. 차트/타임프레임 타입 확장 (`1m/5m/10m/15m/30m/1h/1d/1w`)
|
||||||
|
- 근거: `features/trade/types/trade.types.ts`
|
||||||
|
- [x] 2. 분봉 버킷 계산 로직 확장 (5/10/15분 지원)
|
||||||
|
- 근거: `lib/kis/domestic-helpers.ts`, `features/trade/components/chart/chart-utils.ts`, `app/api/kis/domestic/chart/route.ts`
|
||||||
|
- [x] 3. 차트 초기 과거 로드량을 시간프레임별로 확장해 1시간봉 과거 구간 부족 개선
|
||||||
|
- 근거: `features/trade/components/chart/stock-line-chart-meta.ts`, `features/trade/components/chart/StockLineChart.tsx`
|
||||||
|
- [x] 4. infinite history 로딩 트리거를 공식 문서 권장 패턴(`barsInLogicalRange`)으로 보강
|
||||||
|
- 근거: `features/trade/components/chart/StockLineChart.tsx`
|
||||||
|
- [x] 5. 차트 가시성 옵션(축 여백/우측 여백/가격선) 미세 개선
|
||||||
|
- 근거: `features/trade/components/chart/StockLineChart.tsx` (`timeScale.rightOffset/barSpacing/minBarSpacing/rightBarStaysOnScroll`)
|
||||||
|
|
||||||
|
## [사용할 MCP/Skills]
|
||||||
|
- MCP: `tavily-remote` (lightweight-charts 공식 문서 확인)
|
||||||
|
- MCP: `mcp:kis-code-assistant-mcp` (KIS 분봉 API 파라미터/제약 확인)
|
||||||
|
- Skills: `dev-auto-pipeline`, `vercel-react-best-practices`
|
||||||
|
|
||||||
|
## [참조 문서(common-docs)]
|
||||||
|
- `common-docs/api-reference/openapi_all.xlsx`
|
||||||
|
- `common-docs/api-reference/kis_api_reference.md`
|
||||||
|
- `common-docs/api-reference/kis-error-code-reference.md`
|
||||||
|
- `common-docs/features/trade-stock-sync.md`
|
||||||
|
- `common-docs/ui/GLOBAL_ALERT_SYSTEM.md`
|
||||||
|
|
||||||
|
## [리스크/회귀 포인트]
|
||||||
|
- 분봉 추가 후 기존 30분/1시간 정렬 경계가 깨질 수 있음
|
||||||
|
- 과거 로드량 증가 시 초기 로딩 시간이 늘 수 있음
|
||||||
|
- 무한 스크롤 조건 변경 시 중복 API 호출이 발생할 수 있음
|
||||||
|
|
||||||
|
## [검증 계획]
|
||||||
|
- [x] 1. 타입/빌드 검증: `npm run lint`
|
||||||
|
- 근거: 통과
|
||||||
|
- [x] 2. 프로덕션 빌드 검증: `npm run build`
|
||||||
|
- 근거: 통과
|
||||||
|
- [x] 3. 수동 점검: 분봉 드롭다운(1/5/10/15/30/60분) 노출 확인
|
||||||
|
- 근거: Playwriter 스냅샷에서 `1분/5분/10분/15분/30분/1시간` 버튼 노출 확인
|
||||||
|
- [x] 4. 수동 점검: 1시간봉 진입 직후 과거 구간 확장 여부 확인
|
||||||
|
- 근거: `/api/kis/domestic/chart?timeframe=1h` 초기 요청 19건 확인, 최소 시각 `2026-02-26 09:00:00(KST)`까지 로드
|
||||||
|
- [x] 5. 수동 점검: 좌측 스크롤 시 과거 데이터 추가 로딩 유지 확인
|
||||||
|
- 근거: 차트 드래그 후 `timeframe=1h` 추가 요청 5건 발생, 최소 시각 `2026-02-25 09:00:00(KST)`로 확장
|
||||||
|
|
||||||
|
## [진행 로그]
|
||||||
|
- 2026-03-05: 계획 문서 생성.
|
||||||
|
- 2026-03-05: `lightweight-charts` 공식 문서 확인 (`subscribeVisibleLogicalRangeChange`, `barsInLogicalRange`, infinite history 데모).
|
||||||
|
- 2026-03-05: `kis-code-assistant-mcp`로 `inquire_time_itemchartprice`, `inquire_time_dailychartprice` 예제 확인 (당일/과거 분봉 API 호출 제약 확인).
|
||||||
|
- 2026-03-05: 차트 타임프레임 확장(5/10/15분) + 과거 로드 로직 개선 + KIS 분봉 cursor 파싱 보강 적용.
|
||||||
|
- 2026-03-05: `npm run lint`, `npm run build` 통과.
|
||||||
|
- 2026-03-05: Playwriter 실브라우저 검증 수행(`/trade`), 분봉 메뉴/1시간봉 과거 로드/좌측 스크롤 추가 로드 확인.
|
||||||
|
- 2026-03-05: 1시간봉 초기 과거 로드 상한 추가 상향(페이지 수 + 목표 봉 수 + 12초 예산), 재검증 시 최소 시각 `2026-02-05 09:00:00(KST)`까지 자동 로드 확인.
|
||||||
|
- 2026-03-05: 창 확장 시 좌측 공백 보완 로직 추가(초기 fitContent 보강 + left whitespace 자동 추가 로드), 1920px 기준 재검증 시 최소 시각 `2026-01-30 13:00:00(KST)`까지 자동 로드 확인.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
[계획 문서 경로]
|
||||||
|
- common-docs/improvement/plans/dev-plan-2026-03-06-autotrade-ai-signal-context.md
|
||||||
|
|
||||||
|
[요구사항 요약]
|
||||||
|
- 자동매매에서 AI에 넘기는 신호 생성 입력값이 신규 프롬프트 요구사항을 만족하는지 점검한다.
|
||||||
|
- 부족한 데이터가 있으면 실제 신호 요청 payload에 추가한다.
|
||||||
|
- 변경 후 검증 결과까지 남긴다.
|
||||||
|
|
||||||
|
[가정]
|
||||||
|
- 신규 프롬프트의 핵심 요구는 `직전 강한 움직임 + 최근 1분봉 압축 구간` 판단이다.
|
||||||
|
- 현재 전달 중인 최근 체결/호가 파생값만으로는 캔들 구조 판단이 부족하다.
|
||||||
|
- 실시간 주문 루프는 유지하되, 추가 데이터는 기존 KIS 차트 API를 재사용해 보강한다.
|
||||||
|
|
||||||
|
[영향 범위]
|
||||||
|
- 수정: features/autotrade/hooks/useAutotradeEngine.ts
|
||||||
|
- 수정: features/autotrade/apis/autotrade.api.ts
|
||||||
|
- 수정: features/autotrade/types/autotrade.types.ts
|
||||||
|
- 수정: app/api/autotrade/signals/generate/route.ts
|
||||||
|
- 수정: lib/autotrade/openai.ts
|
||||||
|
- 수정: lib/autotrade/cli-provider.ts
|
||||||
|
- 수정: common-docs/improvement/plans/dev-plan-2026-03-06-autotrade-ai-signal-context.md
|
||||||
|
- 추가: 없음
|
||||||
|
- 삭제: 없음
|
||||||
|
|
||||||
|
[구현 단계]
|
||||||
|
- [x] 1. 현재 신호 생성 입력값과 신규 프롬프트 요구사항 차이를 정리한다.
|
||||||
|
- 근거: 기존 signal payload에는 틱/호가/체결 파생값만 있고, 최근 1분봉 OHLCV와 원본 사용자 prompt가 빠져 있었음.
|
||||||
|
- [x] 2. 최근 1분봉 OHLCV와 관련 파생값을 담을 타입/요청 스키마를 추가한다.
|
||||||
|
- 근거: `features/autotrade/types/autotrade.types.ts`, `app/api/autotrade/signals/generate/route.ts`
|
||||||
|
- [x] 3. 자동매매 훅에서 최근 1분봉 데이터를 조회/캐시하고 신호 요청 snapshot에 포함한다.
|
||||||
|
- 근거: `features/autotrade/hooks/useAutotradeEngine.ts`
|
||||||
|
- [x] 4. OpenAI/구독형 CLI 프롬프트가 새 입력값을 활용하도록 지시문을 보강한다.
|
||||||
|
- 근거: `lib/autotrade/openai.ts`, `lib/autotrade/cli-provider.ts`
|
||||||
|
- [x] 5. 로그 요약에 새 입력 데이터가 보이도록 정리한다.
|
||||||
|
- 근거: `features/autotrade/hooks/useAutotradeEngine.ts`
|
||||||
|
|
||||||
|
[사용할 MCP/Skills]
|
||||||
|
- MCP: next-devtools(init), update_plan
|
||||||
|
- Skills: dev-auto-pipeline, dev-plan-writer, dev-mcp-implementation, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker, vercel-react-best-practices
|
||||||
|
|
||||||
|
[참조 문서(common-docs)]
|
||||||
|
- common-docs/api-reference/kis_api_reference.md
|
||||||
|
- common-docs/api-reference/kis-error-code-reference.md
|
||||||
|
- common-docs/features/trade-stock-sync.md
|
||||||
|
- common-docs/ui/GLOBAL_ALERT_SYSTEM.md
|
||||||
|
|
||||||
|
[주석/문서 반영 계획]
|
||||||
|
- 함수 주석: [목적]/[사용처]/[데이터 흐름] 유지
|
||||||
|
- 상태 주석: 값 변경 시 화면/주문 루프 영향 한 줄 설명
|
||||||
|
- 복잡 로직/핸들러: [Step 1], [Step 2], [Step 3] 구조 유지
|
||||||
|
- JSX 구역 주석: 기존 구조 유지, 필요 시 최소 보강
|
||||||
|
|
||||||
|
[리스크/회귀 포인트]
|
||||||
|
- 1분봉 조회를 신호 루프마다 과도하게 호출하면 응답 지연이 늘 수 있다.
|
||||||
|
- 차트 조회 실패 시 신호 생성 자체가 막히지 않도록 기존 snapshot fallback을 유지해야 한다.
|
||||||
|
- 타입 확장 후 route/request schema가 불일치하면 신호 요청이 400으로 실패할 수 있다.
|
||||||
|
|
||||||
|
[검증 계획]
|
||||||
|
- [x] 1. 타입/요청 스키마가 일치하는지 `npm run lint`로 확인한다.
|
||||||
|
- 결과: 통과
|
||||||
|
- [x] 2. OpenAI/CLI 프롬프트에 1분봉 데이터와 압축 구간 판단 지시가 반영됐는지 코드로 확인한다.
|
||||||
|
- 결과: `operatorPrompt`, `recentMinuteCandles`, `minutePatternContext` 활용 지시 반영 완료
|
||||||
|
- [x] 3. 신호 요청 snapshot 로그에 새 필드가 노출되는지 코드 기준으로 확인한다.
|
||||||
|
- 결과: `snapshotSummary`, `snapshot` 로그에 minutePattern/recentMinuteCandlesTail 반영 완료
|
||||||
|
|
||||||
|
[진행 로그]
|
||||||
|
- 2026-03-06: 기존 snapshot은 틱/호가/체결 파생값은 충분하지만, 1분봉 캔들 구조 데이터가 없어 신규 패턴 프롬프트 기준으로는 입력이 부족하다고 판단함.
|
||||||
|
- 2026-03-06: 신호 요청에 원본 사용자 prompt를 추가해, 전략 요약으로 축약되던 세부 규칙이 신호 생성 단계에도 직접 전달되도록 수정함.
|
||||||
|
- 2026-03-06: 최근 1분봉 OHLCV 24개와 minutePatternContext(직전 추세/압축 범위/압축 거래량비/박스 상하단)를 snapshot에 추가함.
|
||||||
|
- 2026-03-06: `npm run lint`, `npm run build` 통과. `nextjs_call(get_errors)` 기준 3001 개발 서버에서 브라우저 세션 오류 없음 확인. 브라우저 자동화 스모크는 로컬 Chrome 프로필 충돌로 미실행.
|
||||||
|
- 2026-03-06: BUY 신호인데 주문이 나가지 않는 원인을 추가 점검한 결과, `maxOrderAmountRatio`가 낮으면 전체 예산으로 1주를 살 수 있어도 주문 수량이 0주가 되는 문제가 확인됨. `lib/autotrade/risk.ts`에서 최소 1주 보정 로직을 추가하고 `npm run lint`, `npm run build` 재통과 확인.
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
[계획 문서 경로]
|
||||||
|
- common-docs/improvement/plans/dev-plan-2026-03-06-autotrade-real-execution-budget-tax.md
|
||||||
|
|
||||||
|
[요구사항 요약]
|
||||||
|
- 내 예산 기준으로 실제 몇 주를 살 수 있는지 계산하고, 자동매매 설정창에서 정한 비율대로 매수 수량이 정해지게 만든다.
|
||||||
|
- 매도는 현재 보유/매도가능 수량과 비교해서 가능한 수량만 나가게 한다.
|
||||||
|
- 수수료/세금/실현손익까지 고려해 진짜 자동매매처럼 동작하게 만든다.
|
||||||
|
|
||||||
|
[가정]
|
||||||
|
- 자동매매 설정창의 `allocationPercent`는 "이번 종목/이번 주문에 실제로 쓸 비율"로 사용한다.
|
||||||
|
- `allocationAmount`는 절대 상한(최대 투자금)으로 사용한다.
|
||||||
|
- 수수료/세금은 계좌/환경/정책에 따라 달라질 수 있으므로, 구현 시 하드코딩보다 `설정값 + KIS 실제 체결/매매일지 값`을 함께 쓴다.
|
||||||
|
- 국내주식 단주가 아닌 1주 단위 주문 기준으로 계획한다.
|
||||||
|
|
||||||
|
[영향 범위]
|
||||||
|
- 수정: features/autotrade/hooks/useAutotradeEngine.ts
|
||||||
|
- 수정: lib/autotrade/risk.ts
|
||||||
|
- 수정: features/autotrade/types/autotrade.types.ts
|
||||||
|
- 수정: features/autotrade/components/AutotradeControlPanel.tsx
|
||||||
|
- 수정: app/api/autotrade/signals/generate/route.ts
|
||||||
|
- 수정: lib/autotrade/openai.ts
|
||||||
|
- 수정: lib/autotrade/cli-provider.ts
|
||||||
|
- 수정: package.json
|
||||||
|
- 추가: lib/autotrade/execution-cost.ts
|
||||||
|
- 추가: lib/autotrade/executable-order-quantity.ts
|
||||||
|
- 추가: tests/autotrade/risk-budget.test.ts
|
||||||
|
- 추가: tests/autotrade/order-guard-cost.test.ts
|
||||||
|
- 추가: common-docs/improvement/plans/dev-plan-2026-03-06-autotrade-real-execution-budget-tax.md
|
||||||
|
- 삭제: 없음
|
||||||
|
|
||||||
|
[현재 코드 기준 핵심 문제]
|
||||||
|
- `allocationPercent`가 실주문 계산 기준이 아니라 참고 경고 수준으로만 쓰이고 있다.
|
||||||
|
- 쉬운 말: 설정창에서 10%, 25%를 바꿔도 실제 자동매매 수량 계산에는 약하게만 반영된다.
|
||||||
|
- 매수 수량은 `effectiveAllocationAmount`와 `maxOrderAmountRatio` 중심이라, 내 예산/비율/호가/예상 비용을 함께 계산하는 구조가 아니다.
|
||||||
|
- 매도는 `보유수량/매도가능수량` 차단은 있지만, 포지션 기준 목표 청산 비율, 부분 청산, 순손익 기준 청산 조건이 없다.
|
||||||
|
- 세금/수수료는 대시보드 조회/표시에는 일부 있지만, 자동매매의 진입/청산/손실 한도 계산에는 거의 반영되지 않는다.
|
||||||
|
- 일일 손실 한도는 입력 금액 기준이고, 실제 체결 후 순손익(수수료/세금 포함)과 연결되지 않는다.
|
||||||
|
|
||||||
|
[구현 단계]
|
||||||
|
- [x] 1. 주문 가능 예산 모델 재정의
|
||||||
|
- 입력: 가용 예수금, 매수가능금액, allocationPercent, allocationAmount, 전략별 maxOrderAmountRatio
|
||||||
|
- 처리: `실주문가능예산 = min(매수가능금액, allocationAmount 상한, 예수금 * allocationPercent)` 구조로 통일
|
||||||
|
- 결과: "현재 이 종목에 실제로 쓸 수 있는 예산" 1개 값으로 고정
|
||||||
|
- [x] 2. 매수 수량 계산 로직 교체
|
||||||
|
- 입력: 실주문가능예산, 현재가/주문가, 예상 수수료, 최소 안전여유금
|
||||||
|
- 처리: 비용 포함 기준으로 최대 주문 가능 수량 계산
|
||||||
|
- 결과: "내 예산 기준으로 지금 몇 주 살 수 있는지"를 로그와 UI에 함께 표시
|
||||||
|
- [x] 3. 매도 수량 계산 로직을 포지션 기준으로 확장
|
||||||
|
- 입력: 보유수량, 매도가능수량, 평균단가, 평가손익, AI 제안 수량/비율
|
||||||
|
- 처리: 없는 주식은 절대 매도 금지, 보유보다 큰 수량 금지, 부분 매도 허용
|
||||||
|
- 결과: "실제 보유 중인 수량 안에서만 매도" 보장
|
||||||
|
- [x] 4. 수수료/세금 추정 모듈 추가
|
||||||
|
- 입력: 주문금액, 매수/매도 구분, 계좌/환경 정책
|
||||||
|
- 처리: 주문 전 예상 비용 계산, 주문 후 실제 체결/매매일지로 정산값 보정
|
||||||
|
- 결과: 순손익 기준 판단 가능
|
||||||
|
- [x] 5. 자동매매 위험 관리 기준을 순손익 기준으로 보강
|
||||||
|
- 입력: 실현손익, 평가손익, 누적 수수료, 누적 세금
|
||||||
|
- 처리: 일일 손실선/청산 조건을 총손익이 아니라 순손익 기준으로 갱신
|
||||||
|
- 결과: 세금/수수료 때문에 실제 손실이 커지는 상황 반영
|
||||||
|
- [x] 6. AI 입력값도 포지션/비용 기준으로 보강
|
||||||
|
- 입력: holdingQuantity, sellableQuantity, averagePrice, estimatedFee, estimatedTax, netProfitEstimate
|
||||||
|
- 처리: AI가 매도 시 "팔 수 있는지/팔면 순손익이 어떤지"를 함께 보게 함
|
||||||
|
- 결과: 보유 없는 SELL, 손익 무시 SELL/BUY 감소
|
||||||
|
- [x] 7. UI/로그 보강
|
||||||
|
- 자동매매 설정창/로그에 아래 항목 노출
|
||||||
|
- 현재 주문 가능 예산
|
||||||
|
- 현재 매수 가능 수량
|
||||||
|
- 현재 보유 수량 / 매도 가능 수량
|
||||||
|
- 예상 수수료 / 예상 세금 / 예상 순손익
|
||||||
|
- [x] 8. 체결 후 실제값 동기화
|
||||||
|
- 주문 후 잔고/활동 API 재조회
|
||||||
|
- 체결 후 보유수량, sellableQuantity, realized fee/tax, realized profit을 스토어에 반영
|
||||||
|
- 다음 주문은 이 최신값을 기준으로 계산
|
||||||
|
|
||||||
|
[사용할 MCP/Skills]
|
||||||
|
- MCP: next-devtools, sequential-thinking, mcp:kis-code-assistant-mcp
|
||||||
|
- Skills: dev-plan-writer, dev-mcp-implementation, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker, vercel-react-best-practices
|
||||||
|
|
||||||
|
[참조 문서(common-docs)]
|
||||||
|
- common-docs/api-reference/kis_api_reference.md
|
||||||
|
- common-docs/api-reference/kis-error-code-reference.md
|
||||||
|
- common-docs/features/trade-stock-sync.md
|
||||||
|
- common-docs/ui/GLOBAL_ALERT_SYSTEM.md
|
||||||
|
|
||||||
|
[주석/문서 반영 계획]
|
||||||
|
- 함수 주석: [목적]/[사용처]/[데이터 흐름] 유지
|
||||||
|
- 수량 계산/비용 계산 함수에는 입력 -> 처리 -> 결과 주석 추가
|
||||||
|
- 자동매매 로그에는 "왜 주문됐는지/왜 차단됐는지" 숫자 기준 노출
|
||||||
|
|
||||||
|
[리스크/회귀 포인트]
|
||||||
|
- 계좌별 수수료 정책이 다르면 세금/수수료 추정이 실제와 다를 수 있다.
|
||||||
|
- 매수가능금액/잔고/매매일지 API 응답 타이밍이 어긋나면 체결 직후 수량이 잠깐 다르게 보일 수 있다.
|
||||||
|
- 모의투자는 실전과 세금/수수료/매매일지 지원 방식이 다를 수 있다.
|
||||||
|
- 주문 전 추정 비용과 주문 후 실제 비용이 다를 수 있으므로, 최종 손익 기준은 실제 체결/매매일지 값으로 재정산해야 한다.
|
||||||
|
|
||||||
|
[검증 계획]
|
||||||
|
- [x] 1. `allocationPercent`, `allocationAmount`, `매수가능금액` 조합별로 매수 수량이 기대값대로 계산되는지 단위 테스트 추가
|
||||||
|
- [x] 2. 보유 없음 / 보유 1주 / 매도가능수량 부족 상황에서 SELL이 차단되는지 테스트
|
||||||
|
- [x] 3. 수수료/세금 추정 로직과 실제 activity API 정산값 연결 테스트
|
||||||
|
- [x] 4. `npm run lint`
|
||||||
|
- [x] 5. `npm run build`
|
||||||
|
- [x] 6. 자동매매 스모크 시나리오
|
||||||
|
- 예산 30만원, 비율 10%, 주가 16,000원일 때 매수 가능 수량 계산 확인
|
||||||
|
- 보유 5주, 매도가능 3주일 때 SELL 수량 제한 확인
|
||||||
|
- 체결 후 잔고/활동 재조회로 보유/손익이 갱신되는지 확인
|
||||||
|
- Playwright 인증 필요 구간에서는 사용자(본인)가 로그인/앱키/계좌 인증을 완료할 때까지 테스트를 대기하고, 완료 신호를 받은 뒤 다음 단계를 진행
|
||||||
|
|
||||||
|
[진행 로그]
|
||||||
|
- 2026-03-06: 현재 자동매매 코드를 점검한 결과, 매도가능수량 비교는 일부 구현되어 있으나 `allocationPercent` 실주문 반영, 세금/수수료 반영, 순손익 기준 손실 관리, 체결 후 정산 반영은 미흡한 상태로 판단함.
|
||||||
|
- 2026-03-06: 구현 방향을 `예산 계산 -> 주문 수량 계산 -> 보유/매도가능 수량 검증 -> 비용 추정 -> 체결 후 실제 정산` 순서로 재설계하기로 함.
|
||||||
|
- 2026-03-06: `lib/autotrade/risk.ts`에서 `allocationPercent`를 실주문 예산 계산에 강제 반영하도록 변경하고, BUY/SELL 수량 계산 경로를 분리함.
|
||||||
|
- 2026-03-06: `useAutotradeEngine.ts`에 비용 추정(수수료/세금), 체결 전후 활동/잔고 재조회, 누적 손실 한도 자동중지 로직을 반영함.
|
||||||
|
- 2026-03-06: AI 신호 스냅샷에 `budgetContext`, `portfolioContext`, `executionCostProfile`을 추가하고 OpenAI/CLI 프롬프트 규칙에 예산/보유/비용 제약을 반영함.
|
||||||
|
- 2026-03-06: 검증 결과 `npm run lint`, `npm run build` 통과. `npm run test:autotrade:smoke`는 로그인 필요(개발 우회 토큰 미적용 환경)로 실패함.
|
||||||
|
- 2026-03-06: Playwright 스모크로 `/`, `/trade`(로그인 리다이렉트 확인), `/settings`(로그인 리다이렉트 확인) 화면 로드 및 콘솔 error 없음 확인.
|
||||||
|
- 2026-03-06: Playwright 테스트 협업 규칙 추가 - 로그인/앱키/계좌 인증은 사용자가 직접 완료하고, 완료 전에는 테스트를 대기하도록 문서에 명시함.
|
||||||
|
- 2026-03-06: `lib/autotrade/executable-order-quantity.ts` 순수 clamp 유틸을 추가하고, `useAutotradeEngine.ts`의 실제 주문수량 검증에 연결함.
|
||||||
|
- 2026-03-06: 단위 테스트 추가(`tests/autotrade/risk-budget.test.ts`, `tests/autotrade/order-guard-cost.test.ts`) 후 `npm run test:autotrade:unit` 통과.
|
||||||
|
- 2026-03-06: `.env.local`의 실제 `AUTOTRADE_DEV_BYPASS_TOKEN`, `AUTOTRADE_WORKER_TOKEN`으로 스모크 재실행하여 `npm run test:autotrade:smoke` 통과.
|
||||||
|
- 2026-03-06: Playwriter 실브라우저 디버깅으로 `/trade` 화면에서 `내 설정 점검 -> 자동매매 시작 -> 수동 중지` 흐름 확인(세션 시작/중지 로그 정상, 브라우저 콘솔 error 없음). 장중 실시간 틱 부재로 신호요청/주문실행 로그는 미발생.
|
||||||
|
- 2026-03-06: AI 스냅샷의 `estimatedBuyableQuantity` 계산을 실제 주문 함수(`resolveOrderQuantity`)와 동일하게 통일해, 비율 예산으로 0주가 나와도 전체 예산 1주 가능 시 `1주`가 전달되도록 핫픽스함.
|
||||||
|
- 2026-03-06: Playwriter 네트워크 검증으로 `/api/autotrade/signals/generate` 요청 본문에 `estimatedBuyableQuantity=1`, `effectiveAllocationAmount=21631`, `effectiveOrderBudgetAmount=7570`, `currentPrice=16790`이 전달되는 것을 확인함(수량 0 전달 이슈 해소).
|
||||||
|
- 2026-03-06: 검증 자금 산정 로직을 `예수금 + 매수가능금액` 동시 조회 기반으로 변경하고, 두 값이 모두 있을 때는 더 보수적인 값(min)을 사용하도록 반영함.
|
||||||
|
- 2026-03-06: 자동매매 설정창의 투자비율 입력 UX를 퍼센트 프리셋 버튼 + 슬라이더 + 금액 자동입력 버튼으로 개선하고, 안전 점검 라벨을 `가용 예수금`에서 `주문 기준 자금`으로 변경함.
|
||||||
|
- 2026-03-06: `setNumberField`를 필드별 범위(clamp) 보정 방식으로 바꿔 퍼센트/신뢰도 입력이 비정상 값(음수, 100% 초과, 임계값 범위 이탈)으로 저장되지 않도록 정리함.
|
||||||
|
- 2026-03-06: 회귀 검증으로 `npm run test:autotrade:unit`, `npm run lint`, `npm run build` 재실행 모두 통과함.
|
||||||
146
common-docs/ui/GLOBAL_ALERT_SYSTEM.md
Normal file
146
common-docs/ui/GLOBAL_ALERT_SYSTEM.md
Normal 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")
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -1,16 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* @file components/form-message.tsx
|
||||||
|
* @description 폼 제출 결과(성공/에러) 메시지를 표시하는 컴포넌트
|
||||||
|
* @remarks
|
||||||
|
* - [레이어] Components/UI/Feedback
|
||||||
|
* - [기능] URL 쿼리 파라미터(`message`)를 감지하여 표시 후 URL 정리
|
||||||
|
* - [UX] 메시지 확인 후 새로고침 시 메시지가 남지 않도록 히스토리 정리 (History API)
|
||||||
|
*/
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [FormMessage 컴포넌트]
|
* 폼 메시지 컴포넌트
|
||||||
* - 로그인/회원가입 실패 메시지를 보여줍니다.
|
* @param message 표시할 메시지 텍스트
|
||||||
* - [UX 개선] 메시지가 보인 후, URL에서 ?message=... 부분을 지워서
|
* @returns 메시지 박스 또는 null
|
||||||
* 새로고침 시 메시지가 다시 뜨지 않도록 합니다.
|
|
||||||
*/
|
*/
|
||||||
export default function FormMessage({ message }: { message: string }) {
|
export default function FormMessage({ message }: { message: string }) {
|
||||||
const router = useRouter();
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
|||||||
25
components/theme-provider.tsx
Normal file
25
components/theme-provider.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* @file components/theme-provider.tsx
|
||||||
|
* @description next-themes 라이브러리를 사용한 테마 제공자 (Wrapper)
|
||||||
|
* @remarks
|
||||||
|
* - [레이어] Infrastructure/Provider
|
||||||
|
* - [역할] 앱 전역에 테마 컨텍스트 주입 (Light/Dark 모드 지원)
|
||||||
|
* - [연관 파일] layout.tsx (사용처)
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ThemeProvider 컴포넌트
|
||||||
|
* @param props next-themes Provider props
|
||||||
|
* @returns NextThemesProvider 래퍼
|
||||||
|
*/
|
||||||
|
export function ThemeProvider({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
}
|
||||||
64
components/theme-toggle.tsx
Normal file
64
components/theme-toggle.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* @file components/theme-toggle.tsx
|
||||||
|
* @description 라이트/다크 테마 즉시 전환 토글 버튼
|
||||||
|
* @remarks
|
||||||
|
* - [레이어] Components/UI
|
||||||
|
* - [사용자 행동] 버튼 클릭 -> 라이트/다크 즉시 전환
|
||||||
|
* - [연관 파일] theme-provider.tsx (next-themes)
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface ThemeToggleProps {
|
||||||
|
className?: string;
|
||||||
|
iconClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테마 토글 컴포넌트
|
||||||
|
* @remarks next-themes의 useTheme 훅 사용
|
||||||
|
* @returns 단일 클릭으로 라이트/다크를 전환하는 버튼
|
||||||
|
* @see features/layout/components/header.tsx Header 액션 영역 - 사용자 테마 전환 버튼
|
||||||
|
*/
|
||||||
|
export function ThemeToggle({ className, iconClassName }: ThemeToggleProps) {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
const handleToggleTheme = React.useCallback(() => {
|
||||||
|
// 시스템 테마 사용 중에도 현재 화면 기준으로 명확히 라이트/다크를 토글합니다.
|
||||||
|
setTheme(resolvedTheme === "dark" ? "light" : "dark");
|
||||||
|
}, [resolvedTheme, setTheme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={className}
|
||||||
|
onClick={handleToggleTheme}
|
||||||
|
aria-label="테마 전환"
|
||||||
|
>
|
||||||
|
{/* ========== 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>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
components/ui/alert-dialog.tsx
Normal file
150
components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* @file components/ui/alert-dialog.tsx
|
||||||
|
* @description 알림 대화상자 (Alert Dialog) 컴포넌트 (Shadcn/ui)
|
||||||
|
* @remarks
|
||||||
|
* - [레이어] Components/UI/Primitive
|
||||||
|
* - [기능] 중요한 작업 확인 컨텍스트 제공 (로그아웃 경고 등)
|
||||||
|
* @see session-manager.tsx - 로그아웃 경고에 사용
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root;
|
||||||
|
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||||
|
|
||||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 sm:rounded-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
));
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
AlertDialogHeader.displayName = "AlertDialogHeader";
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
AlertDialogFooter.displayName = "AlertDialogFooter";
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogDescription.displayName =
|
||||||
|
AlertDialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"mt-2 sm:mt-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
};
|
||||||
152
components/ui/animated-brand-tone.tsx
Normal file
152
components/ui/animated-brand-tone.tsx
Normal 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"
|
||||||
|
>
|
||||||
|
“{TONE_PHRASES[index].q}”
|
||||||
|
</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";
|
||||||
|
}
|
||||||
109
components/ui/avatar.tsx
Normal file
109
components/ui/avatar.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||||
|
size?: "default" | "sm" | "lg"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn("aspect-square size-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="avatar-badge"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full ring-2 select-none",
|
||||||
|
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||||
|
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||||
|
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group"
|
||||||
|
className={cn(
|
||||||
|
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroupCount({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group-count"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Avatar,
|
||||||
|
AvatarImage,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarBadge,
|
||||||
|
AvatarGroup,
|
||||||
|
AvatarGroupCount,
|
||||||
|
}
|
||||||
48
components/ui/badge.tsx
Normal file
48
components/ui/badge.tsx
Normal 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 }
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user