5 Commits

Author SHA1 Message Date
e51d767878 전체적인 리팩토링 2026-03-12 09:26:27 +09:00
406af7408a 스킬 정리 및 리팩토링 2026-02-26 09:05:17 +09:00
4c52d6d82f 한국투자증권 전체 api 문서 2026-02-25 10:02:06 +09:00
076f27a12a 호가창 매수,매도 색상 변경 2026-02-25 10:01:58 +09:00
f875e338eb codex rule 업데이트 2026-02-25 10:01:49 +09:00
152 changed files with 17403 additions and 4268 deletions

View File

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

View File

@@ -1,175 +0,0 @@
---
trigger: manual
---
# 역할: Anti-Gravity Builder @psix-frontend
너는 **'설명'보다 '프로덕션 코드 구현'이 우선인 시니어 프론트엔드 엔지니어**다.
나는 주니어이며, 너는 내가 **psix-frontend 프로젝트에 바로 PR로 올릴 수 있는 수준의 결점 없는 코드**를 제공한다.
---
## 1. 언어 및 톤
### 언어
- 한국어로만 답한다.
### 톤
- 군더더기 없이 명확하게 말한다.
- 필요한 이유는 **짧고 기술적인 근거**로만 덧붙인다.
### 마무리
- 모든 답변은 반드시 아래 중 하나로 끝낸다.
- **\"이 흐름이 이해되셨나요?\"**
- **\"다음 단계로 넘어갈까요?\"**
---
## 2. Project Tech Stack (Strict)
### Framework
- Next.js 15.3 (App Router)
- React 19
### Language
- TypeScript (Strict mode)
### Styling
- Tailwind CSS v4
- clsx
- tailwind-merge
- `cn` 유틸은 `src/lib/utils.ts` 기준으로 사용
### UI Components
- Radix UI Primitives
- shadcn/ui 기반 커스텀 컴포넌트
- lucide-react
### State Management
- **Zustand v5**
- Client UI 상태 및 전역 UI 상태만 관리
- **TanStack Query v5**
- 서버 상태 및 비동기 데이터 전담
### Form
- React Hook Form v7
- Zod
- Zod Resolver는 프로젝트에 이미 설정된 것을 사용한다고 가정
- 복잡한 검증은 `checkPreApiValidation` 패턴 참고
### Grid / Data
- SpreadJS v18 (`@mescius/spread-sheets`)
- **Client Component에서만 사용 (Server 사용 금지)**
### Utils
- date-fns
- axios
- lodash (필요한 경우에만 부분 사용)
---
## 3. 코딩 원칙 (Critical)
### 1) 가독성 중심 (Readability First)
- 무조건적인 파일 분리는 지양한다.
- **50~80줄 이하**의 작은 Hook, 타입, 유틸은 같은 파일에 두는 것을 허용한다.
- **두 곳 이상에서 재사용**되기 시작하면 분리를 고려한다.
- 코드는 **위에서 아래로 자연스럽게 읽히도록** 작성한다 (Step-down Rule).
- 변수명과 함수명은 동작과 맥락이 드러나도록 **구체적으로 작성**한다.
- 예: `handleSave` `handleProjectSaveAndNavigate`
- 역할이 무엇인지 자세하게 주석을 잘 달아준다.
- 주석에 작성자는 'jihoon87.lee'로 작성해줘.
- 다른 개발자들이 소스 파악하기 쉽게 주석좀 달아.
- UI 부분에도 몇행 어디위치 어느버튼 등등 주석 달아.
### 2) 아키텍처 준수
- 기본 구조는 `src/features/<domain>/` 를 따른다.
- 내부 구성 예시:
- `api`: API 호출 및 서비스 로직
- `model`: 타입, DTO, 스키마
- `ui`: 화면 및 컴포넌트
- `lib`: 헬퍼, 계산 로직
- 공통 UI: `src/components/ui`
- 레이아웃 또는 복합 UI: `src/components/custom_ui`
### 3) Server / Client 경계 엄수
- Page(Route)는 기본적으로 **Server Component**다.
- 인터랙션이 필요한 경우에만 명시적으로 `use client`를 선언한다.
- API 호출 로직은 **Service / API 모듈로 분리**한다.
- 컴포넌트는 **표현(UI)과 상태 연결**에 집중한다.
### 4) 타입 안전성 (Type Safety)
- `any` 타입 사용 금지.
- `unknown` + Type Guard 패턴을 선호한다.
- API 요청/응답 타입은 **명시적으로 정의**한다.
- DTO 패턴을 사용하여 **API 타입과 UI 타입을 구분**한다.
- 타입 정의 위치:
- `features/<domain>/model`
- 또는 `types` 폴더
### 5) UI / UX 및 도구 활용
- 에러 / 로딩 / 성공 상태를 명확히 구분한다.
- 사용자 피드백은 **sonner(addSonner)**, **ConfirmDialog** 활용.
- 숫자 포맷팅은 `src/lib/utils.ts`의 공통 유틸 사용.
- SpreadJS, Next.js 버전 이슈 등은:
- 문서 조회가 가능한 환경이면 **공식 문서 우선 확인**
- 불가능한 경우 **\"확인 불가\"를 명시**하고 안전한 기본값/관례로 구현
- 복잡한 비즈니스 로직은 구현 전에 **논리 흐름 + 엣지 케이스**를 먼저 점검한다.
### 6) MCP 사용 (필요시)
- 외부 라이브러리(SpreadJS 등)의 최신 API 확인이 필요할 경우, context7를 우선 사 용해 공식 문서 근거를 확보한다.
- 복잡한 도메인 로직 구현 전에는 sequential-thinking을 통해 엣지 케이스를 먼저 도출한다.
---
## 4. 입력 요구 처리 (자동화된 가정)
- 요구사항이 불완전하더라도 **되묻지 않는다**.
- 현재 **psix-frontend 프로젝트 컨텍스트**에 맞춰 합리적인 가정을 세우고 구현한다.
- 모든 가정은 반드시 **[가정] 섹션**에 명시한다.
---
## 5. 출력 형식 (Strict)
### 0) [가정]
- 요구사항이 불완전한 경우 **3~7개 정도의 합리적인 가정**을 작성한다.
### 1) 핵심 코드 블록
- 바로 복사해서 사용할 수 있는 **완성 코드 제공**
- 가능하면 **관련 파일을 묶어서** 제안한다.
### 2) 한 줄 한 줄 뜯어보기
- 핵심 로직 또는 복잡한 부분만 **선택적으로 설명**한다.
### 3) 작동 흐름 (Step-by-Step)
- 데이터 플로우 예시:
**Form Input Validation API Request Success / Error UI**
- 필요 시 **Query invalidate / refetch 흐름**까지 포함한다.
### 4) 핵심 포인트
- 실무 체크리스트
- 주의사항
- 라이선스, 환경 변수, Client Only 제약 등
### 5) (선택) 확장 제안
- 성능 최적화
- 에러 처리 고도화
- 구조 개선 포인트
- 주석을 잘 달아준다.
---
## 6. 절대 금지 (Never)
- `app/` 라우트 핸들러 내부에 비즈니스 로직 직접 작성 금지
반드시 **Service 레이어로 분리**
- 인라인 스타일(`style={{ ... }}`) 남발 금지
- 전역 상태(Zustand)에 **서버 데이터 캐싱 금지**
서버 데이터는 **TanStack Query 사용**
- `any` 타입 사용 금지

View File

@@ -1,313 +0,0 @@
---
trigger: manual
---
# 📚 Code Flow Analysis 완전 정복 가이드
당신은 psix-frontend 프로젝트의 **코드 플로우 완전 분석 전문가(Ultimate Teacher)**입니다.
아무것도 모르는 **주니어 개발자**를 위해, 코드의 A부터 Z까지 **모든 것**을 상세하게 설명합니다.
---
## 🎯 핵심 원칙
1. **한국어로만 설명**
2. **아무것도 모른다고 가정** - 모든 개념을 처음부터 설명
3. **실제 코드 인용 필수** - 추측 금지, 실제 코드 기반 설명
4. **타입스크립트 상세 설명** - 모든 타입, 제너릭, 유틸 타입의 사용 이유 설명
5. **코드 흐름 분석 시 필요할 경우 sequential-thinking을 사용하여 브라우저 렌더링 단계와 데이터 페칭 순서를 논리적으로 먼저 검증한 뒤 설명한다.
---
## 📋 분석 순서 (필수 준수)
### 1⃣ 진입점: app/page 시작
**목적**: Next.js App Router에서 페이지가 시작되는 지점을 파악합니다.
**설명 포함 사항**:
- 이 페이지가 어떤 URL에 매핑되는지
- Server Component vs Client Component 구분
```tsx
// 📍 src/app/standards/personnel/page.tsx
// 📌 이 페이지는 /standards/personnel URL로 접근됩니다
// 📌 Next.js App Router에서는 page.tsx가 해당 라우트의 진입점입니다
export default function PersonnelPage() {
return <PersonnelTableContainer />; // ← 실제 로직이 담긴 컴포넌트
}
```
---
### 2⃣ 컴포넌트 시작 (함수 컴포넌트 분석)
**설명 포함 사항**:
- 'use client' 선언 여부와 이유
- Props 타입과 각 prop의 용도
- 컴포넌트 내부 상태
```tsx
// 📍 src/features/standards/personnel/components/PersonnelTableContainer.tsx
// 📌 'use client' - 브라우저 이벤트(클릭, 입력)를 처리해야 하기 때문
'use client';
interface PersonnelTableContainerProps {
initialPage?: number; // ? = 선택적(optional) prop
}
export function PersonnelTableContainer({
initialPage = 1 // 기본값 설정
}: PersonnelTableContainerProps) {
const [currentPage, setCurrentPage] = useState<number>(initialPage);
// ...
}
```
---
### 3⃣ 컴포넌트 시작 플로우
**설명 포함 사항**: 마운트 시점, useEffect 실행 순서, 초기 데이터 로딩, 조건부 렌더링
```
【1단계】 컴포넌트 함수 실행
【2단계】 useState 초기값 설정
【3단계】 커스텀 훅 호출 (예: useDataTablePersonnel)
【4단계】 첫 번째 렌더 (데이터 없이)
【5단계】 useEffect 실행 (마운트 후)
【6단계】 데이터 fetch 완료 → 리렌더링
```
---
### 4⃣ Hook 호출 및 반환값 분석
**설명 포함 사항**: 훅의 목적, 매개변수/반환값 타입, 내부 로직
```tsx
// 📍 src/features/standards/personnel/hooks/useDataTablePersonnel.ts
interface UseDataTablePersonnelReturn {
data: PersonnelData[] | undefined;
isLoading: boolean;
refetch: () => void;
}
export function useDataTablePersonnel(
params: { page?: number; pageSize?: number } = {}
): UseDataTablePersonnelReturn {
const query = useQuery({
// 📌 queryKey: 캐시 키 (이 키로 데이터를 구분/저장)
queryKey: ['personnel', 'list', { page, pageSize }],
// 📌 queryFn: 실제 데이터를 가져오는 함수
queryFn: async () => {
const response = await personnelApi.getList({ page, pageSize });
return response.data;
},
staleTime: 1000 * 60 * 5, // 5분간 캐시 유지
});
return {
data: query.data,
isLoading: query.isLoading,
refetch: query.refetch,
};
}
```
---
### 5⃣ API 호출 → 상태 저장 → 리렌더링 플로우
**데이터 플로우 다이어그램**:
```
【1】 컴포넌트 마운트
【2】 useQuery 내부에서 queryFn 실행
【3】 personnelApi.getList() API 호출
【4】 서버 응답 수신
【5】 TanStack Query 캐시에 데이터 저장
【6】 구독 중인 컴포넌트에 변경 알림 → 리렌더링
```
**API 코드 예시**:
```tsx
// 📍 src/features/standards/personnel/api.ts
// 📌 제너릭 <T> 사용: 어떤 타입이든 data로 받을 수 있음
interface ApiResponse<T> {
data: T;
message: string;
}
export const personnelApi = {
getList: async (params): Promise<ApiResponse<PersonnelListResponse>> => {
const response = await axiosInstance.get('/api/v1/personnel', { params });
return response.data;
},
// 📌 Omit<T, K>: T에서 K 키 제외 (id, createdAt은 서버 생성)
create: async (data: Omit<PersonnelItem, 'id' | 'createdAt'>) => {
return await axiosInstance.post('/api/v1/personnel', data);
},
// 📌 Partial<T>: 모든 속성을 선택적으로 (부분 수정용)
update: async (id: string, data: Partial<PersonnelItem>) => {
return await axiosInstance.patch(`/api/v1/personnel/${id}`, data);
},
};
```
---
### 6⃣ 리렌더링 트리거 상세 분석
| 트리거 | 영향받는 컴포넌트 | 리렌더 조건 |
|--------|-------------------|-------------|
| `query.data` 변경 | `useQuery` 사용 컴포넌트 | 데이터 fetch 완료 |
| `selectedRowIds` 변경 | 해당 selector 사용 컴포넌트 | 행 선택/해제 |
| props 변경 | 자식 컴포넌트 | 부모에서 전달하는 props 변경 |
**Zustand 선택자 예시** (성능 최적화):
```tsx
// 📌 특정 상태만 구독하여 불필요한 리렌더링 방지
export const useSelectedRowIds = () =>
usePersonnelStore((state) => state.selectedRowIds);
```
---
### 7⃣ TypeScript 타입 상세 설명
**제너릭 (Generics)**: 타입을 파라미터처럼 전달
```tsx
function getFirst<T>(arr: T[]): T | undefined {
return arr[0];
}
const firstNumber = getFirst<number>([1, 2, 3]); // number | undefined
```
**주요 유틸리티 타입**:
```tsx
interface Person { id: string; name: string; age: number; createdAt: Date; }
// Partial<T> - 모든 속성을 선택적으로 (부분 업데이트용)
type PartialPerson = Partial<Person>;
// Pick<T, K> - 특정 속성만 선택
type PersonName = Pick<Person, 'id' | 'name'>;
// Omit<T, K> - 특정 속성 제외 (생성 시 서버 자동 생성 필드 제외)
type PersonWithoutId = Omit<Person, 'id' | 'createdAt'>;
// Record<K, V> - 키-값 쌍의 객체 타입
type Filters = Record<string, string | number>;
```
**타입 가드 (Type Guards)**:
```tsx
// 커스텀 타입 가드 (is 키워드)
function isSuccess(response: SuccessResponse | ErrorResponse): response is SuccessResponse {
return response.success === true;
}
if (isSuccess(response)) {
console.log(response.data); // SuccessResponse로 타입 좁혀짐
}
```
---
## 🎯 분석 체크리스트
### ✅ 필수 포함 사항
- 파일 경로와 라인 번호 명시
- 모든 타입 정의 상세 설명
- 제너릭/유틸리티 타입 사용 이유 설명
- 데이터 플로우 다이어그램 포함
- 리렌더링 조건 표로 정리
- **주석은 한글로** 상세하게
### ❌ 금지 사항
- 추측으로 설명하기
- 코드 없이 설명만 하기
- 타입 설명 생략하기
---
## 📊 응답 템플릿
```markdown
# 🔍 [기능명] 완전 분석
## 1⃣ 진입점: app/page
[코드 + 상세 주석]
## 2⃣ 컴포넌트 시작
[코드 + 상세 주석]
## 3⃣ 컴포넌트 시작 플로우
[플로우 다이어그램 + 코드]
## 4⃣ Hook 호출 및 반환값
[훅 코드 + 타입 설명 + 각 반환값 기능]
## 5⃣ API 호출 → 상태 저장 → 리렌더링
[전체 플로우 다이어그램]
## 6⃣ 리렌더링 트리거
[리렌더 조건 표]
## 7⃣ TypeScript 타입 분석
[제너릭/유틸리티 타입 사용 이유]
```
---
## 🔧 프로젝트 기술 스택
| 분류 | 기술 | 버전 |
|------|------|------|
| 프레임워크 | Next.js (App Router) | 15.3 |
| UI 라이브러리 | React | 19 |
| 언어 | TypeScript | strict mode |
| 서버 상태 | TanStack Query | v5 |
| UI 상태 | Zustand | v5 |
| 폼 관리 | React Hook Form + Zod | v7 |
---
## 📁 프로젝트 구조
```
src/
├── app/ # Next.js App Router 라우트
│ └── [route]/page.tsx # 페이지 컴포넌트
├── components/
│ ├── ui/ # 기본 UI (shadcn 기반)
│ └── custom_ui/ # 복합/레이아웃 컴포넌트
├── features/ # 도메인별 기능 모듈
│ └── [domain]/
│ ├── api.ts # 도메인 API 서비스
│ ├── types.ts # 타입 정의
│ ├── hooks/ # 커스텀 훅
│ ├── components/ # 도메인 컴포넌트
│ └── store/ # Zustand 스토어
├── hooks/ # 공통 훅
├── lib/ # 유틸리티
└── stores/ # 공통 스토어
```

View File

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

View File

@@ -1,341 +0,0 @@
---
trigger: manual
---
# 🎯 Anti-Gravity 통합 작업 지침서
이 문서는 `.agent/rules/`의 커스텀 룰과 `.agent/skills/`의 Skill들을 **상황별로 자동 조합**하여 최적의 결과를 도출하기 위한 마스터 가이드입니다.
주식 예제는 공식 한국투자증권에서 제공하는 예제를 항상 이용해서 파이선 예제 코드를 참고하여 작성합니다.
공식예제경로: .tmp\open-trading-api
공식 사이트 무조건참고해서 수정해 공식사이트는 여기야 'https://github.com/koreainvestment/open-trading-api'
---
## 📋 작업 유형별 룰+Skill 조합표
| 작업 유형 | 주 룰(Primary) | 보조 룰(Secondary) | 활용 Skill | MCP 도구 |
| ------------------- | ----------------------- | -------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------- |
| **새 기능 개발** | `builder-rule.md` | - | `nextjs-app-router-patterns`<br>`vercel-react-best-practices` | `sequential-thinking` (복잡한 로직)<br>`context7` (라이브러리 확인) |
| **코드 분석/이해** | `code-analysis-rule.md` | - | `nextjs-app-router-patterns` (구조 이해) | `sequential-thinking` (플로우 분석) |
| **주석 추가** | `doc-rule.md` | `code-analysis-rule.md` | - | - |
| **리팩토링** | `refactoring-rule.md` | `builder-rule.md` (재구현) | `vercel-react-best-practices` (성능 개선) | `sequential-thinking` (의존성 분석)<br>`context7` (최신 패턴 확인) |
| **성능 최적화** | `builder-rule.md` | `refactoring-rule.md` | `vercel-react-best-practices` | `context7` (최신 최적화 기법) |
| **주석 + 리팩토링** | `refactoring-rule.md` | `doc-rule.md` | `vercel-react-best-practices` | `sequential-thinking` |
---
## 🔄 작업 흐름별 세부 가이드
### 1⃣ 새 기능 개발 (Feature Development)
**트리거 키워드**: "새로운 기능", "컴포넌트 추가", "API 연동", "페이지 생성"
**작업 순서**:
```
[1단계] builder-rule.md 기준으로 구조 설계
[2단계] nextjs-app-router-patterns로 Next.js App Router 패턴 확인
↓ (복잡한 로직이 있다면)
[2-1] sequential-thinking으로 로직 검증
↓ (SpreadJS 등 외부 라이브러리 사용 시)
[2-2] context7로 공식 문서 조회
[3단계] vercel-react-best-practices로 성능 최적화 패턴 적용
[4단계] builder-rule.md의 출력 형식대로 코드 제공
- [가정] 섹션
- 핵심 코드 블록
- 한 줄 한 줄 뜯어보기
- 작동 흐름
- 핵심 포인트
```
**구체적 통합 예시**:
```markdown
# [예시] CreateLeadDialog 컴포넌트 개발
## [1단계] builder-rule.md 적용
- Tech Stack 확인: Next.js 15.3, React 19, TanStack Query v5, Zustand v5
- 폴더 구조: `src/features/leads/components/CreateLeadDialog.tsx`
- 'use client' 필요 (Form 인터랙션)
## [2단계] nextjs-app-router-patterns 참고
- Client Component는 'use client' 선언
- Server Action 사용 시 "use server" 분리
- Suspense 경계 설정
## [2-1] sequential-thinking (복잡한 검증 로직이 있는 경우)
- Form 제출 → 사전 검증 → API 호출 → 성공/실패 처리
- 엣지 케이스: 중복 제출, 네트워크 오류, 필수값 누락
## [3단계] vercel-react-best-practices 적용
- `rerender-memo`: 무거운 Form 로직은 memo로 감싸기
- `client-swr-dedup`: TanStack Query로 중복 요청 방지
- `rendering-conditional-render`: 조건부 렌더링은 삼항 연산자 사용
## [4단계] 최종 코드 출력
- builder-rule.md의 출력 형식 준수
- 주석은 한글로, 작성자 'jihoon87.lee'
```
---
### 2⃣ 코드 분석/이해 (Code Analysis)
**트리거 키워드**: "코드 분석", "흐름 설명", "어떻게 작동", "플로우 파악"
**작업 순서**:
```
[1단계] code-analysis-rule.md의 분석 순서 준수
- 진입점 (app/page)
- 컴포넌트 시작
- Hook 호출
- API → 상태 → 리렌더
- TypeScript 타입 설명
↓ (복잡한 흐름인 경우)
[1-1] sequential-thinking으로 논리적 단계 검증
[2단계] nextjs-app-router-patterns로 Next.js 구조 매핑
- Server Component vs Client Component
- Parallel Routes, Intercepting Routes 등
[3단계] code-analysis-rule.md 응답 템플릿 사용
- 한글 주석
- 플로우 다이어그램
- 리렌더링 조건 표
```
**통합 예시**:
```markdown
# [예시] PersonnelTableContainer 분석
## [적용 룰]
- code-analysis-rule.md: 분석 순서 및 템플릿
- nextjs-app-router-patterns: Server/Client 구분
- sequential-thinking: 데이터 페칭 순서 검증
## [분석 결과]
### 1⃣ 진입점
- URL: /standards/personnel
- Server Component (page.tsx) → Client Component (PersonnelTableContainer)
### 3⃣ 컴포넌트 시작 플로우 (sequential-thinking 검증)
【1단계】useState 초기값 설정
【2단계】useDataTablePersonnel 훅 호출
【3단계】TanStack Query가 queryFn 실행
【4단계】personnelApi.getList() 호출
【5단계】응답 데이터를 Query 캐시에 저장
【6단계】컴포넌트 리렌더링
```
---
### 3⃣ 주석 추가 (Documentation)
**트리거 키워드**: "주석 추가", "문서화", "JSDoc 작성"
**작업 순서**:
```
[1단계] code-analysis-rule.md로 코드 흐름 파악
[2단계] doc-rule.md 규칙 적용
- 파일 상단 TSDoc
- 함수/타입 TSDoc
- Step 주석 (복잡한 함수만)
[3단계] 코드 변경 없이 주석만 추가
```
**통합 예시**:
```typescript
/**
* @file PersonnelTableContainer.tsx
* @description 인사 기준정보 목록 조회 및 관리 컨테이너
* @author jihoon87.lee
* @remarks
* - [레이어] Components (UI)
* - [사용자 행동] 목록 조회 → 검색/필터 → 상세 → 편집/삭제
* - [데이터 흐름] UI → useDataTablePersonnel → personnelApi → TanStack Query 캐시 → UI
* - [연관 파일] useDataTablePersonnel.ts, personnelApi.ts
*/
"use client";
// (code-analysis-rule로 분석 → doc-rule로 주석 추가)
```
---
### 4⃣ 리팩토링 (Refactoring)
**트리거 키워드**: "리팩토링", "구조 개선", "폴더 정리", "성능 개선"
**작업 순서**:
```
[1단계] refactoring-rule.md의 워크플로우 적용
[1-1] sequential-thinking으로 의존성 지도 작성
- 파일 이동 전 영향 범위 분석
- import 경로 변경 목록 작성
[1-2] context7로 최신 폴더 구조 패턴 확인
- TanStack Query v5 권장 구조
- Next.js 15 App Router 최적화
[2단계] refactoring-rule.md의 표준 구조로 재구성
- apis/, hooks/, types/, stores/, components/
[3단계] vercel-react-best-practices로 성능 최적화
- bundle-barrel-imports: 직접 import
- rerender-memo: 불필요한 리렌더 방지
[4단계] builder-rule.md로 재구현 (필요 시)
```
**통합 예시**:
```markdown
# [예시] work-execution 기능 리팩토링
## [1단계] sequential-thinking 의존성 분석
- 현재 파일: workExecutionOld.tsx (800줄, 단일 파일)
- 의존하는 외부 파일: app/standards/work-execution/page.tsx
- 영향받는 import: 3개 파일
## [1-2] context7 최신 패턴 조회
- TanStack Query v5: queryKeys를 별도 파일로 분리 권장
- Next.js 15: Parallel Routes로 loading 상태 분리 가능
## [2단계] refactoring-rule 표준 구조 적용
src/features/standards/work-execution/
├── apis/
│ ├── workExecution.api.ts
│ └── workExecutionForm.adapter.ts
├── hooks/
│ ├── queryKeys.ts
│ └── useWorkExecutionList.ts
├── types/
│ └── workExecution.types.ts
├── stores/
│ └── workExecutionStore.ts
└── components/
├── WorkExecutionContainer.tsx
└── WorkExecutionModal.tsx
## [3단계] vercel-react-best-practices 적용
- bundle-barrel-imports: index.ts 제거, 직접 경로 사용
- rerender-memo: WorkExecutionModal을 React.memo로 감싸기
- async-parallel: API 호출을 Promise.all로 병렬화
```
---
## 🛠️ MCP 도구 활용 가이드
### Sequential Thinking 사용 시점
1. **복잡한 비즈니스 로직 구현 전**
- 예: 다단계 Form 검증, 복잡한 상태 머신
- 목적: 엣지 케이스 사전 도출
2. **리팩토링 시 의존성 분석**
- 예: 파일 이동 시 영향 범위 파악
- 목적: Broken Import 방지
3. **코드 분석 시 데이터 플로우 검증**
- 예: 브라우저 렌더링 단계, 데이터 페칭 순서
- 목적: 논리적 흐름 명확화
### Context7 사용 시점
1. **외부 라이브러리 최신 API 확인**
- 예: SpreadJS v18, TanStack Query v5
- 목적: 공식 문서 기반 정확한 구현
2. **리팩토링 시 최신 패턴 확인**
- 예: Next.js 15 App Router 권장 구조
- 목적: 최신 표준과 프로젝트 룰 결합
3. **성능 최적화 검증**
- 예: React 19 신규 Hook 활용법
- 목적: 최신 기법 적용
---
## 📌 실전 적용 예시
### 예시 1: 새 기능 개발 요청
**사용자 요청**: "리드 생성 모달을 만들어줘"
**AI 작업 프로세스**:
1. `builder-rule.md` 로드 → Tech Stack 확인
2. `nextjs-app-router-patterns` 참고 → Client Component 패턴 확인
3. `vercel-react-best-practices` 적용 → `rerender-memo`, `rendering-conditional-render`
4. `builder-rule.md` 출력 형식으로 코드 제공
### 예시 2: 코드 플로우 분석 요청
**사용자 요청**: "PersonnelTableContainer가 어떻게 작동하는지 설명해줘"
**AI 작업 프로세스**:
1. `code-analysis-rule.md` 로드 → 분석 순서 준수
2. `sequential-thinking` 사용 → 데이터 페칭 순서 검증
3. `nextjs-app-router-patterns` 참고 → Server/Client 구분 설명
4. `code-analysis-rule.md` 템플릿으로 결과 출력
### 예시 3: 리팩토링 요청
**사용자 요청**: "work-execution 폴더를 정리해줘"
**AI 작업 프로세스**:
1. `refactoring-rule.md` 로드 → 워크플로우 확인
2. `sequential-thinking` 사용 → 의존성 지도 작성
3. `context7` 조회 → TanStack Query v5 권장 구조 확인
4. `refactoring-rule.md` 표준 구조로 재구성
5. `vercel-react-best-practices` 적용 → `bundle-barrel-imports`, `rerender-memo`
---
## ✅ 체크리스트
작업 시작 전 항상 확인:
- [ ] 작업 유형이 무엇인가? (개발/분석/주석/리팩토링)
- [ ] 주 룰(Primary)과 보조 룰(Secondary)은?
- [ ] 어떤 Skill을 참고해야 하는가?
- [ ] MCP 도구(sequential-thinking, context7)가 필요한가?
- [ ] 출력 형식은 어떤 룰을 따르는가?
---
## 🎓 학습 자료
- **builder-rule.md**: Tech Stack, 코딩 원칙, 출력 형식
- **code-analysis-rule.md**: 분석 순서, TypeScript 타입 설명
- **doc-rule.md**: TSDoc 형식, Step 주석 규칙
- **refactoring-rule.md**: 폴더 구조, 워크플로우
- **nextjs-app-router-patterns**: Next.js 패턴, Server/Client 구분
- **vercel-react-best-practices**: 성능 최적화 57개 룰

View File

@@ -1,94 +0,0 @@
---
trigger: manual
---
# 역할: Anti-Gravity Refactoring Specialist
당신은 psix-frontend 프로젝트의 **구조적 개선 및 리팩토링 전문가**입니다.
기존 스파게티 코드나 레거시 구조를 **모던하고 유지보수 가능한 표준 구조**로 재설계합니다.
---
## 📥 입력 (Input)
- **FEATURE_ROOT**: 리팩토링할 기능의 루트 폴더 경로
- 예) `src/features/standards/work-execution`
---
## 🎯 목표 (Goal)
1. **표준 폴더 구조 지향**: `apis`, `components`, `hooks`, `stores`, `types` 5대 폴더를 기본으로 구성한다.
2. **유연성 허용**: 필요에 따라 `utils`(유틸리티), `lib`(라이브러리 래퍼), `constants`(상수) 등 보조 폴더 생성을 허용한다.
3. **단일 파일 분해**: 거대한 파일은 기능 단위로 쪼개야 함
4. **배럴 파일 제거**: `index.ts`를 사용한 re-export 패턴을 제거하고 직접 경로(`.../components/MyComponent`)를 사용
---
## 🛠️ MCP 도구 활용 (분석 및 설계 지침)
### 1. Sequential Thinking 활용 (의존성 및 리스크 분석)
- **적용 시점**: `1) FEATURE_ROOT의 기존 구조와 import 의존성 분석` 단계에서 필수 사용
- **수행 작업**:
- 실제 파일을 옮기기 전, `sequential-thinking`을 사용하여 이동할 파일들의 의존성 지도(Dependency Map)를 먼저 그린다.
- 파일 이동 시 영향받는 외부 파일(page.tsx 등)의 리스트를 미리 확보한다.
- 수정해야 할 import 경로가 많은 경우, 논리적 순차 단계를 설정하여 하나씩 해결함으로써 경로 오류(Broken Import)를 방지한다.
### 2. Context7 활용 (기술 표준 검증)
- **적용 시점**: 폴더 구조 재구성 중 최신 라이브러리 패턴이 가이드와 충돌하거나 모호할 때 사용
- **수행 작업**:
- TanStack Query의 최신 v5 권장 폴더 구조나 Next.js 15의 App Router 최적화 기법이 필요할 경우 `context7`을 통해 공식 문서를 조회한다.
- 조회된 최신 표준과 본 룰의 구조(`apis`, `hooks`, `types` 등)를 결합하여 개발자가 유지보수하기 가장 편한 최적의 경로를 도출한다.
---
## 📋 작업 지시 (Workflow)
1. **분석**: `FEATURE_ROOT` 내의 기존 파일 구조와 외부 의존성(import)을 파악한다. (MCP 활용)
2. **구조 설계**:
- **기본 폴더**: `apis`, `hooks`, `types`, `stores`, `components`
- **선택 폴더**: `utils` (순수 함수), `lib` (설정/래퍼), `constants` (상수 데이터)
- 위 기준에 맞춰 파일 분류 계획을 세운다.
3. **이동 및 생성**: 파일을 계획된 폴더로 이동하거나 분리 생성한다.
4. **경로 수정**: 이동된 파일에 맞춰 모든 `import` 경로를 업데이트한다.
5. **청소**: 불필요해진 폴더(구조상 매핑되지 않는 옛 폴더)와 `index.ts` 파일을 삭제한다.
6. **진입점 갱신**: `page.tsx` 등 외부에서 해당 기능을 사용하는 곳의 import 경로를 수정한다.
---
## 🏗️ 권장 파일 구조 (Standard Structure)
```text
<FEATURE_ROOT>/
├── apis/
│ ├── apiError.ts
│ ├── <feature>.api.ts # API 호출 로직
│ ├── <feature>Form.adapter.ts # Form <-> API 변환
│ └── <feature>List.adapter.ts # List <-> API 변환
├── hooks/
│ ├── queryKeys.ts # Query Key Factory
│ ├── use<Feature>List.ts # 목록 조회 Hooks
│ ├── use<Feature>Mutations.ts # CUD Hooks
│ └── use<Feature>Form.ts # Form Logic Hooks
├── types/
│ ├── api.types.ts # 공통 API 응답 규격
│ ├── <feature>.types.ts # 도메인 Entity
│ └── selectOption.types.ts # 공통 Select Option
├── stores/
│ └── <feature>Store.ts # Zustand Store
├── components/
│ ├── <Feature>Container.tsx # 메인 컨테이너
│ └── <Feature>Modal.tsx # 모달 컴포넌트
├── utils/ # (Optional)
│ └── <feature>Utils.ts # 순수 헬퍼 함수
└── constants/ # (Optional)
└── <feature>.constants.ts # 상수 (components 내부에 둬도 무방)
```
---
## ⚠️ 규칙 (Rules)
1. **로직 변경 금지**: 오직 파일 위치와 구조만 변경하며, 비즈니스 로직은 건드리지 않는다.
2. **Naming Convention**:
- 파일명은 **ASCII 영문**만 사용 (한글 금지)
- UI 컴포넌트 파일: `PascalCase` (예: `WorkExecutionContainer.tsx`)
- Hooks 및 일반 파일: `camelCase` (예: `useWorkExecutionList.ts`)
3. **Clean Import**: import 시 불필요한 별칭(alias)보다는 명확한 상대/절대 경로를 사용한다.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,62 +1,25 @@
# AGENTS.md (auto-trade) # AGENTS.md (auto-trade)
## 기본 원칙 ## 운영 원칙
- 모든 응답과 설명은 한국어로 작성. - 중복 규칙은 `AGENTS.md`에 두지 않고 각 스킬(`.agents/skills/*/SKILL.md`)에서 관리한다.
- 쉬운 말로 설명하고, 어려운 용어는 괄호로 짧게 뜻을 덧붙임. - 개발 작업은 스킬 기반으로 수행한다.
- 요청이 모호하면 먼저 질문 1~3개로 범위를 확인.
- 소스 수정시 사이드이팩트가 발생하지 않게 사용자에게 묻거나 최대한 영향이 가지 않게 수정한다. 진짜 필요없는건 삭제하고 영향이 갈 내용이면 이전 소스가 영향이 없는지 검증하고 수정한다.
## 프로젝트 요약 ## 스킬 호출 규칙
- Next.js 16 App Router, React 19, TypeScript - 개발 요청 키워드(`개발해줘`, `구현해줘`, `기능 추가`, `버그 수정`, `리팩토링`, `개선해줘`, `고쳐줘`, `최적화해줘`, `만들어줘`)가 들어오면 `dev-auto-pipeline` 스킬을 우선 사용한다.
- 상태 관리: zustand - 파이프라인 단계 스킬은 아래 순서로 사용한다.
- 데이터: Supabase 1. `dev-plan-writer`
- 폼 및 검증: react-hook-form, zod 2. `dev-mcp-implementation`
- UI: Tailwind CSS v4, Radix UI (`components.json` 사용) 3. `dev-refactor-polish`
4. `dev-test-gate`
5. `dev-plan-completion-checker`
- 단순 설명/문서 요약/잡담 요청에는 파이프라인 스킬을 강제하지 않는다.
## 명령어 ## 설명 방식 규칙
- 개발 서버(포트 3001): `npm run dev` - 사용자 설명은 어려운 용어보다 쉬운 한국어를 우선 사용한다.
- 린트: `npm run lint` - 기술 용어를 써야 할 때는 바로 아래 줄에 쉬운 말로 다시 풀어쓴다.
- 빌드: `npm run build` - 데이터 흐름 설명은 항상 `입력 -> 처리 -> 결과` 순서의 짧은 단계로 말한다.
- 실행: `npm run start` - 사용자가 헷갈린 상황에서는 추상 설명보다 "지금 화면에서 확인할 것"을 먼저 안내한다.
- 요청/응답 설명 시에는 핵심 필드 3~5개만 먼저 보여주고, 필요 시 상세를 추가한다.
## 코드 및 문서 규칙
- JSX 섹션 주석 형식: `{/* ========== SECTION NAME ========== */}`
- 함수 및 컴포넌트 JSDoc에 `@see` 필수
- `@see`에는 호출 파일, 함수/이벤트 이름, 목적을 함께 작성
- 상태 정의, 이벤트 핸들러, 복잡한 JSX 로직에는 인라인 주석을 충분히 작성
- UI 흐름 설명 필수: `어느 UI -> A 함수 호출 -> B 함수 호출 -> 리턴값 반영` 형태로 작성
## 브랜드 색상 규칙
- 메인 컬러는 헤더 로고/프로필 기준의 보라 계열 `brand` 팔레트 사용
- 새 UI 작성 시 `indigo/purple/pink` 하드코딩 대신 `brand-*` 토큰 사용
- 예시: `bg-brand-500`, `text-brand-600`, `from-brand-500 to-brand-700`
- 기본 액션 색(버튼/포커스)은 `primary` 사용
- `primary``app/globals.css``brand` 팔레트와 같은 톤으로 유지
- 색상 변경이 필요하면 컴포넌트 개별 수정보다 먼저 `app/globals.css` 토큰 수정
## 개발 도구 활용
- **Skills**: 프로젝트에 적합한 스킬을 적극 활용하여 베스트 프랙티스 적용
- **MCP 서버**:
- `sequential-thinking`: 복잡한 문제 해결 시 단계별 사고 과정 정리
- `tavily-remote`: 최신 기술 트렌드 및 문서 검색
- `playwright` / `playwriter`: 브라우저 자동화 테스트
- `next-devtools`: Next.js 프로젝트 개발 및 디버깅
- `context7`: 라이브러리/프레임워크 공식 문서 참조
- `supabase-mcp-server`: Supabase 프로젝트 관리 및 쿼리
## 한국 투자 증권 API 이용시
- `mcp:kis-code-assistant-mcp` 활용
- `C:\dev\auto-trade\.tmp\open-trading-api` 활용
- API 이용시 공식 문서에 최신 업데이트가 안되어 있을수 있으므로 필요시 최신 API 명세 엑셀파일 요청을 한다. 그럼 사용자가 업로드 해줄것이다.
## 소개문구
- 불안감을 해소하고 확신을 주는 문구
- 친근하고 확신에 찬 문구를 사용하여 심리적 장벽을 낮추는 전략

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -3,6 +3,11 @@ import type { DashboardActivityResponse } from "@/features/dashboard/types/dashb
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticDashboardActivity } from "@/lib/kis/dashboard"; import { getDomesticDashboardActivity } from "@/lib/kis/dashboard";
import { hasKisApiSession } from "@/app/api/kis/_session"; import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { import {
readKisAccountParts, readKisAccountParts,
readKisCredentialsFromHeaders, readKisCredentialsFromHeaders,
@@ -23,29 +28,31 @@ import {
export async function GET(request: Request) { export async function GET(request: Request) {
const hasSession = await hasKisApiSession(); const hasSession = await hasKisApiSession();
if (!hasSession) { if (!hasSession) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
} }
const credentials = readKisCredentialsFromHeaders(request.headers); const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) { if (!hasKisConfig(credentials)) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
error: "KIS API 키 설정이 필요합니다.", code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
}, message: "KIS API 키 설정이 필요합니다.",
{ status: 400 }, });
);
} }
const account = readKisAccountParts(request.headers); const account = readKisAccountParts(request.headers);
if (!account) { if (!account) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
error: code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.", message:
}, "계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
{ status: 400 }, });
);
} }
try { try {
@@ -66,10 +73,13 @@ export async function GET(request: Request) {
}, },
}); });
} catch (error) { } catch (error) {
const message = return createKisApiErrorResponse({
error instanceof Error status: 500,
? error.message code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
: "주문내역/매매일지 조회 중 오류가 발생했습니다."; message: toKisApiErrorMessage(
return NextResponse.json({ error: message }, { status: 500 }); error,
"주문내역/매매일지 조회 중 오류가 발생했습니다.",
),
});
} }
} }

View File

@@ -3,6 +3,11 @@ import type { DashboardBalanceResponse } from "@/features/dashboard/types/dashbo
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticDashboardBalance } from "@/lib/kis/dashboard"; import { getDomesticDashboardBalance } from "@/lib/kis/dashboard";
import { hasKisApiSession } from "@/app/api/kis/_session"; import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { import {
readKisAccountParts, readKisAccountParts,
readKisCredentialsFromHeaders, readKisCredentialsFromHeaders,
@@ -21,29 +26,31 @@ import {
export async function GET(request: Request) { export async function GET(request: Request) {
const hasSession = await hasKisApiSession(); const hasSession = await hasKisApiSession();
if (!hasSession) { if (!hasSession) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
} }
const credentials = readKisCredentialsFromHeaders(request.headers); const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) { if (!hasKisConfig(credentials)) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
error: "KIS API 키 설정이 필요합니다.", code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
}, message: "KIS API 키 설정이 필요합니다.",
{ status: 400 }, });
);
} }
const account = readKisAccountParts(request.headers); const account = readKisAccountParts(request.headers);
if (!account) { if (!account) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
error: code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.", message:
}, "계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
{ status: 400 }, });
);
} }
try { try {
@@ -62,10 +69,10 @@ export async function GET(request: Request) {
}, },
}); });
} catch (error) { } catch (error) {
const message = return createKisApiErrorResponse({
error instanceof Error status: 500,
? error.message code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
: "잔고 조회 중 오류가 발생했습니다."; message: toKisApiErrorMessage(error, "잔고 조회 중 오류가 발생했습니다."),
return NextResponse.json({ error: message }, { status: 500 }); });
} }
} }

View File

@@ -2,14 +2,22 @@ import type {
DashboardChartTimeframe, DashboardChartTimeframe,
DashboardStockChartResponse, DashboardStockChartResponse,
} from "@/features/trade/types/trade.types"; } from "@/features/trade/types/trade.types";
import type { KisCredentialInput } from "@/lib/kis/config"; import { hasKisConfig } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticChart } from "@/lib/kis/domestic"; import { getDomesticChart } from "@/lib/kis/domestic";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { hasKisApiSession } from "@/app/api/kis/_session"; 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[] = [ const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
"1m", "1m",
"5m",
"10m",
"15m",
"30m", "30m",
"1h", "1h",
"1d", "1d",
@@ -23,7 +31,11 @@ const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const hasSession = await hasKisApiSession(); const hasSession = await hasKisApiSession();
if (!hasSession) { if (!hasSession) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
} }
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@@ -34,28 +46,29 @@ export async function GET(request: NextRequest) {
const cursor = (searchParams.get("cursor") ?? "").trim() || undefined; const cursor = (searchParams.get("cursor") ?? "").trim() || undefined;
if (!/^\d{6}$/.test(symbol)) { if (!/^\d{6}$/.test(symbol)) {
return NextResponse.json( return createKisApiErrorResponse({
{ error: "symbol은 6자리 숫자여야 합니다." }, status: 400,
{ status: 400 }, code: KIS_API_ERROR_CODE.INVALID_REQUEST,
); message: "symbol은 6자리 숫자여야 합니다.",
});
} }
if (!VALID_TIMEFRAMES.includes(timeframe)) { if (!VALID_TIMEFRAMES.includes(timeframe)) {
return NextResponse.json( return createKisApiErrorResponse({
{ error: "지원하지 않는 timeframe입니다." }, status: 400,
{ status: 400 }, code: KIS_API_ERROR_CODE.INVALID_REQUEST,
); message: "지원하지 않는 timeframe입니다.",
});
} }
const credentials = readKisCredentialsFromHeaders(request.headers); const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) { if (!hasKisConfig(credentials)) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
error: code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 차트를 조회할 수 없습니다.", message:
}, "대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 차트를 조회할 수 없습니다.",
{ status: 400 }, });
);
} }
try { try {
@@ -81,24 +94,10 @@ export async function GET(request: NextRequest) {
}, },
}); });
} catch (error) { } catch (error) {
const message = return createKisApiErrorResponse({
error instanceof Error status: 500,
? error.message code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
: "KIS 차트 조회 중 오류가 발생했습니다."; message: toKisApiErrorMessage(error, "KIS 차트 조회 중 오류가 발생했습니다."),
return NextResponse.json({ error: message }, { status: 500 }); });
} }
} }
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,
};
}

View File

@@ -3,6 +3,11 @@ import type { DashboardIndicesResponse } from "@/features/dashboard/types/dashbo
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticDashboardIndices } from "@/lib/kis/dashboard"; import { getDomesticDashboardIndices } from "@/lib/kis/dashboard";
import { hasKisApiSession } from "@/app/api/kis/_session"; 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"; import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
/** /**
@@ -18,18 +23,21 @@ import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
export async function GET(request: Request) { export async function GET(request: Request) {
const hasSession = await hasKisApiSession(); const hasSession = await hasKisApiSession();
if (!hasSession) { if (!hasSession) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
} }
const credentials = readKisCredentialsFromHeaders(request.headers); const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) { if (!hasKisConfig(credentials)) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
error: "KIS API 키 설정이 필요합니다.", code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
}, message: "KIS API 키 설정이 필요합니다.",
{ status: 400 }, });
);
} }
try { try {
@@ -47,10 +55,10 @@ export async function GET(request: Request) {
}, },
}); });
} catch (error) { } catch (error) {
const message = return createKisApiErrorResponse({
error instanceof Error status: 500,
? error.message code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
: "지수 조회 중 오류가 발생했습니다."; message: toKisApiErrorMessage(error, "지수 조회 중 오류가 발생했습니다."),
return NextResponse.json({ error: message }, { status: 500 }); });
} }
} }

View File

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

View File

@@ -1,67 +1,137 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { executeOrderCash } from "@/lib/kis/trade"; import { executeOrderCash } from "@/lib/kis/trade";
import { import {
DashboardStockCashOrderRequest,
DashboardStockCashOrderResponse, DashboardStockCashOrderResponse,
} from "@/features/trade/types/trade.types"; } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session"; import { hasKisApiSession } from "@/app/api/kis/_session";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { parseKisAccountParts } from "@/lib/kis/account";
import { import {
KisCredentialInput, createKisApiErrorResponse,
hasKisConfig, KIS_API_ERROR_CODE,
normalizeTradingEnv, toKisApiErrorMessage,
} from "@/lib/kis/config"; } from "@/app/api/kis/_response";
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
/** /**
* @file app/api/kis/domestic/order-cash/route.ts * @file app/api/kis/domestic/order-cash/route.ts
* @description 국내주식 현금 주문 API * @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) { export async function POST(request: NextRequest) {
const credentials = readKisCredentialsFromHeaders(request.headers); const credentials = readKisCredentialsFromHeaders(request.headers);
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv); const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
const hasSession = await hasKisApiSession(); const hasSession = await hasKisApiSession();
if (!hasSession) { if (!hasSession) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 401,
ok: false, code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
tradingEnv, message: "로그인이 필요합니다.",
message: "로그인이 필요합니다.", tradingEnv,
}, });
{ status: 401 },
);
} }
if (!hasKisConfig(credentials)) { if (!hasKisConfig(credentials)) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
ok: false, code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
tradingEnv, message: "KIS API 키 설정이 필요합니다.",
message: "KIS API 키 설정이 필요합니다.", tradingEnv,
}, });
{ status: 400 },
);
} }
try { try {
const body = (await request.json()) as DashboardStockCashOrderRequest; 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,
});
}
// TODO: Validate body fields (symbol, quantity, price, etc.) const parsed = orderCashBodySchema.safeParse(rawBody);
if (
!body.symbol || if (!parsed.success) {
!body.accountNo || const firstIssue = parsed.error.issues[0];
!body.accountProductCode || return createKisApiErrorResponse({
body.quantity <= 0 status: 400,
) { code: KIS_API_ERROR_CODE.INVALID_REQUEST,
return NextResponse.json( message: firstIssue?.message ?? "주문 요청 값이 올바르지 않습니다.",
{ tradingEnv,
ok: false, });
tradingEnv, }
message:
"주문 정보가 올바르지 않습니다. (종목코드, 계좌번호, 수량 확인)", const body = parsed.data;
}, const accountParts = parseKisAccountParts(
{ status: 400 }, 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( const output = await executeOrderCash(
@@ -71,8 +141,8 @@ export async function POST(request: NextRequest) {
orderType: body.orderType, orderType: body.orderType,
quantity: body.quantity, quantity: body.quantity,
price: body.price, price: body.price,
accountNo: body.accountNo, accountNo: accountParts.accountNo,
accountProductCode: body.accountProductCode, accountProductCode: accountParts.accountProductCode,
}, },
credentials, credentials,
); );
@@ -88,31 +158,11 @@ export async function POST(request: NextRequest) {
return NextResponse.json(response); return NextResponse.json(response);
} catch (error) { } catch (error) {
const message = return createKisApiErrorResponse({
error instanceof Error status: 500,
? error.message code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
: "주문 전송 중 오류가 발생했습니다."; message: toKisApiErrorMessage(error, "주문 전송 중 오류가 발생했습니다."),
return NextResponse.json( tradingEnv,
{ });
ok: false,
tradingEnv,
message,
},
{ status: 500 },
);
} }
} }
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,
};
}

View File

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

View File

@@ -5,15 +5,17 @@ import {
} from "@/lib/kis/domestic"; } from "@/lib/kis/domestic";
import { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types"; import { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session"; import { hasKisApiSession } from "@/app/api/kis/_session";
import { import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
KisCredentialInput,
hasKisConfig,
normalizeTradingEnv,
} from "@/lib/kis/config";
import { import {
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER, DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
parseDomesticKisSession, parseDomesticKisSession,
} from "@/lib/kis/domestic-market-session"; } 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 * @file app/api/kis/domestic/orderbook/route.ts
@@ -23,28 +25,32 @@ import {
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const hasSession = await hasKisApiSession(); const hasSession = await hasKisApiSession();
if (!hasSession) { if (!hasSession) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
} }
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim(); const symbol = (searchParams.get("symbol") ?? "").trim();
if (!/^\d{6}$/.test(symbol)) { if (!/^\d{6}$/.test(symbol)) {
return NextResponse.json( return createKisApiErrorResponse({
{ error: "symbol은 6자리 숫자여야 합니다." }, status: 400,
{ status: 400 }, code: KIS_API_ERROR_CODE.INVALID_REQUEST,
); message: "symbol은 6자리 숫자여야 합니다.",
});
} }
const credentials = readKisCredentialsFromHeaders(request.headers); const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) { if (!hasKisConfig(credentials)) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
error: "KIS API 키 설정이 필요합니다.", code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
}, message: "KIS API 키 설정이 필요합니다.",
{ status: 400 }, });
);
} }
try { try {
@@ -95,28 +101,14 @@ export async function GET(request: NextRequest) {
}, },
}); });
} catch (error) { } catch (error) {
const message = return createKisApiErrorResponse({
error instanceof Error status: 500,
? error.message code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
: "호가 조회 중 오류가 발생했습니다."; message: toKisApiErrorMessage(error, "호가 조회 중 오류가 발생했습니다."),
return NextResponse.json({ error: message }, { status: 500 }); });
} }
} }
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,
};
}
function readSessionOverrideFromHeaders(headers: Headers) { function readSessionOverrideFromHeaders(headers: Headers) {
if (process.env.NODE_ENV === "production") return null; if (process.env.NODE_ENV === "production") return null;
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER); const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);

View File

@@ -1,14 +1,19 @@
import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks"; import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks";
import type { DashboardStockOverviewResponse } from "@/features/trade/types/trade.types"; import type { DashboardStockOverviewResponse } from "@/features/trade/types/trade.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { hasKisApiSession } from "@/app/api/kis/_session"; import { hasKisApiSession } from "@/app/api/kis/_session";
import { getDomesticOverview } from "@/lib/kis/domestic"; import { getDomesticOverview } from "@/lib/kis/domestic";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { import {
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER, DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
parseDomesticKisSession, parseDomesticKisSession,
} from "@/lib/kis/domestic-market-session"; } from "@/lib/kis/domestic-market-session";
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
/** /**
* @file app/api/kis/domestic/overview/route.ts * @file app/api/kis/domestic/overview/route.ts
@@ -23,26 +28,33 @@ import {
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const hasSession = await hasKisApiSession(); const hasSession = await hasKisApiSession();
if (!hasSession) { if (!hasSession) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
} }
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim(); const symbol = (searchParams.get("symbol") ?? "").trim();
if (!/^\d{6}$/.test(symbol)) { if (!/^\d{6}$/.test(symbol)) {
return NextResponse.json({ error: "symbol은 6자리 숫자여야 합니다." }, { status: 400 }); return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: "symbol은 6자리 숫자여야 합니다.",
});
} }
const credentials = readKisCredentialsFromHeaders(request.headers); const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) { if (!hasKisConfig(credentials)) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
error: code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 시세를 조회할 수 없습니다.", message:
}, "대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 시세를 조회할 수 없습니다.",
{ status: 400 }, });
);
} }
const fallbackMeta = KOREAN_STOCK_INDEX.find((item) => item.symbol === symbol); const fallbackMeta = KOREAN_STOCK_INDEX.find((item) => item.symbol === symbol);
@@ -71,28 +83,14 @@ export async function GET(request: NextRequest) {
}, },
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "KIS 조회 중 오류가 발생했습니다."; return createKisApiErrorResponse({
return NextResponse.json({ error: message }, { status: 500 }); status: 500,
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
message: toKisApiErrorMessage(error, "KIS 조회 중 오류가 발생했습니다."),
});
} }
} }
/**
* 요청 헤더에서 KIS 키를 읽어옵니다.
* @param headers 요청 헤더
* @returns credentials
*/
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,
};
}
function readSessionOverrideFromHeaders(headers: Headers) { function readSessionOverrideFromHeaders(headers: Headers) {
if (process.env.NODE_ENV === "production") return null; if (process.env.NODE_ENV === "production") return null;
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER); const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);

View File

@@ -5,6 +5,10 @@ import type {
KoreanStockIndexItem, KoreanStockIndexItem,
} from "@/features/trade/types/trade.types"; } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session"; import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
} from "@/app/api/kis/_response";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
const SEARCH_LIMIT = 10; const SEARCH_LIMIT = 10;
@@ -29,7 +33,11 @@ const SEARCH_LIMIT = 10;
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const hasSession = await hasKisApiSession(); const hasSession = await hasKisApiSession();
if (!hasSession) { if (!hasSession) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
} }
// [Step 1] query string에서 검색어(q)를 읽고 공백을 제거합니다. // [Step 1] query string에서 검색어(q)를 읽고 공백을 제거합니다.

View File

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

View File

@@ -6,6 +6,11 @@ import {
} from "@/lib/kis/request"; } from "@/lib/kis/request";
import { revokeKisAccessToken } from "@/lib/kis/token"; import { revokeKisAccessToken } from "@/lib/kis/token";
import { hasKisApiSession } from "@/app/api/kis/_session"; 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"; import { NextRequest, NextResponse } from "next/server";
/** /**
@@ -23,26 +28,22 @@ export async function POST(request: NextRequest) {
const hasSession = await hasKisApiSession(); const hasSession = await hasKisApiSession();
if (!hasSession) { if (!hasSession) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 401,
ok: false, code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
tradingEnv, message: "로그인이 필요합니다.",
message: "로그인이 필요합니다.", tradingEnv,
} satisfies DashboardKisRevokeResponse, });
{ status: 401 },
);
} }
const invalidMessage = validateKisCredentialInput(credentials); const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) { if (invalidMessage) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
ok: false, code: KIS_API_ERROR_CODE.INVALID_REQUEST,
tradingEnv, message: invalidMessage,
message: invalidMessage, tradingEnv,
} satisfies DashboardKisRevokeResponse, });
{ status: 400 },
);
} }
try { try {
@@ -54,18 +55,11 @@ export async function POST(request: NextRequest) {
message, message,
} satisfies DashboardKisRevokeResponse); } satisfies DashboardKisRevokeResponse);
} catch (error) { } catch (error) {
const message = return createKisApiErrorResponse({
error instanceof Error status: 401,
? error.message code: KIS_API_ERROR_CODE.UNAUTHORIZED,
: "API 토큰 폐기 중 오류가 발생했습니다."; message: toKisApiErrorMessage(error, "API 토큰 폐기 중 오류가 발생했습니다."),
tradingEnv,
return NextResponse.json( });
{
ok: false,
tradingEnv,
message,
} satisfies DashboardKisRevokeResponse,
{ status: 401 },
);
} }
} }

View File

@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import type { DashboardKisProfileValidateResponse } from "@/features/trade/types/trade.types"; import type { DashboardKisProfileValidateResponse } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session"; import { hasKisApiSession } from "@/app/api/kis/_session";
import { parseKisAccountParts } from "@/lib/kis/account"; import { parseKisAccountParts } from "@/lib/kis/account";
@@ -6,13 +7,18 @@ import { kisGet } from "@/lib/kis/client";
import { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config"; import { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config";
import { validateKisCredentialInput } from "@/lib/kis/request"; import { validateKisCredentialInput } from "@/lib/kis/request";
import { getKisAccessToken } from "@/lib/kis/token"; import { getKisAccessToken } from "@/lib/kis/token";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
interface KisProfileValidateRequestBody { const kisProfileValidateBodySchema = z.object({
appKey?: string; appKey: z.string().trim().min(1, "앱 키를 입력해 주세요."),
appSecret?: string; appSecret: z.string().trim().min(1, "앱 시크릿을 입력해 주세요."),
tradingEnv?: string; tradingEnv: z.string().optional(),
accountNo?: string; accountNo: z.string().trim().min(1, "계좌번호를 입력해 주세요."),
} });
interface BalanceValidationPreset { interface BalanceValidationPreset {
inqrDvsn: "01" | "02"; inqrDvsn: "01" | "02";
@@ -50,34 +56,44 @@ export async function POST(request: NextRequest) {
const hasSession = await hasKisApiSession(); const hasSession = await hasKisApiSession();
if (!hasSession) { if (!hasSession) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 401,
ok: false, code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
tradingEnv: fallbackTradingEnv, message: "로그인이 필요합니다.",
message: "로그인이 필요합니다.", tradingEnv: fallbackTradingEnv,
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">, });
{ status: 401 },
);
} }
let body: KisProfileValidateRequestBody = {}; let rawBody: unknown = {};
try { try {
body = (await request.json()) as KisProfileValidateRequestBody; rawBody = (await request.json()) as unknown;
} catch { } catch {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
ok: false, code: KIS_API_ERROR_CODE.INVALID_REQUEST,
tradingEnv: fallbackTradingEnv, message: "요청 본문(JSON)을 읽을 수 없습니다.",
message: "요청 본문(JSON)을 읽을 수 없습니다.", tradingEnv: fallbackTradingEnv,
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">, });
{ status: 400 },
);
} }
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 = { const credentials: KisCredentialInput = {
appKey: body.appKey?.trim(), appKey: body.appKey.trim(),
appSecret: body.appSecret?.trim(), appSecret: body.appSecret.trim(),
tradingEnv: normalizeTradingEnv(body.tradingEnv), tradingEnv: normalizeTradingEnv(body.tradingEnv),
}; };
@@ -85,39 +101,25 @@ export async function POST(request: NextRequest) {
const invalidCredentialMessage = validateKisCredentialInput(credentials); const invalidCredentialMessage = validateKisCredentialInput(credentials);
if (invalidCredentialMessage) { if (invalidCredentialMessage) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
ok: false, code: KIS_API_ERROR_CODE.INVALID_REQUEST,
tradingEnv, message: invalidCredentialMessage,
message: invalidCredentialMessage, tradingEnv,
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">, });
{ status: 400 },
);
} }
const accountNoInput = (body.accountNo ?? "").trim(); const accountNoInput = body.accountNo.trim();
if (!accountNoInput) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: "계좌번호를 입력해 주세요.",
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 400 },
);
}
const accountParts = parseKisAccountParts(accountNoInput); const accountParts = parseKisAccountParts(accountNoInput);
if (!accountParts) { if (!accountParts) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
ok: false, code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
tradingEnv, message:
message: "계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.", "계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">, tradingEnv,
{ status: 400 }, });
);
} }
try { try {
@@ -150,19 +152,12 @@ export async function POST(request: NextRequest) {
}, },
} satisfies DashboardKisProfileValidateResponse); } satisfies DashboardKisProfileValidateResponse);
} catch (error) { } catch (error) {
const message = return createKisApiErrorResponse({
error instanceof Error status: 400,
? error.message code: KIS_API_ERROR_CODE.UNAUTHORIZED,
: "계좌 검증 중 오류가 발생했습니다."; message: toKisApiErrorMessage(error, "계좌 검증 중 오류가 발생했습니다."),
tradingEnv,
return NextResponse.json( });
{
ok: false,
tradingEnv,
message,
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 400 },
);
} }
} }

View File

@@ -6,6 +6,11 @@ import {
} from "@/lib/kis/request"; } from "@/lib/kis/request";
import { getKisAccessToken } from "@/lib/kis/token"; import { getKisAccessToken } from "@/lib/kis/token";
import { hasKisApiSession } from "@/app/api/kis/_session"; 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"; import { NextRequest, NextResponse } from "next/server";
/** /**
@@ -23,26 +28,22 @@ export async function POST(request: NextRequest) {
const hasSession = await hasKisApiSession(); const hasSession = await hasKisApiSession();
if (!hasSession) { if (!hasSession) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 401,
ok: false, code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
tradingEnv, message: "로그인이 필요합니다.",
message: "로그인이 필요합니다.", tradingEnv,
} satisfies DashboardKisValidateResponse, });
{ status: 401 },
);
} }
const invalidMessage = validateKisCredentialInput(credentials); const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) { if (invalidMessage) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
ok: false, code: KIS_API_ERROR_CODE.INVALID_REQUEST,
tradingEnv, message: invalidMessage,
message: invalidMessage, tradingEnv,
} satisfies DashboardKisValidateResponse, });
{ status: 400 },
);
} }
try { try {
@@ -54,18 +55,11 @@ export async function POST(request: NextRequest) {
message: "API 키 검증이 완료되었습니다. (토큰 발급 성공)", message: "API 키 검증이 완료되었습니다. (토큰 발급 성공)",
} satisfies DashboardKisValidateResponse); } satisfies DashboardKisValidateResponse);
} catch (error) { } catch (error) {
const message = return createKisApiErrorResponse({
error instanceof Error status: 401,
? error.message code: KIS_API_ERROR_CODE.UNAUTHORIZED,
: "API 키 검증 중 오류가 발생했습니다."; message: toKisApiErrorMessage(error, "API 키 검증 중 오류가 발생했습니다."),
tradingEnv,
return NextResponse.json( });
{
ok: false,
tradingEnv,
message,
} satisfies DashboardKisValidateResponse,
{ status: 401 },
);
} }
} }

View File

@@ -6,6 +6,11 @@ import {
parseKisCredentialRequest, parseKisCredentialRequest,
validateKisCredentialInput, validateKisCredentialInput,
} from "@/lib/kis/request"; } from "@/lib/kis/request";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
/** /**
@@ -23,26 +28,22 @@ export async function POST(request: NextRequest) {
const hasSession = await hasKisApiSession(); const hasSession = await hasKisApiSession();
if (!hasSession) { if (!hasSession) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 401,
ok: false, code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
tradingEnv, message: "로그인이 필요합니다.",
message: "로그인이 필요합니다.", tradingEnv,
} satisfies DashboardKisWsApprovalResponse, });
{ status: 401 },
);
} }
const invalidMessage = validateKisCredentialInput(credentials); const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) { if (invalidMessage) {
return NextResponse.json( return createKisApiErrorResponse({
{ status: 400,
ok: false, code: KIS_API_ERROR_CODE.INVALID_REQUEST,
tradingEnv, message: invalidMessage,
message: invalidMessage, tradingEnv,
} satisfies DashboardKisWsApprovalResponse, });
{ status: 400 },
);
} }
try { try {
@@ -57,18 +58,14 @@ export async function POST(request: NextRequest) {
message: "웹소켓 승인키 발급이 완료되었습니다.", message: "웹소켓 승인키 발급이 완료되었습니다.",
} satisfies DashboardKisWsApprovalResponse); } satisfies DashboardKisWsApprovalResponse);
} catch (error) { } catch (error) {
const message = return createKisApiErrorResponse({
error instanceof Error status: 401,
? error.message code: KIS_API_ERROR_CODE.UNAUTHORIZED,
: "웹소켓 승인키 발급 중 오류가 발생했습니다."; message: toKisApiErrorMessage(
error,
return NextResponse.json( "웹소켓 승인키 발급 중 오류가 발생했습니다.",
{ ),
ok: false, tradingEnv,
tradingEnv, });
message,
} satisfies DashboardKisWsApprovalResponse,
{ status: 401 },
);
} }
} }

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,8 +1,14 @@
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import {
buildKisRequestHeaders,
resolveKisApiErrorMessage,
type KisApiErrorPayload,
} from "@/features/settings/apis/kis-api-utils";
import type { import type {
DashboardActivityResponse, DashboardActivityResponse,
DashboardBalanceResponse, DashboardBalanceResponse,
DashboardIndicesResponse, DashboardIndicesResponse,
DashboardMarketHubResponse,
} from "@/features/dashboard/types/dashboard.types"; } from "@/features/dashboard/types/dashboard.types";
/** /**
@@ -21,18 +27,16 @@ export async function fetchDashboardBalance(
): Promise<DashboardBalanceResponse> { ): Promise<DashboardBalanceResponse> {
const response = await fetch("/api/kis/domestic/balance", { const response = await fetch("/api/kis/domestic/balance", {
method: "GET", method: "GET",
headers: buildKisRequestHeaders(credentials), headers: buildKisRequestHeaders(credentials, { includeAccountNo: true }),
cache: "no-store", cache: "no-store",
}); });
const payload = (await response.json()) as const payload = (await response.json()) as
| DashboardBalanceResponse | DashboardBalanceResponse
| { error?: string }; | KisApiErrorPayload;
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(resolveKisApiErrorMessage(payload, "잔고 조회 중 오류가 발생했습니다."));
"error" in payload ? payload.error : "잔고 조회 중 오류가 발생했습니다.",
);
} }
return payload as DashboardBalanceResponse; return payload as DashboardBalanceResponse;
@@ -55,12 +59,10 @@ export async function fetchDashboardIndices(
const payload = (await response.json()) as const payload = (await response.json()) as
| DashboardIndicesResponse | DashboardIndicesResponse
| { error?: string }; | KisApiErrorPayload;
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(resolveKisApiErrorMessage(payload, "지수 조회 중 오류가 발생했습니다."));
"error" in payload ? payload.error : "지수 조회 중 오류가 발생했습니다.",
);
} }
return payload as DashboardIndicesResponse; return payload as DashboardIndicesResponse;
@@ -77,39 +79,45 @@ export async function fetchDashboardActivity(
): Promise<DashboardActivityResponse> { ): Promise<DashboardActivityResponse> {
const response = await fetch("/api/kis/domestic/activity", { const response = await fetch("/api/kis/domestic/activity", {
method: "GET", method: "GET",
headers: buildKisRequestHeaders(credentials), headers: buildKisRequestHeaders(credentials, { includeAccountNo: true }),
cache: "no-store", cache: "no-store",
}); });
const payload = (await response.json()) as const payload = (await response.json()) as
| DashboardActivityResponse | DashboardActivityResponse
| { error?: string }; | KisApiErrorPayload;
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(resolveKisApiErrorMessage(payload, "활동 데이터 조회 중 오류가 발생했습니다."));
"error" in payload ? payload.error : "활동 데이터 조회 중 오류가 발생했습니다.",
);
} }
return payload as DashboardActivityResponse; return payload as DashboardActivityResponse;
} }
/** /**
* 대시보드 API 공통 헤더를 구성합니다. * 급등/인기/뉴스 시장 허브 데이터를 조회합니다.
* @param credentials KIS 인증 정보 * @param credentials KIS 인증 정보
* @returns KIS 전달 헤더 * @returns 시장 허브 응답
* @see features/dashboard/apis/dashboard.api.ts fetchDashboardBalance/fetchDashboardIndices * @see app/api/kis/domestic/market-hub/route.ts 서버 라우트
*/ */
function buildKisRequestHeaders(credentials: KisRuntimeCredentials) { export async function fetchDashboardMarketHub(
const headers: Record<string, string> = { credentials: KisRuntimeCredentials,
"x-kis-app-key": credentials.appKey, ): Promise<DashboardMarketHubResponse> {
"x-kis-app-secret": credentials.appSecret, const response = await fetch("/api/kis/domestic/market-hub", {
"x-kis-trading-env": credentials.tradingEnv, method: "GET",
}; headers: buildKisRequestHeaders(credentials),
cache: "no-store",
});
if (credentials.accountNo?.trim()) { const payload = (await response.json()) as
headers["x-kis-account-no"] = credentials.accountNo.trim(); | DashboardMarketHubResponse
| KisApiErrorPayload;
if (!response.ok) {
throw new Error(
resolveKisApiErrorMessage(payload, "시장 허브 조회 중 오류가 발생했습니다."),
);
} }
return headers; return payload as DashboardMarketHubResponse;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,29 +0,0 @@
"use client";
import Spline from "@splinetool/react-spline";
import { useState } from "react";
import { cn } from "@/lib/utils";
interface SplineSceneProps {
sceneUrl: string;
className?: string;
}
export function SplineScene({ sceneUrl, className }: SplineSceneProps) {
const [isLoading, setIsLoading] = useState(true);
return (
<div className={cn("relative h-full w-full", className)}>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-zinc-100 dark:bg-zinc-900">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-zinc-200 border-t-brand-500 dark:border-zinc-800" />
</div>
)}
<Spline
scene={sceneUrl}
onLoad={() => setIsLoading(false)}
className="h-full w-full"
/>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import { buildKisErrorDetail } from "@/lib/kis/error-codes";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store"; import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
import { buildKisRealtimeMessage } from "@/features/kis-realtime/utils/websocketUtils"; import { buildKisRealtimeMessage } from "@/features/kis-realtime/utils/websocketUtils";
@@ -63,6 +64,21 @@ const RECONNECT_BASE_DELAY_MS = 1_000;
const RECONNECT_MAX_DELAY_MS = 30_000; const RECONNECT_MAX_DELAY_MS = 30_000;
const RECONNECT_JITTER_MS = 300; const RECONNECT_JITTER_MS = 300;
function isKisWsDebugEnabled() {
if (typeof window === "undefined") return false;
return window.localStorage.getItem("KIS_WS_DEBUG") === "1";
}
function wsDebugLog(...args: unknown[]) {
if (!isKisWsDebugEnabled()) return;
console.log(...args);
}
function wsDebugWarn(...args: unknown[]) {
if (!isKisWsDebugEnabled()) return;
console.warn(...args);
}
export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
isConnected: false, isConnected: false,
error: null, error: null,
@@ -105,7 +121,7 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
// 소켓 생성 // 소켓 생성
// socket 변수에 할당하기 전에 로컬 변수로 제어하여 이벤트 핸들러 클로저 문제 방지 // socket 변수에 할당하기 전에 로컬 변수로 제어하여 이벤트 핸들러 클로저 문제 방지
const ws = new WebSocket(wsConnection.wsUrl); const ws = new WebSocket(wsConnection.wsUrl);
console.log("[KisWebSocket] Connecting to:", wsConnection.wsUrl); wsDebugLog("[KisWebSocket] Connecting to:", wsConnection.wsUrl);
socket = ws; socket = ws;
ws.onopen = () => { ws.onopen = () => {
@@ -116,7 +132,7 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
set({ isConnected: true, error: null }); set({ isConnected: true, error: null });
reconnectAttempt = 0; reconnectAttempt = 0;
console.log("[KisWebSocket] Connected"); wsDebugLog("[KisWebSocket] Connected");
// 재연결 시 기존 구독 복구 // 재연결 시 기존 구독 복구
const approvalKey = wsConnection.approvalKey; const approvalKey = wsConnection.approvalKey;
@@ -147,7 +163,7 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
if (canAutoReconnect) { if (canAutoReconnect) {
reconnectAttempt += 1; reconnectAttempt += 1;
const delayMs = getReconnectDelayMs(reconnectAttempt); const delayMs = getReconnectDelayMs(reconnectAttempt);
console.warn( wsDebugWarn(
`[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"}, wasClean=${event.wasClean}) -> reconnect in ${delayMs}ms (attempt ${reconnectAttempt}/${MAX_AUTO_RECONNECT_ATTEMPTS})`, `[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"}, wasClean=${event.wasClean}) -> reconnect in ${delayMs}ms (attempt ${reconnectAttempt}/${MAX_AUTO_RECONNECT_ATTEMPTS})`,
); );
@@ -170,7 +186,7 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
} }
reconnectAttempt = 0; reconnectAttempt = 0;
console.log( wsDebugLog(
`[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"})`, `[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"})`,
); );
} }
@@ -221,15 +237,15 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
// KIS 서버가 세션을 정리하는 데 최소 10~30초가 필요하므로 // KIS 서버가 세션을 정리하는 데 최소 10~30초가 필요하므로
// 충분한 대기 후 재연결합니다. // 충분한 대기 후 재연결합니다.
if (control.msgCd === "OPSP8996") { if (control.msgCd === "OPSP8996") {
const now = Date.now(); const now = Date.now();
if (now - lastAppKeyConflictAt > 5_000) { if (now - lastAppKeyConflictAt > 5_000) {
lastAppKeyConflictAt = now; lastAppKeyConflictAt = now;
console.warn( wsDebugWarn(
"[KisWebSocket] ALREADY IN USE appkey - 현재 소켓을 닫고 30초 후 재연결합니다.", "[KisWebSocket] ALREADY IN USE appkey - 현재 소켓을 닫고 30초 후 재연결합니다.",
); );
// 현재 소켓을 즉시 닫아 서버 측 세션 정리 유도 // 현재 소켓을 즉시 닫아 서버 측 세션 정리 유도
if (socket === ws && ws.readyState === WebSocket.OPEN) { if (socket === ws && ws.readyState === WebSocket.OPEN) {
ws.close(1000, "ALREADY IN USE - graceful close"); ws.close(1000, "ALREADY IN USE - graceful close");
} }
window.clearTimeout(reconnectRetryTimer); window.clearTimeout(reconnectRetryTimer);
reconnectRetryTimer = window.setTimeout(() => { reconnectRetryTimer = window.setTimeout(() => {
@@ -374,11 +390,11 @@ function sendSubscription(
try { try {
const msg = buildKisRealtimeMessage(appKey, symbol, trId, trType); const msg = buildKisRealtimeMessage(appKey, symbol, trId, trType);
ws.send(JSON.stringify(msg)); ws.send(JSON.stringify(msg));
console.debug( wsDebugLog(
`[KisWebSocket] ${trType === "1" ? "Sub" : "Unsub"} ${trId} ${symbol}`, `[KisWebSocket] ${trType === "1" ? "Sub" : "Unsub"} ${trId} ${symbol}`,
); );
} catch (e) { } catch (e) {
console.warn("[KisWebSocket] Send error", e); wsDebugWarn("[KisWebSocket] Send error", e);
} }
} }
@@ -440,7 +456,10 @@ function buildControlErrorMessage(message: KisWsControlMessage) {
if (message.msgCd === "OPSP8996") { if (message.msgCd === "OPSP8996") {
return "실시간 연결이 다른 세션과 충돌해 재연결을 시도합니다."; return "실시간 연결이 다른 세션과 충돌해 재연결을 시도합니다.";
} }
const detail = [message.msg1, message.msgCd].filter(Boolean).join(" / "); const detail = buildKisErrorDetail({
message: message.msg1,
msgCode: message.msgCd,
});
return detail return detail
? `실시간 제어 메시지 오류: ${detail}` ? `실시간 제어 메시지 오류: ${detail}`
: "실시간 제어 메시지 오류"; : "실시간 제어 메시지 오류";

View File

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

View File

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

View File

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

View File

@@ -6,8 +6,6 @@ import {
ChevronLeft, ChevronLeft,
Home, Home,
Settings, Settings,
User,
Wallet,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
@@ -31,20 +29,6 @@ const MENU_ITEMS: MenuItem[] = [
badge: "LIVE", badge: "LIVE",
showInBottomNav: true, showInBottomNav: true,
}, },
{
title: "자산현황",
href: "/assets",
icon: Wallet,
variant: "ghost",
showInBottomNav: true,
},
{
title: "프로필",
href: "/profile",
icon: User,
variant: "ghost",
showInBottomNav: false,
},
{ {
title: "설정", title: "설정",
href: "/settings", href: "/settings",

View File

@@ -6,7 +6,7 @@
"use client"; "use client";
import { User } from "@supabase/supabase-js"; import { User } from "@supabase/supabase-js";
import { LogOut, Settings, User as UserIcon } from "lucide-react"; import { LogOut, Settings } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { signout } from "@/features/auth/actions"; import { signout } from "@/features/auth/actions";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@@ -18,12 +18,14 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { KIS_REMEMBER_LOCAL_STORAGE_KEYS } from "@/features/settings/lib/kis-remember-storage";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const SESSION_RELATED_STORAGE_KEYS = [ const SESSION_RELATED_STORAGE_KEYS = [
"session-storage", "session-storage",
"auth-storage", "auth-storage",
"autotrade-kis-runtime-store", "autotrade-kis-runtime-store",
...KIS_REMEMBER_LOCAL_STORAGE_KEYS,
] as const; ] as const;
interface UserMenuProps { interface UserMenuProps {
@@ -54,6 +56,7 @@ export function UserMenu({ user, blendWithBackground = false }: UserMenuProps) {
for (const key of SESSION_RELATED_STORAGE_KEYS) { for (const key of SESSION_RELATED_STORAGE_KEYS) {
window.localStorage.removeItem(key); window.localStorage.removeItem(key);
window.sessionStorage.removeItem(key);
} }
}; };
@@ -97,11 +100,6 @@ export function UserMenu({ user, blendWithBackground = false }: UserMenuProps) {
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push("/profile")}>
<UserIcon className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/settings")}> <DropdownMenuItem onClick={() => router.push("/settings")}>
<Settings className="mr-2 h-4 w-4" /> <Settings className="mr-2 h-4 w-4" />
<span></span> <span></span>

View File

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

View File

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

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