Compare commits
21 Commits
871f864dce
...
features/d
| Author | SHA1 | Date | |
|---|---|---|---|
| e51d767878 | |||
| 406af7408a | |||
| 4c52d6d82f | |||
| 076f27a12a | |||
| f875e338eb | |||
| a16af8ad7d | |||
| 19ebb1c6ea | |||
| 276ef09d89 | |||
| b73867c65d | |||
| 7c194d7452 | |||
| 1ac907cd27 | |||
| 12feeb2775 | |||
| 434a814246 | |||
| 8f1d75b4d5 | |||
| 3cea3e66d0 | |||
| f650d51f68 | |||
| 95291e6922 | |||
| def87bd47a | |||
| 89bad1d141 | |||
| e5a518b211 | |||
| ca01f33d71 |
@@ -1,34 +0,0 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
# 개발 기본 원칙
|
||||
|
||||
## 언어 및 커뮤니케이션
|
||||
|
||||
- 모든 응답은 **한글**로 작성
|
||||
- 코드 주석, 문서, 플랜, 결과물 모두 한글 사용
|
||||
|
||||
## 개발 도구 활용
|
||||
|
||||
- **Skills**: 프로젝트에 적합한 스킬을 적극 활용하여 베스트 프랙티스 적용
|
||||
- **MCP 서버**:
|
||||
- `sequential-thinking`: 복잡한 문제 해결 시 단계별 사고 과정 정리
|
||||
- `tavily-remote`: 최신 기술 트렌드 및 문서 검색
|
||||
- `playwright` / `playwriter`: 브라우저 자동화 테스트
|
||||
- `next-devtools`: Next.js 프로젝트 개발 및 디버깅
|
||||
- `context7`: 라이브러리/프레임워크 공식 문서 참조
|
||||
- `supabase-mcp-server`: Supabase 프로젝트 관리 및 쿼리
|
||||
|
||||
## 코드 품질
|
||||
|
||||
- 린트 에러는 즉시 수정
|
||||
- React 베스트 프랙티스 준수 (예: useEffect 내 setState 지양)
|
||||
- TypeScript 타입 안정성 유지
|
||||
- 접근성(a11y) 고려한 UI 구현
|
||||
|
||||
## 테스트 및 검증
|
||||
|
||||
- 브라우저 테스트는 MCP Playwright 활용
|
||||
- 변경 사항은 반드시 로컬에서 검증 후 완료 보고
|
||||
- 에러 발생 시 근본 원인 파악 및 해결
|
||||
@@ -1,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` 타입 사용 금지
|
||||
@@ -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/ # 공통 스토어
|
||||
```
|
||||
@@ -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는 파일명 + 함수명 + 역할**: 전체 경로 불필요
|
||||
|
||||
# 지금부터 작업
|
||||
|
||||
내가 주는 코드를 위 규칙에 맞게 "주석만" 보강하라.
|
||||
@@ -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개 룰
|
||||
@@ -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)보다는 명확한 상대/절대 경로를 사용한다.
|
||||
@@ -1,96 +0,0 @@
|
||||
---
|
||||
name: find-skills
|
||||
description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", or "is there a skill that can help with X".
|
||||
---
|
||||
|
||||
# Find Skills
|
||||
|
||||
This skill helps you discover and install skills from the open agent skills ecosystem.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when the user:
|
||||
|
||||
- Asks "how do I do X" where X might be a common task with an existing skill
|
||||
- Says "find a skill for X" or "is there a skill for X"
|
||||
- Asks "can you do X" where X is a specialized capability
|
||||
- Expresses interest in extending agent capabilities
|
||||
- Wants to search for tools, templates, or workflows
|
||||
- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)
|
||||
|
||||
## What is the Skills CLI?
|
||||
|
||||
The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.
|
||||
|
||||
**Key commands:**
|
||||
|
||||
- `npx skills find [query]` - Search for skills interactively or by keyword
|
||||
- `npx skills add` - Install a skill from GitHub or other sources
|
||||
- `npx skills check` - Check for skill updates
|
||||
- `npx skills update` - Update all installed skills
|
||||
|
||||
**Browse skills at:** <https://skills.sh/>
|
||||
|
||||
## How to Help Users Find Skills
|
||||
|
||||
### Step 1: Understand What They Need
|
||||
|
||||
When a user asks for help with something, identify:
|
||||
|
||||
1. The domain (e.g., React, testing, design, deployment)
|
||||
2. The specific task (e.g., writing tests, creating animations, reviewing PRs)
|
||||
3. Whether this is a common enough task that a skill likely exists
|
||||
|
||||
### Step 2: Search for Skills
|
||||
|
||||
Run the find command with a relevant query:
|
||||
|
||||
```bash
|
||||
npx skills find [query]
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
- User asks "how do I make my React app faster?" → `npx skills find react performance`
|
||||
- User asks "can you help me with PR reviews?" → `npx skills find pr review`
|
||||
- User asks "I need to create a changelog" → `npx skills find changelog`
|
||||
|
||||
### Step 3: Present Recommendations
|
||||
|
||||
When you find relevant skills, present them to the user with:
|
||||
|
||||
1. The skill name and what it does
|
||||
2. The installation command
|
||||
3. A link to the skill's page
|
||||
|
||||
**Example response:**
|
||||
|
||||
> I found a skill that might help!
|
||||
>
|
||||
> **vercel-react-best-practices**
|
||||
> Vercel's official React performance guidelines for AI agents.
|
||||
>
|
||||
> To install it:
|
||||
> `npx skills add vercel-labs/agent-skills@vercel-react-best-practices`
|
||||
>
|
||||
> Learn more: <https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices>
|
||||
|
||||
If the user wants to proceed, you can install the skill for them:
|
||||
|
||||
```bash
|
||||
npx skills add vercel-labs/agent-skills@vercel-react-best-practices
|
||||
```
|
||||
|
||||
### Step 4: Verify Installation (Optional)
|
||||
|
||||
After installing, you can verify it was installed correctly:
|
||||
|
||||
```bash
|
||||
npx skills list
|
||||
```
|
||||
|
||||
## When No Skills Are Found
|
||||
|
||||
1. Try a broader search term
|
||||
2. Check the [skills.sh](https://skills.sh/) website manually if you suspect a network issue
|
||||
3. Suggest the user could create their own skill with `npx skills init`
|
||||
@@ -1,95 +0,0 @@
|
||||
---
|
||||
name: vercel-react-best-practices
|
||||
description: React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.
|
||||
license: MIT
|
||||
metadata:
|
||||
author: vercel
|
||||
version: "1.0.0"
|
||||
---
|
||||
|
||||
# Vercel React Best Practices
|
||||
|
||||
Comprehensive performance optimization guide for React and Next.js applications, maintained by Vercel. Contains 57 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
|
||||
|
||||
## When to Apply
|
||||
|
||||
Reference these guidelines when:
|
||||
|
||||
- Writing new React components or Next.js pages
|
||||
- Implementing data fetching (client or server-side)
|
||||
- Reviewing code for performance issues
|
||||
- Refactoring existing React/Next.js code
|
||||
- Optimizing bundle size or load times
|
||||
|
||||
## Rule Categories by Priority
|
||||
|
||||
| Priority | Category | Impact | Prefix |
|
||||
| -------- | ------------------------- | ----------- | ------------ |
|
||||
| 1 | Eliminating Waterfalls | CRITICAL | `async-` |
|
||||
| 2 | Bundle Size Optimization | CRITICAL | `bundle-` |
|
||||
| 3 | Server-Side Performance | HIGH | `server-` |
|
||||
| 4 | Client-Side Data Fetching | MEDIUM-HIGH | `client-` |
|
||||
| 5 | Re-render Optimization | MEDIUM | `rerender-` |
|
||||
| 6 | Rendering Performance | MEDIUM | `rendering-` |
|
||||
| 7 | JavaScript Performance | LOW-MEDIUM | `js-` |
|
||||
| 8 | Advanced Patterns | LOW | `advanced-` |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### 1. Eliminating Waterfalls (CRITICAL)
|
||||
|
||||
- `async-defer-await` - Move await into branches where actually used
|
||||
- `async-parallel` - Use Promise.all() for independent operations
|
||||
- `async-dependencies` - Use better-all for partial dependencies
|
||||
- `async-api-routes` - Start promises early, await late in API routes
|
||||
- `async-suspense-boundaries` - Use Suspense to stream content
|
||||
|
||||
### 2. Bundle Size Optimization (CRITICAL)
|
||||
|
||||
- `bundle-barrel-imports` - Avoid large barrel files; use direct imports
|
||||
- `bundle-large-libraries` - Optimize heavy deps (e.g. framer-motion, lucide)
|
||||
- `bundle-conditional` - Lazy load conditional components
|
||||
- `bundle-route-split` - Split huge page components
|
||||
- `bundle-dynamic-imports` - Use next/dynamic for heavy client components
|
||||
|
||||
### 3. Server-Side Performance (HIGH)
|
||||
|
||||
- `server-cache-react` - Use React.cache() for per-request deduplication
|
||||
- `server-cache-next` - Use unstable_cache for data coaching
|
||||
- `server-only-utils` - Mark server-only code with 'server-only' package
|
||||
- `server-component-boundaries` - Keep client components at leaves
|
||||
- `server-image-optimization` - Use next/image with proper sizing
|
||||
|
||||
### 4. Client-Side Data Fetching (MEDIUM-HIGH)
|
||||
|
||||
- `client-use-swr` - Use SWR/TanStack Query for client-side data
|
||||
- `client-no-effect-fetch` - Avoid fetching in useEffect without caching libraries
|
||||
- `client-prefetch-link` - Use next/link prefetching
|
||||
- `client-caching-headers` - Respect cache-control headers
|
||||
|
||||
### 5. Re-render Optimization (MEDIUM)
|
||||
|
||||
- `rerender-memo-props` - Memoize complex props
|
||||
- `rerender-dependencies` - Use primitive dependencies in effects
|
||||
- `rerender-functional-setstate` - Use functional setState for stable callbacks
|
||||
- `rerender-context-split` - Split context to avoid wide re-renders
|
||||
|
||||
### 6. Rendering Performance (MEDIUM)
|
||||
|
||||
- `rendering-image-priority` - Priority load LCP images
|
||||
- `rendering-list-virtualization` - Virtualize long lists
|
||||
- `rendering-content-visibility` - Use content-visibility for long lists
|
||||
- `rendering-hoist-jsx` - Extract static JSX outside components
|
||||
- `rendering-hydration-no-flicker` - Use inline script for client-only data
|
||||
|
||||
### 7. JavaScript Performance (LOW-MEDIUM)
|
||||
|
||||
- `js-batch-dom-css` - Group CSS changes
|
||||
- `js-index-maps` - Build Map for repeated lookups
|
||||
- `js-combine-iterations` - Combine multiple filter/map into one loop
|
||||
- `js-set-map-lookups` - Use Set/Map for O(1) lookups
|
||||
|
||||
### 8. Advanced Patterns (LOW)
|
||||
|
||||
- `advanced-event-handler-refs` - Store event handlers in refs
|
||||
- `advanced-init-once` - Initialize app once per app load
|
||||
64
.agents/skills/dev-auto-pipeline/SKILL.md
Normal file
64
.agents/skills/dev-auto-pipeline/SKILL.md
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: dev-auto-pipeline
|
||||
description: 기능 개발/버그 수정/리팩토링 같은 구현 요청에서 계획→구현→리팩토링→테스트→완료체크를 순서대로 실행하는 상위 오케스트레이션 스킬. 단순 설명/문서 요약/잡담에는 사용하지 않는다.
|
||||
---
|
||||
|
||||
# Dev Auto Pipeline
|
||||
|
||||
## 목표
|
||||
|
||||
- 개발 요청을 표준 5단계로 자동 처리한다.
|
||||
- 각 단계 결과를 다음 단계 입력으로 넘겨 누락을 줄인다.
|
||||
|
||||
## 실행 단계 (고정)
|
||||
|
||||
1. `dev-plan-writer`
|
||||
2. `dev-mcp-implementation`
|
||||
3. `dev-refactor-polish`
|
||||
4. `dev-test-gate`
|
||||
5. `dev-plan-completion-checker`
|
||||
|
||||
## 단계 연결 규칙
|
||||
|
||||
1. 계획 단계에서 생성한 계획 문서(`common-docs/improvement/plans/*.md`)를 이후 단계의 기준 문서로 사용한다.
|
||||
2. 구현/리팩토링에서 변경된 파일 목록을 테스트 단계 입력으로 전달한다.
|
||||
3. 테스트 결과를 완료체크 단계 입력으로 전달한다.
|
||||
4. 완료체크는 계획 대비 `완료/부분 완료/미완료`와 최종 판정을 반드시 남긴다.
|
||||
|
||||
## common-docs 기준
|
||||
|
||||
- 사용 문서:
|
||||
- `common-docs/api-reference/openapi_all.xlsx`
|
||||
- `common-docs/api-reference/kis_api_reference.md`
|
||||
- `common-docs/api-reference/kis-error-code-reference.md`
|
||||
- `common-docs/features/trade-stock-sync.md`
|
||||
- `common-docs/ui/GLOBAL_ALERT_SYSTEM.md`
|
||||
- 제외 문서:
|
||||
- `common-docs/features-autotrade-design.md`
|
||||
|
||||
## 최종 보고 형식
|
||||
|
||||
```md
|
||||
[1. 계획]
|
||||
- ...
|
||||
|
||||
[2. 구현]
|
||||
- ...
|
||||
|
||||
[3. 리팩토링/성능/가독성]
|
||||
- ...
|
||||
- 파일 상단 역할 주석 반영 여부
|
||||
- 핵심 입력 데이터 흐름 추적표 포함 여부
|
||||
|
||||
[4. 테스트]
|
||||
- ...
|
||||
|
||||
[5. 계획 대비 완료체크]
|
||||
- 완료/부분 완료/미완료
|
||||
- 최종 판정: 배포 가능/보완 필요
|
||||
|
||||
[6. 핵심 입력 흐름 추적표]
|
||||
- 입력값: (예: 전략 프롬프트)
|
||||
- UI 입력 -> 핸들러 -> 훅/서비스 -> API -> route -> provider -> 결과 반영
|
||||
- 각 단계는 파일/라인 링크 포함
|
||||
```
|
||||
6
.agents/skills/dev-auto-pipeline/agents/openai.yaml
Normal file
6
.agents/skills/dev-auto-pipeline/agents/openai.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "Dev Auto Pipeline"
|
||||
short_description: "Run end-to-end development pipeline"
|
||||
default_prompt: "Use $dev-auto-pipeline to execute plan, implement, refactor, test, and completion checks."
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
123
.agents/skills/dev-mcp-implementation/SKILL.md
Normal file
123
.agents/skills/dev-mcp-implementation/SKILL.md
Normal file
@@ -0,0 +1,123 @@
|
||||
---
|
||||
name: dev-mcp-implementation
|
||||
description: 구현 단계에서 MCP와 기존 스킬을 활용해 근거 기반으로 코드를 작성하는 스킬. 계획 문서가 확정된 뒤 실제 코드 변경이 필요할 때 사용하며, 단순 계획 작성/완료 판정 단계에는 사용하지 않는다.
|
||||
---
|
||||
|
||||
# Dev MCP Implementation
|
||||
|
||||
## 목표
|
||||
|
||||
- 추측 구현을 줄이고 공식 문서/런타임 진단 기반으로 구현한다.
|
||||
- 구현 결과를 나중에 리팩토링/테스트 단계로 넘기기 쉬운 형태로 만든다.
|
||||
|
||||
## 기본 구현 원칙 (AGENTS 반영)
|
||||
|
||||
1. 모든 코드/주석/설명은 한국어 기준으로 작성한다.
|
||||
2. 기술 스택 기준을 지킨다.
|
||||
- Next.js 16 App Router, React 19, TypeScript
|
||||
- Zustand(클라이언트 UI 상태), Supabase, react-hook-form + zod
|
||||
- Tailwind CSS v4, Radix UI
|
||||
3. 사이드이펙트가 예상되면 영향 범위를 먼저 확인하고 구현한다.
|
||||
4. 불필요한 삭제는 하지 않는다. 삭제가 필요하면 영향 검증 후 진행한다.
|
||||
|
||||
## 구현 순서
|
||||
|
||||
1. `dev-plan-writer` 결과를 읽고 구현 범위를 고정한다.
|
||||
2. Next.js 프로젝트면 `next-devtools`로 현재 라우트/에러 상태를 먼저 확인한다.
|
||||
3. 외부 라이브러리 API가 모호하면 `context7`로 공식 문서를 확인한다.
|
||||
4. 복잡한 로직은 `sequential-thinking`으로 엣지 케이스(경계 상황)를 먼저 정리한다.
|
||||
5. DB/권한/SQL 변경은 `supabase-mcp-server`로 안전하게 반영한다.
|
||||
6. 코드 수정 후 최소 동작 확인(`lint`/핵심 UI 실행)을 진행한다.
|
||||
|
||||
## 리팩토링 구현 규칙 (refactoring-rule 반영)
|
||||
|
||||
1. 리팩토링 요청이면 `FEATURE_ROOT` 기준으로 작업한다.
|
||||
2. 아래 기본 구조를 우선 사용한다.
|
||||
- `apis`, `components`, `hooks`, `stores`, `types`
|
||||
3. 필요 시 선택 구조를 사용한다.
|
||||
- `utils`, `lib`, `constants`
|
||||
4. 대형 파일은 책임 단위로 분해하고, 로직은 보존한다.
|
||||
5. `index.ts` 배럴 export 의존을 줄이고 직접 경로 import로 전환한다.
|
||||
6. 파일 이동 후 외부 진입점(`page.tsx` 등) import까지 함께 갱신한다.
|
||||
|
||||
## 필수 적용 스킬
|
||||
|
||||
- `nextjs-app-router-patterns`: Server/Client 경계 검증
|
||||
- `vercel-react-best-practices`: 렌더링/번들/데이터 요청 최적화
|
||||
|
||||
## MCP 활용 맵 (AGENTS 반영)
|
||||
|
||||
- `next-devtools`: Next.js 라우트/컴파일/런타임 오류 점검
|
||||
- `playwright`: 브라우저 상호작용/스모크 검증
|
||||
- `playwriter`: Chrome 확장 기반 상세 디버깅
|
||||
- `context7`: 라이브러리/프레임워크 공식 문서 조회
|
||||
- `supabase-mcp-server`: DB/SQL/함수 작업
|
||||
- `tavily-remote`: 최신 자료/기술 검색
|
||||
- `sequential-thinking`: 복잡 로직 단계화
|
||||
- `figma`: 디자인 파일 레이아웃/스타일/에셋 확인
|
||||
- `mcp:kis-code-assistant-mcp`: 한국투자증권 API 검색/소스 확인
|
||||
|
||||
## 코드/주석 규칙 (문서화 전문가 기준)
|
||||
|
||||
1. 주석 보강 작업은 코드 로직(타입/런타임/동작/변수명/import)을 바꾸지 않는다.
|
||||
2. 주석은 쉬운 한글로 작성하고 "사용처"와 "데이터 흐름"을 먼저 보이게 쓴다.
|
||||
3. 함수/API/Query 주석은 아래 3가지를 중심으로 작성한다.
|
||||
- `[목적]`
|
||||
- `[사용처]`
|
||||
- `[데이터 흐름]`
|
||||
4. 상태(`useState`, `useRef`, store)에는 "값이 바뀌면 화면이 어떻게 변하는지" 한 줄 주석을 단다.
|
||||
5. 복잡한 로직/이벤트 핸들러는 `1, 2, 3...` 단계 주석으로 흐름을 나눈다.
|
||||
6. 긴 JSX는 화면 구역별 주석으로 시각적으로 분리한다.
|
||||
- 예: `{/* ===== 1. 상단: 제목/액션 ===== */}`
|
||||
7. `@param`, `@see`, `@remarks` 같은 딱딱한 TSDoc 태그는 강제하지 않는다.
|
||||
|
||||
## UI/브랜드/문구 규칙
|
||||
|
||||
1. 새 UI는 `indigo/purple/pink` 하드코딩 대신 `brand-*` 토큰을 사용한다.
|
||||
2. 기본 액션 색은 `primary`를 우선한다.
|
||||
3. 색상 톤 변경은 컴포넌트 개별 수정보다 `app/globals.css` 토큰 조정을 우선 검토한다.
|
||||
4. 사용자 문구는 불안을 줄이고 확신을 주는 친근한 톤을 사용한다.
|
||||
|
||||
## common-docs 구현 규칙
|
||||
|
||||
1. KIS API 구현 기준:
|
||||
- `openapi_all.xlsx`를 1순위 스펙으로 본다.
|
||||
- 문서 확인 순서: `openapi_all.xlsx` -> `mcp:kis-code-assistant-mcp` -> `.tmp/open-trading-api` -> `kis_api_reference.md`
|
||||
- 차이가 크면 사용자에게 최신 파일 재확인을 요청한다.
|
||||
2. 에러코드 처리 기준:
|
||||
- `kis-error-code-reference.md`를 따라 `msg_cd + 문구` 형태를 유지한다.
|
||||
- `lib/kis/error-codes.ts`의 `buildKisErrorDetail`/`getKisErrorGuide` 사용 패턴을 유지한다.
|
||||
3. 종목 마스터 데이터 기준:
|
||||
- `features/trade/data/korean-stocks.json`은 수동 편집하지 않는다.
|
||||
- `trade-stock-sync.md` 기준으로 `npm run sync:stocks` / `npm run sync:stocks:check`를 사용한다.
|
||||
4. 전역 알림 UI 기준:
|
||||
- `GLOBAL_ALERT_SYSTEM.md` 기준으로 `useGlobalAlert` 패턴을 우선 사용한다.
|
||||
- 로컬 임시 Alert/Confirm 구현보다 전역 시스템(`GlobalAlertModal`) 연동을 우선한다.
|
||||
5. 제외 문서:
|
||||
- `features-autotrade-design.md`는 현 구현 기준에서 제외한다.
|
||||
|
||||
## 출력 템플릿
|
||||
|
||||
```md
|
||||
[구현 결과]
|
||||
- ...
|
||||
|
||||
[사용한 MCP/Skills]
|
||||
- MCP: ...
|
||||
- Skills: ...
|
||||
|
||||
[변경 파일]
|
||||
- ...
|
||||
|
||||
[핵심 데이터 흐름]
|
||||
- 어느 UI -> A 함수 호출 -> B 함수 호출 -> 리턴값 반영
|
||||
|
||||
[남은 이슈]
|
||||
- ...
|
||||
```
|
||||
|
||||
## 규칙
|
||||
|
||||
- 필요 없는 파일/코드는 남기지 않는다.
|
||||
- 불확실한 라이브러리 API는 문서 근거 없이 단정하지 않는다.
|
||||
- 구현 단계에서 성능에 큰 악영향이 보이면 즉시 메모(기록)하고 다음 단계에서 정리한다.
|
||||
23
.agents/skills/dev-mcp-implementation/agents/openai.yaml
Normal file
23
.agents/skills/dev-mcp-implementation/agents/openai.yaml
Normal file
@@ -0,0 +1,23 @@
|
||||
interface:
|
||||
display_name: "Dev MCP Implementation"
|
||||
short_description: "Implement features with MCP workflows"
|
||||
default_prompt: "Use $dev-mcp-implementation to build code using MCP-first verification."
|
||||
dependencies:
|
||||
tools:
|
||||
- type: "mcp"
|
||||
value: "next-devtools"
|
||||
description: "Next.js route and runtime diagnostics"
|
||||
- type: "mcp"
|
||||
value: "context7"
|
||||
description: "Official framework and library docs"
|
||||
- type: "mcp"
|
||||
value: "supabase-mcp-server"
|
||||
description: "Supabase SQL and function operations"
|
||||
- type: "mcp"
|
||||
value: "playwright"
|
||||
description: "Browser smoke verification"
|
||||
- type: "mcp"
|
||||
value: "kis-code-assistant-mcp"
|
||||
description: "KIS API lookup and source references"
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
58
.agents/skills/dev-plan-completion-checker/SKILL.md
Normal file
58
.agents/skills/dev-plan-completion-checker/SKILL.md
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: dev-plan-completion-checker
|
||||
description: 구현 완료 후 계획 문서와 실제 변경·테스트 근거를 대조해 완료 상태를 판정하는 스킬. 최종 점검 단계에서 사용하며, 계획 작성/구현/테스트 실행 단계를 대신하지 않는다.
|
||||
---
|
||||
|
||||
# Dev Plan Completion Checker
|
||||
|
||||
## 목표
|
||||
|
||||
- 계획대로 구현이 수행됐는지 객관적으로 확인한다.
|
||||
- 누락/부분 완료 항목을 마지막에 명확히 남긴다.
|
||||
|
||||
## 입력
|
||||
|
||||
1. 계획 문서 경로 (`common-docs/improvement/plans/*.md`)
|
||||
2. 변경 파일 목록
|
||||
3. 테스트 결과 (`lint`, `build`, `playwright smoke`, 추가 검증)
|
||||
|
||||
## 작업 순서
|
||||
|
||||
1. 계획 문서의 체크 항목을 읽는다.
|
||||
- 구현 단계 체크박스
|
||||
- 검증 계획 체크박스
|
||||
2. 변경 파일/테스트 결과를 근거로 각 항목 상태를 판정한다.
|
||||
- 완료: 근거가 충분함
|
||||
- 부분 완료: 일부 근거만 있음
|
||||
- 미완료: 근거가 없음
|
||||
3. 누락 항목에 대해 바로 실행 가능한 후속 작업을 작성한다.
|
||||
4. 최종 완료 판정(`배포 가능` / `보완 필요`)을 내린다.
|
||||
|
||||
## 판정 규칙
|
||||
|
||||
1. 구현 단계에 미완료가 1개 이상이면 `보완 필요`
|
||||
2. 검증 계획에 미완료가 있으면 `보완 필요`
|
||||
3. 테스트 생략 항목은 사유와 대체 검증이 있으면 `부분 완료`로 인정 가능
|
||||
|
||||
## 출력 템플릿
|
||||
|
||||
```md
|
||||
[계획 문서]
|
||||
- 경로: ...
|
||||
|
||||
[완료 체크 결과]
|
||||
- 완료: ...
|
||||
- 부분 완료: ...
|
||||
- 미완료: ...
|
||||
|
||||
[근거]
|
||||
- 변경 파일: ...
|
||||
- 테스트 결과: ...
|
||||
|
||||
[보완 필요 항목]
|
||||
1. ...
|
||||
2. ...
|
||||
|
||||
[최종 판정]
|
||||
- 배포 가능/보완 필요
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "Dev Completion Checker"
|
||||
short_description: "Check plan completion against evidence"
|
||||
default_prompt: "Use $dev-plan-completion-checker to compare plan checklists with changed files and test results."
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
153
.agents/skills/dev-plan-writer/SKILL.md
Normal file
153
.agents/skills/dev-plan-writer/SKILL.md
Normal file
@@ -0,0 +1,153 @@
|
||||
---
|
||||
name: dev-plan-writer
|
||||
description: 구현 전에 실행 가능한 계획 문서를 만드는 스킬. 기능 추가/버그 수정/구조 변경 요청에서 범위·영향·작업 순서·검증 기준을 먼저 고정할 때 사용하며, 실제 코드 대량 구현 단계에서는 단독 사용하지 않는다.
|
||||
---
|
||||
|
||||
# Dev Plan Writer
|
||||
|
||||
## 목표
|
||||
|
||||
- 구현 전에 계획부터 확정하여 누락(빠뜨림)을 줄인다.
|
||||
- 주니어 개발자도 바로 따라갈 수 있게 단계를 단순하게 작성한다.
|
||||
|
||||
## 언어/소통 규칙
|
||||
|
||||
1. 모든 계획과 설명을 한국어로 작성한다.
|
||||
2. 어려운 용어는 짧은 괄호 설명을 붙인다.
|
||||
3. 요청이 모호하면 질문 1~3개로 범위를 먼저 고정한다.
|
||||
|
||||
## 프로젝트 기본 컨텍스트
|
||||
|
||||
- 기술 스택: Next.js 16 App Router, React 19, TypeScript, Zustand, Supabase, react-hook-form, zod, Tailwind CSS v4, Radix UI
|
||||
- 기본 명령어: `npm run dev`(포트 3001), `npm run lint`, `npm run build`, `npm run start`
|
||||
|
||||
## 안전 계획 규칙
|
||||
|
||||
1. 수정/추가/삭제 파일을 분리해서 영향 범위를 먼저 적는다.
|
||||
2. 삭제/이동/계약 변경(입출력 규칙 변경)은 사전 확인 질문을 남긴다.
|
||||
3. "진짜 필요 없는 코드만 제거" 원칙으로 계획을 세운다.
|
||||
4. 사이드이펙트(옆 영향) 가능성이 있으면 검증 단계를 계획에 반드시 넣는다.
|
||||
|
||||
## 작업 순서
|
||||
|
||||
1. 요구사항을 3줄 이내로 요약한다.
|
||||
2. 모호한 부분이 있으면 질문 1~3개로 범위를 먼저 고정한다.
|
||||
3. 영향 파일(수정/추가/삭제)을 먼저 찾고, 사이드이펙트(옆 영향)를 표시한다.
|
||||
4. 사용할 MCP/Skills를 단계별로 고른다.
|
||||
5. 구현 단계를 순서대로 작성한다.
|
||||
6. 검증 단계를 구현 단계와 1:1로 매핑한다.
|
||||
|
||||
## 계획 문서 저장 규칙 (필수)
|
||||
|
||||
1. 저장 위치: `common-docs/improvement/plans/`
|
||||
2. 파일명 규칙: `dev-plan-YYYY-MM-DD-<작업슬러그>.md`
|
||||
- 예: `dev-plan-2026-02-25-order-validation.md`
|
||||
3. 하나의 개발 요청은 하나의 계획 파일을 기준으로 끝까지 추적한다.
|
||||
4. 구현이 시작되면 같은 파일에 진행/완료 상태를 계속 갱신한다.
|
||||
|
||||
## 계획 상태 관리 규칙
|
||||
|
||||
1. 구현 단계/검증 계획을 체크박스 형식으로 작성한다.
|
||||
2. 각 체크 항목 옆에 근거(변경 파일, 테스트 결과)를 짧게 남긴다.
|
||||
3. 완료 판단은 마지막에 `dev-plan-completion-checker`가 수행한다.
|
||||
|
||||
## 리팩토링 요청 전용 계획 규칙 (refactoring-rule 반영)
|
||||
|
||||
1. 입력값으로 `FEATURE_ROOT`를 명시한다.
|
||||
2. 목표에 아래 4가지를 반드시 넣는다.
|
||||
- 표준 폴더 구조(`apis/components/hooks/stores/types`)
|
||||
- 선택 폴더 허용(`utils/lib/constants`)
|
||||
- 대형 파일 분해
|
||||
- 배럴 파일 제거 및 직접 import
|
||||
3. 작업 지시는 6단계로 고정해 계획한다.
|
||||
- 분석 -> 구조 설계 -> 이동/생성 -> 경로 수정 -> 청소 -> 진입점 갱신
|
||||
4. 계획 문서에 "권장 파일 구조 트리"를 포함한다.
|
||||
|
||||
## 도구 선택 기준
|
||||
|
||||
- Next.js 런타임/라우트 점검: `next-devtools`
|
||||
- 라이브러리 공식 문서 확인: `context7`
|
||||
- 복잡 로직 분해: `sequential-thinking`
|
||||
- Supabase SQL/함수 작업: `supabase-mcp-server`
|
||||
- 브라우저 동작 검증: `playwright`
|
||||
- Chrome 확장 기반 디버깅: `playwriter`
|
||||
- 최신 기술/레퍼런스 검색: `tavily-remote`
|
||||
- Figma 레이아웃/스타일 확인: `figma`
|
||||
|
||||
## common-docs 계획 반영 규칙
|
||||
|
||||
1. `common-docs` 기준 문서를 계획 단계에서 먼저 지정한다.
|
||||
- `common-docs/api-reference/openapi_all.xlsx`
|
||||
- `common-docs/api-reference/kis_api_reference.md`
|
||||
- `common-docs/api-reference/kis-error-code-reference.md`
|
||||
- `common-docs/features/trade-stock-sync.md`
|
||||
- `common-docs/ui/GLOBAL_ALERT_SYSTEM.md`
|
||||
2. 아래 문서는 계획에서 제외한다.
|
||||
- `common-docs/features-autotrade-design.md` (향후 기획 문서)
|
||||
3. KIS 연동 작업이면 스펙 확인 순서를 계획에 명시한다.
|
||||
- `openapi_all.xlsx` -> `mcp:kis-code-assistant-mcp` -> `.tmp/open-trading-api` -> `kis_api_reference.md`
|
||||
4. 종목 코드/마스터 데이터 변경이면 `trade-stock-sync.md` 기준으로 자동 동기화 명령을 계획에 넣는다.
|
||||
5. 사용자 알림/확인 모달 변경이면 `GLOBAL_ALERT_SYSTEM.md` 기준으로 전역 알림 시스템 유지 계획을 넣는다.
|
||||
|
||||
## 출력 템플릿
|
||||
|
||||
```md
|
||||
[계획 문서 경로]
|
||||
- common-docs/improvement/plans/dev-plan-YYYY-MM-DD-<작업슬러그>.md
|
||||
|
||||
[요구사항 요약]
|
||||
- ...
|
||||
|
||||
[확인 질문(필요 시 1~3개)]
|
||||
- ...
|
||||
|
||||
[가정]
|
||||
- ...
|
||||
|
||||
[영향 범위]
|
||||
- 수정: ...
|
||||
- 추가: ...
|
||||
- 삭제: ...
|
||||
|
||||
[구현 단계]
|
||||
- [ ] 1. ...
|
||||
- [ ] 2. ...
|
||||
- [ ] 3. ...
|
||||
|
||||
[사용할 MCP/Skills]
|
||||
- MCP: ...
|
||||
- Skills: ...
|
||||
|
||||
[참조 문서(common-docs)]
|
||||
- ...
|
||||
|
||||
[주석/문서 반영 계획]
|
||||
- 함수 주석: [목적]/[사용처]/[데이터 흐름]
|
||||
- 상태 주석: 값 변경 시 화면 영향 한 줄 설명
|
||||
- 복잡 로직/핸들러: 1, 2, 3 단계 주석
|
||||
- JSX 구역 주석: 화면 구조가 보이게 분리
|
||||
- TSDoc 딱딱한 태그(`@param`, `@see`, `@remarks`) 강제 없음
|
||||
|
||||
[리팩토링 구조 계획(리팩토링 요청 시)]
|
||||
- FEATURE_ROOT: ...
|
||||
- 목표(표준 구조/선택 구조/대형파일 분해/배럴 제거): ...
|
||||
- Workflow 6단계: ...
|
||||
- 권장 구조 트리: ...
|
||||
|
||||
[리스크/회귀 포인트]
|
||||
- ...
|
||||
|
||||
[검증 계획]
|
||||
- [ ] 1. ...
|
||||
- [ ] 2. ...
|
||||
- [ ] 3. ...
|
||||
|
||||
[진행 로그]
|
||||
- 2026-..-..: ...
|
||||
```
|
||||
|
||||
## 규칙
|
||||
|
||||
- 계획 승인 전에 실제 구현 코드를 대량 작성하지 않는다.
|
||||
- 파일 삭제는 반드시 필요성/대체 경로를 확인한 뒤 진행한다.
|
||||
- 동작 변경과 리팩토링을 섞지 않는다.
|
||||
17
.agents/skills/dev-plan-writer/agents/openai.yaml
Normal file
17
.agents/skills/dev-plan-writer/agents/openai.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
interface:
|
||||
display_name: "Dev Plan Writer"
|
||||
short_description: "Write implementation plans with checks"
|
||||
default_prompt: "Use $dev-plan-writer to create a tracked implementation plan file."
|
||||
dependencies:
|
||||
tools:
|
||||
- type: "mcp"
|
||||
value: "next-devtools"
|
||||
description: "Next.js runtime and route diagnostics"
|
||||
- type: "mcp"
|
||||
value: "context7"
|
||||
description: "Official library documentation lookup"
|
||||
- type: "mcp"
|
||||
value: "sequential-thinking"
|
||||
description: "Step-by-step reasoning for complex planning"
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
212
.agents/skills/dev-refactor-polish/SKILL.md
Normal file
212
.agents/skills/dev-refactor-polish/SKILL.md
Normal file
@@ -0,0 +1,212 @@
|
||||
---
|
||||
name: dev-refactor-polish
|
||||
description: 구현 완료 직후 가독성·데이터 흐름·성능을 다듬는 후처리 리팩토링 스킬. 핵심 동작을 바꾸지 않는 정리 단계에서 사용하며, 신규 기능의 본 구현 단계 대체 용도로는 사용하지 않는다.
|
||||
---
|
||||
|
||||
# Dev Refactor Polish
|
||||
|
||||
## 목표
|
||||
|
||||
- 핵심 비즈니스 동작은 유지하고 코드 품질을 높인다.
|
||||
- 읽기 쉬운 구조와 명확한 데이터 흐름 설명을 만든다.
|
||||
- 사용자 혼란을 줄이는 작은 UX 개선(문구, 버튼 라벨, 피드백 표시)은 허용한다.
|
||||
|
||||
## 리팩토링 목표 (refactoring-rule 반영)
|
||||
|
||||
1. 표준 폴더 구조를 지향한다.
|
||||
- 기본: `apis`, `components`, `hooks`, `stores`, `types`
|
||||
2. 필요 시 보조 폴더를 유연하게 허용한다.
|
||||
- 선택: `utils`, `lib`, `constants`
|
||||
3. 거대한 단일 파일은 기능 단위로 분해한다.
|
||||
4. 배럴 파일(`index.ts` re-export) 의존을 줄이고 직접 import 경로를 사용한다.
|
||||
|
||||
## 리팩토링 기본 원칙
|
||||
|
||||
1. 설명과 주석은 한국어로 쉽게 쓴다. 어려운 용어는 괄호로 짧게 풀어쓴다.
|
||||
2. 사이드이펙트 위험이 있으면 영향 범위를 먼저 확인하고 수정한다.
|
||||
3. 불필요한 코드만 제거하고, 영향 가능성이 있으면 검증 후 반영한다.
|
||||
|
||||
## 리팩토링 순서
|
||||
|
||||
1. 핵심 동작 변경 없이 중복 코드를 줄인다.
|
||||
2. 함수/변수 이름을 역할이 드러나는 이름으로 정리한다.
|
||||
3. 복잡한 JSX는 섹션 주석으로 나눈다.
|
||||
4. 상태/이벤트/복잡 로직에 인라인 주석을 보강한다.
|
||||
5. 함수/API/Query에 쉬운 설명 주석을 보강한다.
|
||||
6. 데이터 흐름을 `어느 UI -> A 함수 호출 -> B 함수 호출 -> 리턴값 반영`으로 명시한다.
|
||||
7. `vercel-react-best-practices` 기준으로 불필요한 리렌더/요청을 줄인다.
|
||||
|
||||
## 작업 지시 (Workflow, refactoring-rule 반영)
|
||||
|
||||
1. 분석: `FEATURE_ROOT` 내부 파일 구조와 외부 import 의존성을 파악한다.
|
||||
2. 구조 설계: 기본/선택 폴더 기준으로 파일 분류 계획을 세운다.
|
||||
3. 이동/생성: 파일을 이동하거나 책임 단위로 분리 생성한다.
|
||||
4. 경로 수정: 이동된 파일에 맞춰 import 경로를 일괄 수정한다.
|
||||
5. 청소: 옛 폴더와 불필요한 `index.ts`를 정리한다.
|
||||
6. 진입점 갱신: `page.tsx` 등 외부 진입 파일 import를 최종 갱신한다.
|
||||
|
||||
## 권장 파일 구조 (Standard Structure)
|
||||
|
||||
```text
|
||||
<FEATURE_ROOT>/
|
||||
├── apis/
|
||||
│ ├── apiError.ts
|
||||
│ ├── <feature>.api.ts
|
||||
│ ├── <feature>Form.adapter.ts
|
||||
│ └── <feature>List.adapter.ts
|
||||
├── hooks/
|
||||
│ ├── queryKeys.ts
|
||||
│ ├── use<Feature>List.ts
|
||||
│ ├── use<Feature>Mutations.ts
|
||||
│ └── use<Feature>Form.ts
|
||||
├── types/
|
||||
│ ├── api.types.ts
|
||||
│ ├── <feature>.types.ts
|
||||
│ └── selectOption.types.ts
|
||||
├── stores/
|
||||
│ └── <feature>Store.ts
|
||||
├── components/
|
||||
│ ├── <Feature>Container.tsx
|
||||
│ └── <Feature>Modal.tsx
|
||||
├── utils/ # Optional
|
||||
│ └── <feature>Utils.ts
|
||||
├── lib/ # Optional
|
||||
│ └── <feature>Lib.ts
|
||||
└── constants/ # Optional
|
||||
└── <feature>.constants.ts
|
||||
```
|
||||
|
||||
## 의존성/리스크 분석 규칙
|
||||
|
||||
1. 파일 이동 전 `sequential-thinking`으로 의존성 지도를 먼저 만든다.
|
||||
2. 최신 구조 기준이 모호하면 `context7`로 공식 문서를 확인한다.
|
||||
|
||||
## common-docs 리팩토링 반영 규칙
|
||||
|
||||
1. KIS 연동 리팩토링 시 아래 기준을 유지한다.
|
||||
- 스펙 기준: `common-docs/api-reference/openapi_all.xlsx`
|
||||
- 보조 확인: `kis_api_reference.md`, `kis-error-code-reference.md`
|
||||
2. 에러 처리 리팩토링 시 `lib/kis/error-codes.ts` 중심 구조(`msg_cd + 문구`)를 깨지 않게 유지한다.
|
||||
3. 종목 데이터 리팩토링 시 `korean-stocks.json`을 수동 편집 대상으로 옮기지 않는다.
|
||||
- 동기화 흐름은 `trade-stock-sync.md` 기준으로 유지한다.
|
||||
4. 알림 UI 리팩토링 시 전역 알림 구조(`useGlobalAlert`, `GlobalAlertModal`)를 우선 유지한다.
|
||||
5. `features-autotrade-design.md`는 리팩토링 기준 문서로 사용하지 않는다.
|
||||
|
||||
## 주석 규칙 (문서화 전문가 기준)
|
||||
|
||||
1. 주석 보강 작업은 코드 로직(타입/런타임/동작/변수명/import)을 바꾸지 않는다.
|
||||
2. 함수/API/쿼리 주석은 `[목적]`, `[사용처]`, `[데이터 흐름]` 중심으로 쉽게 쓴다.
|
||||
3. 상태(`useState`, `useRef`, `useMemo`, store 파생 상태)는 반드시 `[State]`, `[Ref]` 형식으로 역할 주석을 단다.
|
||||
- 예: `// [State] 자동매매 실행 중 여부 (배너/버튼 상태에 사용)`
|
||||
- 예: `// [Ref] 마지막 신호 요청 시각 (요청 과다 방지용)`
|
||||
4. 복잡한 로직/핸들러는 반드시 `[Step 1]`, `[Step 2]`, `[Step 3]` 형식으로 흐름을 나눈다.
|
||||
- 예: `// [Step 1] 입력값 유효성 검증`
|
||||
5. 긴 JSX는 화면 구역 주석으로 나눠서 읽기 쉽게 만든다.
|
||||
- 예: `{/* ========== 1. 상단: 상태/액션 영역 ========== */}`
|
||||
6. 데이터 흐름이 중요한 입력(UI prompt, 검색어, 주문 설정값)은 입력 지점에 "어디 API로 가는지"를 한 줄로 명시한다.
|
||||
- 예: `// [데이터 흐름] textarea -> patchSetupForm -> compile API -> AI provider(OpenAI/CLI)`
|
||||
7. `@param`, `@see`, `@remarks` 같은 딱딱한 TSDoc 태그는 강제하지 않는다.
|
||||
8. 결과 기준은 "주니어가 5분 내 파악 가능한지"로 잡는다.
|
||||
|
||||
### 파일 상단 역할 주석 (필수)
|
||||
|
||||
1. 핵심 파일(`components`, `hooks`, `apis`, `lib`, `route.ts`)은 import 위(또는 `"use client"` 바로 아래)에 파일 역할 주석을 단다.
|
||||
2. 형식은 아래 템플릿을 따른다.
|
||||
|
||||
```ts
|
||||
/**
|
||||
* [파일 역할]
|
||||
* 이 파일이 시스템에서 맡는 역할
|
||||
*
|
||||
* [주요 책임]
|
||||
* - 책임 1
|
||||
* - 책임 2
|
||||
* - 책임 3
|
||||
*/
|
||||
```
|
||||
|
||||
### 흐름 추적 문서화 규칙 (필수)
|
||||
|
||||
1. 사용자가 "이 값이 어디로 가는지"를 물으면 반드시 함수 체인을 파일/라인으로 답한다.
|
||||
2. 형식은 `UI 입력 -> 핸들러 -> 훅/서비스 -> API 클라이언트 -> route -> provider -> 결과 반영` 순서를 유지한다.
|
||||
3. 최종 답변에 최소 1개 이상의 "핵심 입력 흐름 추적표"를 포함한다.
|
||||
4. 라인 표기는 `절대경로:라인` 링크 형식으로 제공한다.
|
||||
|
||||
### 필수 주석 패턴 (컴포넌트/훅)
|
||||
|
||||
1. State/Ref 선언부
|
||||
|
||||
```ts
|
||||
// [State] 자동매매 설정 모달 열림 여부
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
|
||||
// [Ref] 최근 가격 캐시 (신호 생성용)
|
||||
const recentPricesRef = useRef<number[]>([]);
|
||||
```
|
||||
|
||||
2. 핸들러/비즈니스 함수
|
||||
|
||||
```ts
|
||||
const handleStart = async () => {
|
||||
// [Step 1] 필수 입력값 검증
|
||||
// [Step 2] 전략 컴파일/검증 API 호출
|
||||
// [Step 3] 세션 시작 및 UI 상태 갱신
|
||||
};
|
||||
```
|
||||
|
||||
3. JSX 섹션 구분
|
||||
|
||||
```tsx
|
||||
return (
|
||||
<>
|
||||
{/* ========== 1. 상단: 상태 및 액션 ========== */}
|
||||
{/* ========== 2. 본문: 설정 입력 영역 ========== */}
|
||||
{/* ========== 3. 하단: 검증/시작 버튼 영역 ========== */}
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
## UI/브랜드/문구 규칙
|
||||
|
||||
1. UI 수정 시 `brand-*` 토큰과 `primary` 사용 기준을 지킨다.
|
||||
2. 사용자 문구는 불안을 줄이고 확신을 주는 톤으로 개선한다.
|
||||
|
||||
## 품질 체크리스트
|
||||
|
||||
- 핵심 비즈니스 로직 변경이 없는가?
|
||||
- 작은 UX 개선이라도 API 계약(입출력 규칙), 권한, 저장 데이터 구조를 건드리지 않았는가?
|
||||
- 주니어가 5분 안에 흐름을 파악할 수 있는가?
|
||||
- 상태 변경이 화면 어디에 반영되는지 보이는가?
|
||||
- 무거운 계산/렌더가 메모이제이션(재계산 줄이기) 대상인지 검토했는가?
|
||||
|
||||
## 출력 템플릿
|
||||
|
||||
```md
|
||||
[리팩토링 요약]
|
||||
- ...
|
||||
|
||||
[가독성 개선 포인트]
|
||||
- ...
|
||||
|
||||
[작은 UX 개선 포인트]
|
||||
- ...
|
||||
|
||||
[성능 개선 포인트]
|
||||
- ...
|
||||
|
||||
[데이터 흐름 정리]
|
||||
- 어느 UI -> A 함수 호출 -> B 함수 호출 -> 리턴값 반영
|
||||
|
||||
[핵심 입력 흐름 추적표]
|
||||
- 입력값: (예: 전략 프롬프트)
|
||||
- [파일:라인] -> 함수 -> 다음 호출
|
||||
|
||||
[회귀 위험 점검]
|
||||
- ...
|
||||
```
|
||||
|
||||
## 규칙
|
||||
|
||||
- 파일 이동/삭제가 있으면 영향 범위를 먼저 검증한다.
|
||||
- 구조 개선은 하되 과도한 분리(쪼개기)는 피한다.
|
||||
- 작은 UX 개선을 했으면 왜 개선했는지 한 줄 근거를 남긴다.
|
||||
14
.agents/skills/dev-refactor-polish/agents/openai.yaml
Normal file
14
.agents/skills/dev-refactor-polish/agents/openai.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
interface:
|
||||
display_name: "Dev Refactor Polish"
|
||||
short_description: "Refactor code for readability and performance"
|
||||
default_prompt: "Use $dev-refactor-polish to improve readability, data flow, and small UX polish."
|
||||
dependencies:
|
||||
tools:
|
||||
- type: "mcp"
|
||||
value: "context7"
|
||||
description: "Official docs for framework-safe refactors"
|
||||
- type: "mcp"
|
||||
value: "sequential-thinking"
|
||||
description: "Dependency impact reasoning before file moves"
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
91
.agents/skills/dev-test-gate/SKILL.md
Normal file
91
.agents/skills/dev-test-gate/SKILL.md
Normal file
@@ -0,0 +1,91 @@
|
||||
---
|
||||
name: dev-test-gate
|
||||
description: 개발/리팩토링 후 lint·build·Playwright 스모크 테스트를 실행하고 실패 원인을 정리하는 검증 스킬. 최종 품질 게이트 단계에서 사용하며, 구현 자체를 대체하지 않는다.
|
||||
---
|
||||
|
||||
# Dev Test Gate
|
||||
|
||||
## 목표
|
||||
|
||||
- 변경 사항의 안정성을 빠르게 확인한다.
|
||||
- 실패 원인과 영향 범위를 짧고 명확하게 남긴다.
|
||||
|
||||
## 공통 기준
|
||||
|
||||
1. 결과 보고는 한국어로 작성한다.
|
||||
2. 테스트 결과는 주니어도 이해 가능하게 쉬운 말로 정리한다.
|
||||
3. 테스트 생략은 원칙적으로 금지하고, 불가한 경우 사유와 대체 검증을 남긴다.
|
||||
|
||||
## 테스트 순서
|
||||
|
||||
1. 정적 검사: `npm run lint`
|
||||
2. 빌드 검사: `npm run build`
|
||||
3. 개발 서버 실행: `npm run dev` (기본 포트 3001)
|
||||
4. 런타임 확인: 핵심 화면 로드와 기본 동작 확인
|
||||
5. Playwright 스모크 테스트(기본): 핵심 화면 간단 확인을 반드시 수행
|
||||
6. 사용자 요청 테스트가 있으면 해당 테스트를 추가 실행한다.
|
||||
|
||||
## Playwright 스모크 기본 규칙
|
||||
|
||||
1. 핵심 화면 3종을 기본 대상으로 잡는다.
|
||||
2. 화면 타입은 아래 기준으로 고른다.
|
||||
- 서비스 진입 화면 1개
|
||||
- 핵심 기능 화면 1개
|
||||
- 설정/인증 관련 화면 1개
|
||||
3. 각 화면에서 최소 항목을 확인한다.
|
||||
- 페이지 로드 성공
|
||||
- 치명 오류 문구/콘솔 에러 없음
|
||||
- 핵심 버튼 또는 입력 요소 1개 이상 상호작용 가능
|
||||
|
||||
## 검증 보강 규칙
|
||||
|
||||
1. UI 변경이 있으면 브랜드 토큰(`brand-*`, `primary`) 적용 여부를 함께 점검한다.
|
||||
2. KIS API 연동 변경이 있으면 계좌/인증/오류 처리 기본 시나리오를 스모크 범위에 포함한다.
|
||||
3. 리팩토링 요청이면 구조 점검을 추가한다.
|
||||
- `FEATURE_ROOT`가 목표 구조(`apis/components/hooks/stores/types`)를 따르는지 확인
|
||||
- 파일 이동 후 진입점 import 경로가 깨지지 않았는지 확인
|
||||
- 불필요한 `index.ts` 배럴 파일 잔존 여부를 확인
|
||||
|
||||
## common-docs 연계 검증 규칙
|
||||
|
||||
1. KIS 연동 파일 변경 시 아래를 점검한다.
|
||||
- `kis_api_reference.md` 기준 엔드포인트/흐름이 크게 어긋나지 않는지 확인
|
||||
- `kis-error-code-reference.md` 기준 `msg_cd + 문구` 표시 흐름 유지 확인
|
||||
2. `features/trade/data/korean-stocks.json` 또는 동기화 스크립트 변경 시
|
||||
- `npm run sync:stocks:check`를 추가 실행한다.
|
||||
3. 전역 알림 관련 파일(`features/layout/hooks/use-global-alert.ts`, `GlobalAlertModal`) 변경 시
|
||||
- 핵심 시나리오(성공 알림 1건, 확인 모달 1건)를 스모크 검증에 포함한다.
|
||||
4. `features-autotrade-design.md`는 테스트 기준 문서에서 제외한다.
|
||||
|
||||
## 실패 처리 규칙
|
||||
|
||||
1. 실패 로그에서 직접 원인 라인을 먼저 찾는다.
|
||||
2. 원인 수정 후 같은 테스트를 재실행한다.
|
||||
3. 연쇄 실패(한 수정으로 여러 실패)가 있으면 우선순위를 나눠 정리한다.
|
||||
4. 시간/환경 제한으로 테스트를 못 돌리면 이유와 대체 검증을 반드시 기록한다.
|
||||
|
||||
## 출력 템플릿
|
||||
|
||||
```md
|
||||
[테스트 결과]
|
||||
- lint: 통과/실패
|
||||
- build: 통과/실패
|
||||
- playwright smoke: 통과/실패
|
||||
- common-docs 연계 검증: 통과/실패
|
||||
- 추가 테스트: ...
|
||||
|
||||
[실패 및 조치]
|
||||
- ...
|
||||
|
||||
[최종 상태]
|
||||
- 배포 가능/보류
|
||||
```
|
||||
|
||||
## 완료체크 인계 규칙
|
||||
|
||||
1. 테스트 결과는 `dev-plan-completion-checker`에 그대로 전달한다.
|
||||
2. 전달 형식은 아래 4줄을 포함한다.
|
||||
- lint 결과
|
||||
- build 결과
|
||||
- playwright smoke 결과
|
||||
- 생략/실패 사유 및 대체 검증
|
||||
14
.agents/skills/dev-test-gate/agents/openai.yaml
Normal file
14
.agents/skills/dev-test-gate/agents/openai.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
interface:
|
||||
display_name: "Dev Test Gate"
|
||||
short_description: "Run lint, build, and Playwright smoke tests"
|
||||
default_prompt: "Use $dev-test-gate to run lint, build, and smoke verification before completion."
|
||||
dependencies:
|
||||
tools:
|
||||
- type: "mcp"
|
||||
value: "playwright"
|
||||
description: "Browser smoke test automation"
|
||||
- type: "mcp"
|
||||
value: "next-devtools"
|
||||
description: "Next.js runtime error and route checks"
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
14
.agents/skills/nextjs-app-router-patterns/agents/openai.yaml
Normal file
14
.agents/skills/nextjs-app-router-patterns/agents/openai.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
interface:
|
||||
display_name: "Next.js App Router Patterns"
|
||||
short_description: "Next.js App Router patterns and checks"
|
||||
default_prompt: "Use $nextjs-app-router-patterns to review App Router structure, server/client boundaries, and data fetching patterns."
|
||||
dependencies:
|
||||
tools:
|
||||
- type: "mcp"
|
||||
value: "next-devtools"
|
||||
description: "Next.js runtime route and error diagnostics"
|
||||
- type: "mcp"
|
||||
value: "context7"
|
||||
description: "Official Next.js documentation lookup"
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
45
.env.example
45
.env.example
@@ -8,16 +8,35 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
# 세션 타임아웃(분 단위)
|
||||
NEXT_PUBLIC_SESSION_TIMEOUT_MINUTES=30
|
||||
|
||||
# KIS 거래 모드: real(실전) | mock(모의)
|
||||
KIS_TRADING_ENV=real
|
||||
|
||||
# 서버 기본 키를 쓰고 싶은 경우(선택)
|
||||
KIS_APP_KEY_REAL=
|
||||
KIS_APP_SECRET_REAL=
|
||||
KIS_BASE_URL_REAL=https://openapi.koreainvestment.com:9443
|
||||
KIS_WS_URL_REAL=ws://ops.koreainvestment.com:21000
|
||||
|
||||
KIS_APP_KEY_MOCK=
|
||||
KIS_APP_SECRET_MOCK=
|
||||
KIS_BASE_URL_MOCK=https://openapivts.koreainvestment.com:29443
|
||||
KIS_WS_URL_MOCK=ws://ops.koreainvestment.com:31000
|
||||
# 자동매매/AI 설정
|
||||
OPENAI_API_KEY=
|
||||
AUTOTRADE_AI_MODEL=gpt-4o-mini
|
||||
# auto | openai_api | subscription_cli | rule_fallback
|
||||
AUTOTRADE_AI_MODE=auto
|
||||
# subscription_cli 모드에서 사용할 CLI 선택값(auto | gemini | codex)
|
||||
AUTOTRADE_SUBSCRIPTION_CLI=auto
|
||||
# subscription_cli 공통 모델(옵션): vendor 전용 설정이 없을 때 fallback으로 사용
|
||||
AUTOTRADE_SUBSCRIPTION_CLI_MODEL=
|
||||
# Codex CLI 전용 모델(옵션): 예) gpt-5-codex
|
||||
AUTOTRADE_CODEX_MODEL=
|
||||
# Gemini CLI 전용 모델(옵션): 예) auto | pro | flash | flash-lite | gemini-2.5-pro
|
||||
AUTOTRADE_GEMINI_MODEL=
|
||||
# subscription_cli 호출 타임아웃(ms)
|
||||
AUTOTRADE_SUBSCRIPTION_CLI_TIMEOUT_MS=60000
|
||||
# subscription_cli 디버그 로그(1/true/on): Next 서버 콘솔에 CLI 호출/시도 로그 출력
|
||||
AUTOTRADE_SUBSCRIPTION_CLI_DEBUG=0
|
||||
# Codex CLI 실행 파일 경로(옵션): PATH 인식 문제 시 절대경로 지정
|
||||
AUTOTRADE_CODEX_COMMAND=
|
||||
# Gemini CLI 실행 파일 경로(옵션): PATH 인식 문제 시 절대경로 지정
|
||||
AUTOTRADE_GEMINI_COMMAND=
|
||||
AUTOTRADE_HEARTBEAT_TTL_SEC=90
|
||||
AUTOTRADE_MAX_DAILY_ORDERS_DEFAULT=20
|
||||
AUTOTRADE_CONFIDENCE_THRESHOLD_DEFAULT=0.65
|
||||
AUTOTRADE_DEV_BYPASS_TOKEN=autotrade-dev-bypass
|
||||
# 워커 인증 토큰: 직접 랜덤 문자열 생성해서 앱/워커에 동일하게 넣어 주세요.
|
||||
# 예) openssl rand -hex 32
|
||||
AUTOTRADE_WORKER_TOKEN=autotrade-worker-local
|
||||
# 워커 점검 주기(ms)
|
||||
AUTOTRADE_WORKER_POLL_MS=5000
|
||||
# 워커가 호출할 Next.js 앱 주소
|
||||
AUTOTRADE_APP_URL=http://127.0.0.1:3001
|
||||
|
||||
6
.gemini/settings.json
Normal file
6
.gemini/settings.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"tools": {
|
||||
"approvalMode": "auto_edit",
|
||||
"allowed": ["run_shell_command"]
|
||||
}
|
||||
}
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -119,8 +119,14 @@ storybook-static/
|
||||
*.local
|
||||
.cache/
|
||||
node_modules
|
||||
.tmp/
|
||||
|
||||
# ========================================
|
||||
# Custom
|
||||
# ========================================
|
||||
.playwright-mcp/
|
||||
|
||||
# ========================================
|
||||
# Documentation (문서)
|
||||
# ========================================
|
||||
docs/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"real:7777b1c958636e65539aadf6c4273a0dfa33c17e55d1beb7dbfd033d49457f6e":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIsImF1ZCI6ImFkMGFjN2Y3LWY5YTYtNDRlNy1iOGRjLWU0ZjRhY2Q0YmQ2NyIsInByZHRfY2QiOiIiLCJpc3MiOiJ1bm9ndyIsImV4cCI6MTc3MDcwNzkzMiwiaWF0IjoxNzcwNjIxNTMyLCJqdGkiOiJQUzA2TG12SndjRHl1MDZLVXpPRjVsSHJacWR6ZWJKTXhCVWIifQ.P1_T4XoIxPDyNIxS3rx73IqlNLpZRmKKXOtan74A7D19On9JlsIQIpTY8bVGPFfRxFkC0vfCZ1qDX-xpxGi6SA","expiresAt":1770707932000}}
|
||||
Submodule .tmp/open-trading-api deleted from aea5e779da
58
AGENTS.md
58
AGENTS.md
@@ -1,45 +1,25 @@
|
||||
# AGENTS.md (auto-trade)
|
||||
# AGENTS.md (auto-trade)
|
||||
|
||||
## 기본 원칙
|
||||
- 모든 응답과 설명은 한국어로 작성.
|
||||
- 쉬운 말로 설명. 어려운 용어는 괄호로 짧게 뜻을 덧붙임.
|
||||
- 요청이 모호하면 먼저 질문 1~3개로 범위를 확인.
|
||||
## 운영 원칙
|
||||
|
||||
## 프로젝트 요약
|
||||
- Next.js 16 App Router, React 19, TypeScript
|
||||
- 상태 관리: zustand
|
||||
- 데이터: Supabase
|
||||
- 폼 및 검증: react-hook-form, zod
|
||||
- UI: Tailwind CSS v4, Radix UI (components.json 사용)
|
||||
- 중복 규칙은 `AGENTS.md`에 두지 않고 각 스킬(`.agents/skills/*/SKILL.md`)에서 관리한다.
|
||||
- 개발 작업은 스킬 기반으로 수행한다.
|
||||
|
||||
## 명령어
|
||||
- 개발 서버: (포트는 3001번이야)
|
||||
pm run dev
|
||||
- 린트:
|
||||
pm run lint
|
||||
- 빌드:
|
||||
pm run build
|
||||
- 실행:
|
||||
pm run start
|
||||
## 스킬 호출 규칙
|
||||
|
||||
## 코드 및 문서 규칙
|
||||
- JSX 섹션 주석 형식: {/* ========== SECTION NAME ========== */}
|
||||
- 함수 및 컴포넌트 JSDoc에 @see 필수 (호출 파일, 함수 또는 이벤트 이름, 목적 포함)
|
||||
- 상태 정의, 이벤트 핸들러, 복잡한 JSX 로직에는 인라인 주석을 충분히 작성
|
||||
- 개발 요청 키워드(`개발해줘`, `구현해줘`, `기능 추가`, `버그 수정`, `리팩토링`, `개선해줘`, `고쳐줘`, `최적화해줘`, `만들어줘`)가 들어오면 `dev-auto-pipeline` 스킬을 우선 사용한다.
|
||||
- 파이프라인 단계 스킬은 아래 순서로 사용한다.
|
||||
1. `dev-plan-writer`
|
||||
2. `dev-mcp-implementation`
|
||||
3. `dev-refactor-polish`
|
||||
4. `dev-test-gate`
|
||||
5. `dev-plan-completion-checker`
|
||||
- 단순 설명/문서 요약/잡담 요청에는 파이프라인 스킬을 강제하지 않는다.
|
||||
|
||||
## 브랜드 색상 규칙
|
||||
- 메인 컬러는 헤더 로고/프로필 기준의 보라 계열 `brand` 팔레트를 사용.
|
||||
- 새 UI 작성 시 `indigo/purple/pink` 하드코딩 대신 `brand-*` 토큰 사용. 예: `bg-brand-500`, `text-brand-600`, `from-brand-500 to-brand-700`.
|
||||
- 기본 액션 색(버튼/포커스)은 `primary`를 사용하고, `primary`는 `app/globals.css`의 `brand` 팔레트와 같은 톤으로 유지.
|
||||
- 색상 변경이 필요하면 컴포넌트 개별 수정보다 먼저 `app/globals.css` 토큰을 수정.
|
||||
## 설명 방식 규칙
|
||||
|
||||
## 설명 방식
|
||||
- 단계별로 짧게, 예시는 1개만.
|
||||
- 사용자가 요청한 변경과 이유를 함께 설명.
|
||||
- 파일 경로는 pp/...처럼 코드 형식으로 표기.
|
||||
|
||||
## 여러 도구를 함께 쓸 때 (쉬운 설명)
|
||||
- 기준 설명을 한 군데에 모아두고, 그 파일만 계속 업데이트하는 것이 핵심.
|
||||
- 예를 들어 PROJECT_CONTEXT.md에 스택, 폴더 구조, 규칙을 적어둔다.
|
||||
- 그리고 각 도구의 규칙 파일에 PROJECT_CONTEXT.md를 참고라고 써 둔다.
|
||||
- 이렇게 하면 어떤 도구를 쓰든 같은 파일을 읽게 되어, 매번 다시 설명할 일이 줄어든다.
|
||||
- 사용자 설명은 어려운 용어보다 쉬운 한국어를 우선 사용한다.
|
||||
- 기술 용어를 써야 할 때는 바로 아래 줄에 쉬운 말로 다시 풀어쓴다.
|
||||
- 데이터 흐름 설명은 항상 `입력 -> 처리 -> 결과` 순서의 짧은 단계로 말한다.
|
||||
- 사용자가 헷갈린 상황에서는 추상 설명보다 "지금 화면에서 확인할 것"을 먼저 안내한다.
|
||||
- 요청/응답 설명 시에는 핵심 필드 3~5개만 먼저 보여주고, 필요 시 상세를 추가한다.
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
# PROJECT_CONTEXT.md
|
||||
|
||||
이 파일은 프로젝트 설명의 기준(원본)입니다.
|
||||
여기만 업데이트하고, 다른 도구 규칙에서는 이 파일을 참고하도록 연결하세요.
|
||||
|
||||
## 한 줄 요약
|
||||
- 자동매매(오토 트레이드) 웹 앱
|
||||
|
||||
## 기술 스택
|
||||
- Next.js 16 (App Router)
|
||||
- React 19, TypeScript
|
||||
- 상태 관리: zustand
|
||||
- 데이터: Supabase
|
||||
- 폼/검증: react-hook-form, zod
|
||||
- UI: Tailwind CSS v4, Radix UI
|
||||
|
||||
## 폴더 구조(핵심만)
|
||||
- pp/ 라우팅 및 페이지
|
||||
- eatures/ 도메인별 기능
|
||||
- components/ 공용 UI
|
||||
- lib/ 유틸/클라이언트
|
||||
- utils/ 헬퍼
|
||||
|
||||
## 주요 규칙(요약)
|
||||
- JSX 섹션 주석 형식: {/* ========== SECTION NAME ========== */}
|
||||
- 함수/컴포넌트 JSDoc에 @see 필수
|
||||
- 파일 상단에 @author jihoon87.lee
|
||||
- 상태/이벤트/복잡 로직에 인라인 주석 충분히 작성
|
||||
|
||||
## 작업 흐름
|
||||
- 개발 서버:
|
||||
pm run dev
|
||||
- 린트:
|
||||
pm run lint
|
||||
- 빌드:
|
||||
pm run build
|
||||
- 실행:
|
||||
pm run start
|
||||
|
||||
## 자주 하는 설명 템플릿
|
||||
- 변경 이유: (왜 바꾸는지)
|
||||
- 변경 내용: (무엇을 바꾸는지)
|
||||
- 영향 범위: (어디에 영향이 있는지)
|
||||
|
||||
## 업데이트 가이드
|
||||
- 새 규칙/패턴이 생기면 여기에 먼저 추가
|
||||
- 문장이 길어지면 더 짧게 요약
|
||||
- 도구별 규칙 파일에는 PROJECT_CONTEXT.md 참고만 적기
|
||||
164
README.md
164
README.md
@@ -1,36 +1,160 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# auto-trade
|
||||
|
||||
## Getting Started
|
||||
한국투자증권(KIS) Open API와 Supabase 인증을 연결한 국내주식 트레이딩 대시보드입니다.
|
||||
사용자는 로그인 후 API 키를 검증하고, 종목 검색/상세/차트/호가/체결/주문 화면을 한 곳에서 사용할 수 있습니다.
|
||||
|
||||
First, run the development server:
|
||||
## 1) 핵심 기능
|
||||
|
||||
- 인증: Supabase 이메일/소셜 로그인, 비밀번호 재설정, 세션 타임아웃 자동 로그아웃
|
||||
- KIS 연결: 실전/모의 모드 선택, API 키 검증, 웹소켓 승인키 발급
|
||||
- 트레이드 화면: 종목 검색, 종목 개요, 캔들 차트, 실시간 호가/체결, 현금 주문
|
||||
- 종목 검색 인덱스: `korean-stocks.json` 기반 고속 검색 + 자동 갱신 스크립트
|
||||
|
||||
## 2) 기술 스택
|
||||
|
||||
- 프레임워크: Next.js 16 (App Router), React 19, TypeScript
|
||||
- 상태관리: Zustand
|
||||
- 서버 상태: TanStack Query (React Query)
|
||||
- 인증/백엔드: Supabase (`@supabase/ssr`, `@supabase/supabase-js`)
|
||||
- UI: Tailwind CSS v4, Radix UI, Sonner
|
||||
- 차트: `lightweight-charts`
|
||||
|
||||
## 3) 화면/라우트
|
||||
|
||||
- `/`: 서비스 랜딩 페이지
|
||||
- `/login`, `/signup`, `/forgot-password`, `/reset-password`: 인증 페이지
|
||||
- `/dashboard`: 로그인 전용(현재 플레이스홀더)
|
||||
- `/settings`: KIS API 키 연결/해제
|
||||
- `/trade`: 실제 트레이딩 대시보드
|
||||
|
||||
## 4) UI 흐름 (중요)
|
||||
|
||||
- 인증 흐름: 로그인 UI -> `features/auth/actions.ts` -> Supabase Auth -> 세션 쿠키 반영 -> 보호 라우트 접근 허용
|
||||
- KIS 연결 흐름: 설정 UI -> `/api/kis/validate` -> 토큰 발급 성공 확인 -> `use-kis-runtime-store`에 검증 상태 저장
|
||||
- 실시간 흐름: 트레이드 UI -> `/api/kis/ws/approval` -> `useKisTradeWebSocket` 구독 -> 체결/호가 파싱 -> 차트/호가창 반영
|
||||
- 검색 흐름: 검색 UI -> `/api/kis/domestic/search` -> `korean-stocks.json` 메모리 검색 -> 결과 목록 반영
|
||||
|
||||
## 5) 빠른 시작
|
||||
|
||||
### 5-1. 요구 사항
|
||||
|
||||
- Node.js 20 이상
|
||||
- npm 10 이상 권장
|
||||
|
||||
### 5-2. 설치
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 5-3. 환경변수 설정
|
||||
|
||||
`.env.example`을 복사해서 `.env.local`을 만듭니다.
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
Copy-Item .env.example .env.local
|
||||
```
|
||||
|
||||
필수 값은 아래를 먼저 채우면 됩니다.
|
||||
|
||||
- `NEXT_PUBLIC_SUPABASE_URL`
|
||||
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||
|
||||
KIS는 `.env`에 저장하지 않고 `/settings` 입력 폼에서 직접 입력합니다.
|
||||
|
||||
- 앱키/시크릿/계좌번호는 사용자 브라우저에서만 관리
|
||||
- 서버 API는 로그인 세션 + 요청 헤더로 전달된 키가 있어야 동작
|
||||
- `NEXT_PUBLIC_BASE_URL` (선택, 배포 도메인 사용 시 권장)
|
||||
|
||||
### 5-4. 로컬 실행
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
- 개발 서버: `http://localhost:3001`
|
||||
- Turbopack 적용: `package.json`의 `dev` 스크립트에 `--turbopack` 포함
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
### 5-5. 점검 명령
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
```bash
|
||||
npm run lint
|
||||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
## Learn More
|
||||
## 6) 종목 인덱스 동기화
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
`features/trade/data/korean-stocks.json`은 수동 편집용 파일이 아닙니다.
|
||||
KIS 마스터 파일(KOSPI/KOSDAQ)에서 자동 생성합니다.
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
```bash
|
||||
npm run sync:stocks
|
||||
```
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
검증만 하고 싶으면:
|
||||
|
||||
## Deploy on Vercel
|
||||
```bash
|
||||
npm run sync:stocks:check
|
||||
```
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
상세 문서: `docs/trade-stock-sync.md`
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
## 7) API 엔드포인트 요약
|
||||
|
||||
- 인증/연결
|
||||
- `POST /api/kis/validate`: API 키 검증
|
||||
- `POST /api/kis/revoke`: 토큰 폐기
|
||||
- `POST /api/kis/ws/approval`: 웹소켓 승인키 발급
|
||||
|
||||
- 국내주식
|
||||
- `GET /api/kis/domestic/search?q=...`: 종목 검색(로컬 인덱스)
|
||||
- `GET /api/kis/domestic/overview?symbol=...`: 종목 개요
|
||||
- `GET /api/kis/domestic/orderbook?symbol=...`: 호가
|
||||
- `GET /api/kis/domestic/chart?symbol=...&timeframe=...`: 차트
|
||||
- `POST /api/kis/domestic/order-cash`: 현금 주문
|
||||
|
||||
## 8) 프로젝트 구조
|
||||
|
||||
```text
|
||||
app/
|
||||
(home)/ 랜딩
|
||||
(auth)/ 로그인/회원가입/비밀번호 재설정
|
||||
(main)/ 로그인 후 화면(dashboard/trade/settings)
|
||||
api/kis/ KIS 연동 API 라우트
|
||||
features/
|
||||
auth/ 인증 UI/액션/상수
|
||||
settings/ KIS 키 설정 UI + 런타임 스토어
|
||||
trade/ 검색/차트/호가/주문/웹소켓
|
||||
lib/kis/ KIS REST/WS 공통 로직
|
||||
scripts/
|
||||
sync-korean-stocks.mjs
|
||||
utils/supabase/ 서버/클라이언트/미들웨어 클라이언트
|
||||
```
|
||||
|
||||
## 9) 트러블슈팅
|
||||
|
||||
- KIS 검증 실패
|
||||
- 입력한 앱키/시크릿, 실전/모의 모드가 맞는지 확인
|
||||
- KIS Open API 앱 권한과 IP 허용 설정 확인
|
||||
|
||||
- 실시간 체결/호가가 안 들어옴
|
||||
- `/settings`에서 검증 상태가 유지되는지 확인
|
||||
- 장 구간(장중/동시호가/시간외)에 따라 데이터가 달라질 수 있음
|
||||
- 브라우저 콘솔 디버그가 필요하면 `localStorage.KIS_WS_DEBUG = "1"` 설정
|
||||
|
||||
- 검색 결과가 기대와 다름
|
||||
- 검색은 KIS 실시간 검색 API가 아니라 로컬 인덱스(`korean-stocks.json`) 기반
|
||||
- 최신 종목 반영이 필요하면 `npm run sync:stocks` 실행
|
||||
|
||||
## 10) 운영 주의사항
|
||||
|
||||
- 현재 KIS 입력값과 검증 상태 일부는 브라우저 로컬 스토리지(localStorage, 브라우저 저장소)에 저장됩니다.
|
||||
- 실전 운영 시에는 민감정보 보관 정책(암호화, 만료, 서버 보관 방식)을 반드시 따로 설계하세요.
|
||||
- 주문은 계좌번호가 필요합니다. 계좌번호 입력/검증 UX는 운영 환경에 맞게 보완이 필요합니다.
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import { AUTH_ROUTES } from "@/features/auth/constants";
|
||||
import { Mail } from "lucide-react";
|
||||
|
||||
/**
|
||||
* [비밀번호 찾기 페이지]
|
||||
@@ -31,10 +32,10 @@ export default async function ForgotPasswordPage({
|
||||
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
{message && <FormMessage message={message} />}
|
||||
|
||||
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
||||
<Card className="border-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/70">
|
||||
<CardHeader className="space-y-3 text-center">
|
||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
||||
<span className="text-sm font-semibold">MAIL</span>
|
||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
|
||||
<Mail className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||
비밀번호 재설정
|
||||
@@ -59,13 +60,13 @@ export default async function ForgotPasswordPage({
|
||||
placeholder="name@example.com"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="h-11 transition-all duration-200"
|
||||
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
formAction={requestPasswordReset}
|
||||
className="h-11 w-full bg-linear-to-r from-gray-900 to-black font-semibold text-white shadow-lg transition-all hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white"
|
||||
className="h-11 w-full bg-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
|
||||
>
|
||||
재설정 링크 보내기
|
||||
</Button>
|
||||
@@ -74,7 +75,7 @@ export default async function ForgotPasswordPage({
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href={AUTH_ROUTES.LOGIN}
|
||||
className="text-sm font-medium text-gray-700 hover:text-black dark:text-gray-300 dark:hover:text-white"
|
||||
className="text-sm font-medium text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||
>
|
||||
로그인 페이지로 돌아가기
|
||||
</Link>
|
||||
|
||||
@@ -12,17 +12,18 @@ export default async function AuthLayout({
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-linear-to-br from-gray-50 via-white to-gray-100 dark:from-black dark:via-gray-950 dark:to-gray-900">
|
||||
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-linear-to-br from-brand-50 via-white to-brand-100/60 dark:from-brand-950 dark:via-gray-950 dark:to-brand-900/40">
|
||||
{/* ========== 헤더 (홈 이동용) ========== */}
|
||||
<Header user={user} />
|
||||
|
||||
{/* ========== 배경 그라디언트 레이어 ========== */}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,var(--tw-gradient-stops))] from-gray-200/30 via-gray-100/15 to-transparent dark:from-gray-800/30 dark:via-gray-900/20" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,var(--tw-gradient-stops))] from-gray-300/30 via-gray-200/15 to-transparent dark:from-gray-700/30 dark:via-gray-800/20" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,var(--tw-gradient-stops))] from-brand-200/40 via-brand-100/20 to-transparent dark:from-brand-800/25 dark:via-brand-900/15" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,var(--tw-gradient-stops))] from-brand-300/30 via-brand-200/15 to-transparent dark:from-brand-700/20 dark:via-brand-800/10" />
|
||||
|
||||
{/* ========== 애니메이션 블러 효과 ========== */}
|
||||
<div className="absolute left-1/4 top-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-300/20 blur-3xl dark:bg-gray-700/20" />
|
||||
<div className="absolute bottom-1/4 right-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-400/20 blur-3xl delay-700 dark:bg-gray-600/20" />
|
||||
<div className="absolute left-1/4 top-1/4 h-72 w-72 animate-pulse rounded-full bg-brand-300/25 blur-3xl dark:bg-brand-700/15" />
|
||||
<div className="absolute bottom-1/4 right-1/4 h-72 w-72 animate-pulse rounded-full bg-brand-400/20 blur-3xl delay-700 dark:bg-brand-600/15" />
|
||||
<div className="absolute right-1/3 top-1/3 h-48 w-48 animate-pulse rounded-full bg-brand-200/30 blur-2xl delay-1000 dark:bg-brand-800/20" />
|
||||
|
||||
{/* ========== 메인 콘텐츠 영역 (중앙 정렬) ========== */}
|
||||
<main className="z-10 flex w-full flex-1 flex-col items-center justify-center px-4 py-12">
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import LoginForm from "@/features/auth/components/login-form";
|
||||
import { LogIn } from "lucide-react";
|
||||
|
||||
/**
|
||||
* [로그인 페이지 컴포넌트]
|
||||
*
|
||||
* Modern UI with glassmorphism effect (유리 형태 디자인)
|
||||
* - 투명 배경 + 블러 효과로 깊이감 표현
|
||||
* - 그라디언트 배경으로 생동감 추가
|
||||
* 브랜드 컬러 기반 글래스모피즘 카드 디자인
|
||||
* - 보라색 그라디언트 아이콘 배지
|
||||
* - shadcn/ui 컴포넌트로 일관된 디자인 시스템 유지
|
||||
*
|
||||
* @param searchParams - URL 쿼리 파라미터 (에러 메시지 전달용)
|
||||
@@ -23,36 +23,25 @@ export default async function LoginPage({
|
||||
}: {
|
||||
searchParams: Promise<{ message: string }>;
|
||||
}) {
|
||||
// URL에서 메시지 파라미터 추출 (로그인 실패 시 에러 메시지 표시)
|
||||
const { message } = await searchParams;
|
||||
|
||||
return (
|
||||
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
{/* 에러/성공 메시지 표시 영역 */}
|
||||
{/* URL 파라미터에 message가 있으면 표시됨 */}
|
||||
<FormMessage message={message} />
|
||||
|
||||
{/* ========== 로그인 카드 (Glassmorphism) ========== */}
|
||||
{/* bg-white/70: 70% 투명도의 흰색 배경 */}
|
||||
{/* backdrop-blur-xl: 배경 블러 효과 (유리 느낌) */}
|
||||
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
||||
{/* ========== 카드 헤더 영역 ========== */}
|
||||
<Card className="border-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/70">
|
||||
<CardHeader className="space-y-3 text-center">
|
||||
{/* 아이콘 배경: 그라디언트 원형 */}
|
||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
||||
<span className="text-4xl">👋</span>
|
||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
|
||||
<LogIn className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
{/* 페이지 제목 */}
|
||||
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||
환영합니다!
|
||||
</CardTitle>
|
||||
{/* 페이지 설명 */}
|
||||
<CardDescription className="text-base">
|
||||
서비스 이용을 위해 로그인해 주세요.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
{/* ========== 카드 콘텐츠 영역 (폼) ========== */}
|
||||
<CardContent>
|
||||
<LoginForm />
|
||||
</CardContent>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { KeyRound } from "lucide-react";
|
||||
|
||||
/**
|
||||
* [비밀번호 재설정 페이지]
|
||||
@@ -39,10 +40,10 @@ export default async function ResetPasswordPage({
|
||||
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
{message && <FormMessage message={message} />}
|
||||
|
||||
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
||||
<Card className="border-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/70">
|
||||
<CardHeader className="space-y-3 text-center">
|
||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
||||
<span className="text-sm font-semibold">PW</span>
|
||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
|
||||
<KeyRound className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||
비밀번호 재설정
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { UserPlus } from "lucide-react";
|
||||
|
||||
export default async function SignupPage({
|
||||
searchParams,
|
||||
@@ -19,13 +20,12 @@ export default async function SignupPage({
|
||||
|
||||
return (
|
||||
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
{/* 메시지 알림 */}
|
||||
<FormMessage message={message} />
|
||||
|
||||
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
||||
<Card className="border-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/70">
|
||||
<CardHeader className="space-y-3 text-center">
|
||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
||||
<span className="text-4xl">🚀</span>
|
||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
|
||||
<UserPlus className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||
회원가입
|
||||
@@ -35,16 +35,14 @@ export default async function SignupPage({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
{/* ========== 폼 영역 ========== */}
|
||||
<CardContent className="space-y-6">
|
||||
<SignupForm />
|
||||
|
||||
{/* ========== 로그인 링크 ========== */}
|
||||
<p className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
이미 계정이 있으신가요?{" "}
|
||||
<Link
|
||||
href={AUTH_ROUTES.LOGIN}
|
||||
className="font-semibold text-gray-900 transition-colors hover:text-black dark:text-gray-100 dark:hover:text-white"
|
||||
className="font-semibold text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||
>
|
||||
로그인 하러 가기
|
||||
</Link>
|
||||
|
||||
@@ -4,163 +4,209 @@
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, BarChart3, ShieldCheck, Sparkles, Zap } from "lucide-react";
|
||||
import { ArrowRight, Sparkles } from "lucide-react";
|
||||
import { Header } from "@/features/layout/components/header";
|
||||
import { AUTH_ROUTES } from "@/features/auth/constants";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import ShaderBackground from "@/components/ui/shader-background";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { AnimatedBrandTone } from "@/components/ui/animated-brand-tone";
|
||||
|
||||
interface StartStep {
|
||||
step: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const START_STEPS: StartStep[] = [
|
||||
{
|
||||
step: "01",
|
||||
title: "앱키 연결, 1분이면 끝",
|
||||
description:
|
||||
"복잡한 절차 없이, 지금 쓰는 계좌로 바로 시작할 수 있어요.",
|
||||
},
|
||||
{
|
||||
step: "02",
|
||||
title: "투자금/손실선만 입력하세요",
|
||||
description:
|
||||
"어렵게 계산할 필요 없이, 내가 감당 가능한 금액만 정하면 돼요.",
|
||||
},
|
||||
{
|
||||
step: "03",
|
||||
title: "신호 확인 후 자동 실행",
|
||||
description:
|
||||
"차트 감시는 JOORIN-E가 맡고, 당신은 중요한 순간만 확인하면 됩니다.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 홈 메인 랜딩 페이지
|
||||
* @returns 랜딩 UI
|
||||
* @see features/layout/components/header.tsx blendWithBackground 모드 헤더를 함께 사용
|
||||
*/
|
||||
export default async function HomePage() {
|
||||
// [로그인 상태 조회] 사용자 유무에 따라 CTA 링크를 분기합니다.
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
const primaryCtaHref = user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.SIGNUP;
|
||||
const primaryCtaLabel = user ? "내 전략 시작하기" : "무료로 시작하기";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col overflow-x-hidden bg-transparent">
|
||||
<div className="flex min-h-screen flex-col overflow-x-hidden bg-black text-white selection:bg-brand-500/30">
|
||||
<Header user={user} showDashboardLink={true} blendWithBackground={true} />
|
||||
|
||||
<main className="relative isolate flex-1 pt-16">
|
||||
{/* ========== SHADER BACKGROUND SECTION ========== */}
|
||||
<ShaderBackground opacity={1} className="-z-20" />
|
||||
<main className="relative isolate flex-1">
|
||||
{/* ========== BACKGROUND ========== */}
|
||||
<ShaderBackground opacity={0.6} className="-z-20" />
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 -z-10 bg-black/60"
|
||||
/>
|
||||
|
||||
{/* ========== HERO SECTION ========== */}
|
||||
<section className="container mx-auto max-w-7xl px-4 pb-10 pt-16 md:pt-24">
|
||||
<div className="p-2 md:p-6">
|
||||
<div className="mx-auto max-w-4xl text-center">
|
||||
<span className="inline-flex items-center gap-2 rounded-full px-4 py-1.5 text-xs font-semibold text-brand-200 [text-shadow:0_2px_24px_rgba(0,0,0,0.65)]">
|
||||
<section className="container mx-auto max-w-5xl px-4 pt-32 md:pt-48">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<span className="inline-flex animate-in fade-in-0 slide-in-from-top-2 items-center gap-2 rounded-full border border-brand-400/30 bg-white/5 px-4 py-1.5 text-xs font-medium tracking-wide text-brand-200 backdrop-blur-md duration-1000">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Shader Background Landing
|
||||
처음 하는 자동매매도 쉽게, JOORIN-E
|
||||
</span>
|
||||
|
||||
<h1 className="mt-5 text-4xl font-black tracking-tight text-white [text-shadow:0_4px_30px_rgba(0,0,0,0.6)] md:text-6xl">
|
||||
데이터로 판단하고
|
||||
<h1 className="mt-8 animate-in slide-in-from-bottom-4 text-5xl font-black tracking-tight text-white duration-1000 md:text-8xl">
|
||||
복잡한 차트 대신
|
||||
<br />
|
||||
<span className="bg-linear-to-r from-brand-300 via-brand-400 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>
|
||||
</h1>
|
||||
|
||||
<p className="mx-auto mt-5 max-w-2xl text-sm leading-relaxed text-white/80 [text-shadow:0_2px_16px_rgba(0,0,0,0.5)] md:text-lg">
|
||||
실시간 시세 확인, 전략 점검, 주문 연결까지 한 화면에서 이어지는 자동매매 환경을
|
||||
제공합니다. 복잡한 설정은 줄이고 실행 속도는 높였습니다.
|
||||
<p className="mt-8 max-w-2xl animate-in slide-in-from-bottom-4 text-sm leading-relaxed text-white/60 duration-1000 md:text-xl">
|
||||
감으로 사고파는 불안한 투자, 이제 줄여보세요.
|
||||
<br className="hidden md:block" />
|
||||
예산과 손실선을 먼저 지키는 방식으로, 주식을 더 편하게 도와드립니다.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
||||
{/* [분기 렌더] 로그인 사용자는 대시보드, 비로그인 사용자는 가입/로그인 동선을 노출합니다. */}
|
||||
{user ? (
|
||||
<Button asChild size="lg" className="h-12 rounded-full bg-primary px-8 text-base hover:bg-primary/90">
|
||||
<Link href={AUTH_ROUTES.DASHBOARD}>
|
||||
대시보드 바로가기
|
||||
<ArrowRight className="ml-1.5 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button asChild size="lg" className="h-12 rounded-full bg-primary px-8 text-base hover:bg-primary/90">
|
||||
<Link href={AUTH_ROUTES.SIGNUP}>
|
||||
무료로 시작하기
|
||||
<ArrowRight className="ml-1.5 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="mt-12 flex animate-in slide-in-from-bottom-6 flex-col gap-4 duration-1000 sm:flex-row">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="h-12 rounded-full border-white/40 bg-transparent px-8 text-base text-white hover:bg-white/10 hover:text-white"
|
||||
className="group h-14 min-w-[200px] rounded-full bg-brand-500 px-10 text-lg font-bold text-white transition-all hover:scale-105 hover:bg-brand-400 active:scale-95"
|
||||
>
|
||||
<Link href={AUTH_ROUTES.LOGIN}>로그인</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-2xl p-4 text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.35)]">
|
||||
<p className="text-xs text-white/70">지연 시간 기준</p>
|
||||
<p className="mt-1 text-lg font-bold">Low Latency</p>
|
||||
</div>
|
||||
<div className="rounded-2xl p-4 text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.35)]">
|
||||
<p className="text-xs text-white/70">모니터링</p>
|
||||
<p className="mt-1 text-lg font-bold">실시간 시세 반영</p>
|
||||
</div>
|
||||
<div className="rounded-2xl p-4 text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.35)]">
|
||||
<p className="text-xs text-white/70">실행 환경</p>
|
||||
<p className="mt-1 text-lg font-bold">웹 기반 자동매매</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== FEATURE SECTION ========== */}
|
||||
<section className="container mx-auto max-w-7xl px-4 pb-16 md:pb-24">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="border-0 bg-transparent text-white shadow-none">
|
||||
<CardHeader>
|
||||
<div className="mb-2 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-brand-400/20 text-brand-200">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
</div>
|
||||
<CardTitle className="text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.4)]">실시간 데이터 가시화</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm leading-relaxed text-white/75">
|
||||
시세 변화와 거래 흐름을 빠르게 확인할 수 있게 핵심 정보만 선별해 보여줍니다.
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 bg-transparent text-white shadow-none">
|
||||
<CardHeader>
|
||||
<div className="mb-2 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-brand-400/20 text-brand-200">
|
||||
<Zap className="h-5 w-5" />
|
||||
</div>
|
||||
<CardTitle className="text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.4)]">전략 실행 속도 최적화</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm leading-relaxed text-white/75">
|
||||
필요한 단계만 남긴 단순한 흐름으로 전략 테스트와 실행 전환 시간을 줄였습니다.
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 bg-transparent text-white shadow-none">
|
||||
<CardHeader>
|
||||
<div className="mb-2 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-brand-400/20 text-brand-200">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
</div>
|
||||
<CardTitle className="text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.4)]">명확한 리스크 관리</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm leading-relaxed text-white/75">
|
||||
자동매매에서 중요한 손실 한도와 조건을 먼저 정의하고 일관되게 적용할 수 있습니다.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== CTA SECTION ========== */}
|
||||
<section className="container mx-auto max-w-7xl px-4 pb-20">
|
||||
<div className="p-2 md:p-4">
|
||||
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-brand-200 [text-shadow:0_2px_18px_rgba(0,0,0,0.45)]">준비되면 바로 시작하세요</p>
|
||||
<h2 className="mt-1 text-2xl font-bold tracking-tight text-white [text-shadow:0_2px_18px_rgba(0,0,0,0.45)] md:text-3xl">
|
||||
AutoTrade에서 전략을 실행해 보세요
|
||||
</h2>
|
||||
</div>
|
||||
<Button asChild className="h-11 rounded-full bg-primary px-7 hover:bg-primary/90">
|
||||
<Link href={user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.SIGNUP}>
|
||||
{user ? "대시보드 열기" : "회원가입 시작"}
|
||||
<ArrowRight className="ml-1.5 h-4 w-4" />
|
||||
<Link href={primaryCtaHref}>
|
||||
{primaryCtaLabel}
|
||||
<ArrowRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== BRAND TONE SECTION (움직이는 글자) ========== */}
|
||||
<section className="container mx-auto max-w-5xl px-4 py-24 md:py-40">
|
||||
<AnimatedBrandTone />
|
||||
</section>
|
||||
|
||||
{/* ========== SIMPLE STEPS SECTION ========== */}
|
||||
<section className="container mx-auto max-w-5xl px-4 py-24">
|
||||
<div className="flex flex-col items-center gap-16 md:flex-row md:items-start">
|
||||
<div className="flex-1 text-center md:text-left">
|
||||
<h2 className="text-3xl font-black md:text-5xl">
|
||||
주식이 처음이어도
|
||||
<br />
|
||||
<span className="text-brand-300">3단계면 준비 끝.</span>
|
||||
</h2>
|
||||
<p className="mt-6 text-sm leading-relaxed text-white/50 md:text-lg">
|
||||
앱키 연결 -> 투자금/손실선 설정 -> 시작 버튼.
|
||||
<br />
|
||||
어려운 용어 없이, 필요한 것만 빠르게 설정해보세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-2 grid w-full gap-4 md:grid-cols-1">
|
||||
{START_STEPS.map((item) => (
|
||||
<div
|
||||
key={item.step}
|
||||
className="group flex items-center gap-6 rounded-2xl border border-white/5 bg-white/5 p-6 transition-all hover:bg-white/10"
|
||||
>
|
||||
<span className="text-3xl font-black text-brand-500/50 group-hover:text-brand-500">
|
||||
{item.step}
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 보안 안심 문구 (사용자 요청 반영) */}
|
||||
<div className="mt-16 flex flex-col items-center justify-center text-center animate-in slide-in-from-bottom-6 duration-1000 delay-300">
|
||||
<div className="flex max-w-2xl flex-col items-center gap-4 rounded-2xl border border-brand-500/20 bg-brand-500/5 p-8 backdrop-blur-sm md:flex-row md:gap-8 md:text-left">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-brand-500/10 text-brand-400">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="lucide lucide-shield-check"
|
||||
>
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-brand-100">
|
||||
계좌 키/정보, 어디에 저장되나요?
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-brand-200/70">
|
||||
<strong className="text-brand-200">
|
||||
핵심 정보는 내 브라우저에만 저장됩니다.
|
||||
</strong>
|
||||
<br />
|
||||
JOORIN-E는 계좌 비밀번호를 저장하지 않으며,
|
||||
<br className="hidden md:block" />
|
||||
API 키도 장기 보관하지 않도록 최소 범위로만 사용합니다.
|
||||
<br className="hidden md:block" />
|
||||
매매 요청은 필요한 순간에만 증권사와 통신합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== FINAL CTA SECTION ========== */}
|
||||
<section className="container mx-auto max-w-5xl px-4 py-32">
|
||||
<div className="relative overflow-hidden rounded-[2.5rem] border border-brand-500/20 bg-linear-to-b from-brand-500/10 to-transparent p-12 text-center md:p-24">
|
||||
<h2 className="text-3xl font-black md:text-6xl">
|
||||
감으로 매매하던 습관에서
|
||||
<br />
|
||||
오늘부터 규칙 매매로 바꿔보세요.
|
||||
</h2>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="h-16 rounded-full bg-white px-12 text-xl font-black text-black transition-all hover:scale-110 active:scale-95"
|
||||
>
|
||||
<Link href={primaryCtaHref}>{primaryCtaLabel}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-sm text-white/30">
|
||||
© 2026 POPUP STUDIO. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
*/
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { DashboardContainer } from "@/features/dashboard/components/DashboardContainer";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
|
||||
/**
|
||||
* 대시보드 페이지
|
||||
* @returns DashboardContainer UI
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 클라이언트 상호작용(검색/시세/차트)은 해당 컴포넌트가 담당합니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 대시보드 상태 헤더/지수/보유종목 UI를 제공합니다.
|
||||
*/
|
||||
export default async function DashboardPage() {
|
||||
// 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Header } from "@/features/layout/components/header";
|
||||
import { Sidebar } from "@/features/layout/components/sidebar";
|
||||
import { MobileBottomNav, Sidebar } from "@/features/layout/components/sidebar";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
|
||||
export default async function MainLayout({
|
||||
@@ -13,12 +13,13 @@ export default async function MainLayout({
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-zinc-50 dark:bg-black">
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<Header user={user} />
|
||||
<div className="flex flex-1 pt-16">
|
||||
<Sidebar />
|
||||
<main className="flex-1 w-full p-6 md:p-8 lg:p-10">{children}</main>
|
||||
<main className="min-w-0 flex-1 pb-20 md:pb-0">{children}</main>
|
||||
</div>
|
||||
<MobileBottomNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
26
app/(main)/settings/page.tsx
Normal file
26
app/(main)/settings/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @file app/(main)/settings/page.tsx
|
||||
* @description 로그인 사용자 전용 설정 페이지(Server Component)
|
||||
*/
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { SettingsContainer } from "@/features/settings/components/SettingsContainer";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
|
||||
/**
|
||||
* 설정 페이지
|
||||
* @returns SettingsContainer UI
|
||||
* @see features/settings/components/SettingsContainer.tsx KIS 인증 설정 UI를 제공합니다.
|
||||
*/
|
||||
export default async function SettingsPage() {
|
||||
// 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) redirect("/login");
|
||||
|
||||
return <SettingsContainer />;
|
||||
}
|
||||
|
||||
26
app/(main)/trade/page.tsx
Normal file
26
app/(main)/trade/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @file app/(main)/trade/page.tsx
|
||||
* @description 로그인 사용자 전용 트레이딩 페이지(Server Component)
|
||||
*/
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { TradeContainer } from "@/features/trade/components/TradeContainer";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
|
||||
/**
|
||||
* 트레이딩 페이지
|
||||
* @returns TradeContainer UI
|
||||
* @see features/trade/components/TradeContainer.tsx 종목 검색/차트/호가/주문 기능을 제공합니다.
|
||||
*/
|
||||
export default async function TradePage() {
|
||||
// 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) redirect("/login");
|
||||
|
||||
return <TradeContainer />;
|
||||
}
|
||||
|
||||
227
app/api/autotrade/_shared.ts
Normal file
227
app/api/autotrade/_shared.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { hasKisConfig } from "@/lib/kis/config";
|
||||
import {
|
||||
readKisAccountParts,
|
||||
readKisCredentialsFromHeaders,
|
||||
} from "@/app/api/kis/domestic/_shared";
|
||||
import type {
|
||||
AutotradeSessionInfo,
|
||||
AutotradeStopReason,
|
||||
} from "@/features/autotrade/types/autotrade.types";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
|
||||
export const AUTOTRADE_DEV_BYPASS_HEADER = "x-autotrade-dev-bypass";
|
||||
export const AUTOTRADE_WORKER_TOKEN_HEADER = "x-autotrade-worker-token";
|
||||
|
||||
export const AUTOTRADE_API_ERROR_CODE = {
|
||||
AUTH_REQUIRED: "AUTOTRADE_AUTH_REQUIRED",
|
||||
INVALID_REQUEST: "AUTOTRADE_INVALID_REQUEST",
|
||||
CREDENTIAL_REQUIRED: "AUTOTRADE_CREDENTIAL_REQUIRED",
|
||||
SESSION_NOT_FOUND: "AUTOTRADE_SESSION_NOT_FOUND",
|
||||
CONFLICT: "AUTOTRADE_CONFLICT",
|
||||
INTERNAL: "AUTOTRADE_INTERNAL",
|
||||
} as const;
|
||||
|
||||
export type AutotradeApiErrorCode =
|
||||
(typeof AUTOTRADE_API_ERROR_CODE)[keyof typeof AUTOTRADE_API_ERROR_CODE];
|
||||
|
||||
export interface AutotradeSessionRecord extends AutotradeSessionInfo {
|
||||
userId: string;
|
||||
strategySummary: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __autotradeSessionMap: Map<string, AutotradeSessionRecord> | undefined;
|
||||
}
|
||||
|
||||
function getSessionMap() {
|
||||
if (!globalThis.__autotradeSessionMap) {
|
||||
globalThis.__autotradeSessionMap = new Map<string, AutotradeSessionRecord>();
|
||||
}
|
||||
|
||||
return globalThis.__autotradeSessionMap;
|
||||
}
|
||||
|
||||
|
||||
export function createAutotradeErrorResponse(options: {
|
||||
status: number;
|
||||
code: AutotradeApiErrorCode;
|
||||
message: string;
|
||||
extra?: Record<string, unknown>;
|
||||
}) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
errorCode: options.code,
|
||||
message: options.message,
|
||||
...(options.extra ?? {}),
|
||||
},
|
||||
{ status: options.status },
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAutotradeUserId(headers?: Headers) {
|
||||
if (isAutotradeDevBypass(headers)) {
|
||||
return "dev-autotrade-user";
|
||||
}
|
||||
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (error || !user) return null;
|
||||
return user.id;
|
||||
}
|
||||
|
||||
export async function readJsonBody(request: Request) {
|
||||
const text = await request.text();
|
||||
if (!text.trim()) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasAutotradeKisRuntimeHeaders(headers: Headers) {
|
||||
if (isAutotradeDevBypass(headers)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const credentials = readKisCredentialsFromHeaders(headers);
|
||||
const account = readKisAccountParts(headers);
|
||||
|
||||
return Boolean(hasKisConfig(credentials) && account);
|
||||
}
|
||||
|
||||
export function upsertAutotradeSession(record: AutotradeSessionRecord) {
|
||||
const map = getSessionMap();
|
||||
map.set(record.userId, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
export function getAutotradeSession(userId: string) {
|
||||
const map = getSessionMap();
|
||||
const record = map.get(userId) ?? null;
|
||||
if (!record) return null;
|
||||
|
||||
if (record.runtimeState === "RUNNING" && isHeartbeatExpired(record.lastHeartbeatAt)) {
|
||||
const stoppedRecord = {
|
||||
...record,
|
||||
runtimeState: "STOPPED" as const,
|
||||
stopReason: "heartbeat_timeout" as const,
|
||||
endedAt: new Date().toISOString(),
|
||||
};
|
||||
map.set(userId, stoppedRecord);
|
||||
return stoppedRecord;
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
export function listAutotradeSessions() {
|
||||
return Array.from(getSessionMap().values()).sort((a, b) =>
|
||||
b.startedAt.localeCompare(a.startedAt),
|
||||
);
|
||||
}
|
||||
|
||||
export function stopAutotradeSession(userId: string, reason: AutotradeStopReason) {
|
||||
const map = getSessionMap();
|
||||
const record = map.get(userId);
|
||||
if (!record) return null;
|
||||
|
||||
const stoppedRecord: AutotradeSessionRecord = {
|
||||
...record,
|
||||
runtimeState: "STOPPED",
|
||||
stopReason: reason,
|
||||
endedAt: new Date().toISOString(),
|
||||
lastHeartbeatAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
map.set(userId, stoppedRecord);
|
||||
return stoppedRecord;
|
||||
}
|
||||
|
||||
export function sweepExpiredAutotradeSessions() {
|
||||
const map = getSessionMap();
|
||||
let expiredCount = 0;
|
||||
|
||||
for (const [userId, record] of map.entries()) {
|
||||
if (record.runtimeState !== "RUNNING") continue;
|
||||
if (!isHeartbeatExpired(record.lastHeartbeatAt)) continue;
|
||||
|
||||
const stoppedRecord: AutotradeSessionRecord = {
|
||||
...record,
|
||||
runtimeState: "STOPPED",
|
||||
stopReason: "heartbeat_timeout",
|
||||
endedAt: new Date().toISOString(),
|
||||
lastHeartbeatAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
map.set(userId, stoppedRecord);
|
||||
expiredCount += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
totalSessionCount: map.size,
|
||||
expiredCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAutotradeHeartbeatTtlSec() {
|
||||
const parsed = Number.parseInt(process.env.AUTOTRADE_HEARTBEAT_TTL_SEC ?? "90", 10);
|
||||
if (!Number.isFinite(parsed)) return 90;
|
||||
return Math.min(300, Math.max(30, parsed));
|
||||
}
|
||||
|
||||
export function isHeartbeatExpired(lastHeartbeatAt: string) {
|
||||
const lastHeartbeatMs = new Date(lastHeartbeatAt).getTime();
|
||||
if (!Number.isFinite(lastHeartbeatMs)) return true;
|
||||
|
||||
return Date.now() - lastHeartbeatMs > getAutotradeHeartbeatTtlSec() * 1000;
|
||||
}
|
||||
|
||||
export function sanitizeAutotradeError(error: unknown, fallback: string) {
|
||||
const message = error instanceof Error ? error.message : fallback;
|
||||
return maskSensitiveTokens(message) || fallback;
|
||||
}
|
||||
|
||||
export function maskSensitiveTokens(value: string) {
|
||||
return value
|
||||
.replace(/([A-Za-z0-9]{4})[A-Za-z0-9]{8,}([A-Za-z0-9]{4})/g, "$1********$2")
|
||||
.replace(/(x-kis-app-secret\s*[:=]\s*)([^\s]+)/gi, "$1********")
|
||||
.replace(/(x-kis-app-key\s*[:=]\s*)([^\s]+)/gi, "$1********");
|
||||
}
|
||||
|
||||
export function isAutotradeWorkerAuthorized(headers: Headers) {
|
||||
const providedToken = headers.get(AUTOTRADE_WORKER_TOKEN_HEADER)?.trim();
|
||||
if (!providedToken) return false;
|
||||
|
||||
const expectedToken = process.env.AUTOTRADE_WORKER_TOKEN?.trim();
|
||||
if (expectedToken) {
|
||||
return providedToken === expectedToken;
|
||||
}
|
||||
|
||||
// 운영 환경에서는 토큰 미설정 상태를 허용하지 않습니다.
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return providedToken === "autotrade-worker-local";
|
||||
}
|
||||
|
||||
function isAutotradeDevBypass(headers?: Headers) {
|
||||
if (!headers || process.env.NODE_ENV === "production") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const providedToken = headers.get(AUTOTRADE_DEV_BYPASS_HEADER)?.trim();
|
||||
if (!providedToken) return false;
|
||||
|
||||
const expectedToken =
|
||||
process.env.AUTOTRADE_DEV_BYPASS_TOKEN?.trim() || "autotrade-dev-bypass";
|
||||
return providedToken === expectedToken;
|
||||
}
|
||||
25
app/api/autotrade/sessions/active/route.ts
Normal file
25
app/api/autotrade/sessions/active/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
AUTOTRADE_API_ERROR_CODE,
|
||||
createAutotradeErrorResponse,
|
||||
getAutotradeSession,
|
||||
getAutotradeUserId,
|
||||
} from "@/app/api/autotrade/_shared";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const userId = await getAutotradeUserId(request.headers);
|
||||
if (!userId) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 401,
|
||||
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const session = getAutotradeSession(userId);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
session: session && session.runtimeState === "RUNNING" ? session : null,
|
||||
});
|
||||
}
|
||||
73
app/api/autotrade/sessions/heartbeat/route.ts
Normal file
73
app/api/autotrade/sessions/heartbeat/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AUTOTRADE_API_ERROR_CODE,
|
||||
createAutotradeErrorResponse,
|
||||
getAutotradeSession,
|
||||
getAutotradeUserId,
|
||||
readJsonBody,
|
||||
sanitizeAutotradeError,
|
||||
upsertAutotradeSession,
|
||||
} from "@/app/api/autotrade/_shared";
|
||||
|
||||
const heartbeatRequestSchema = z.object({
|
||||
sessionId: z.string().uuid(),
|
||||
leaderTabId: z.string().trim().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const userId = await getAutotradeUserId(request.headers);
|
||||
if (!userId) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 401,
|
||||
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const rawBody = await readJsonBody(request);
|
||||
const parsed = heartbeatRequestSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 400,
|
||||
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: parsed.error.issues[0]?.message ?? "heartbeat 요청값이 올바르지 않습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const session = getAutotradeSession(userId);
|
||||
if (!session || session.runtimeState !== "RUNNING") {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 404,
|
||||
code: AUTOTRADE_API_ERROR_CODE.SESSION_NOT_FOUND,
|
||||
message: "실행 중인 자동매매 세션이 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (session.sessionId !== parsed.data.sessionId) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 409,
|
||||
code: AUTOTRADE_API_ERROR_CODE.CONFLICT,
|
||||
message: "세션 식별자가 일치하지 않습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const updated = upsertAutotradeSession({
|
||||
...session,
|
||||
lastHeartbeatAt: new Date().toISOString(),
|
||||
leaderTabId: parsed.data.leaderTabId ?? session.leaderTabId,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
session: updated,
|
||||
});
|
||||
} catch (error) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 500,
|
||||
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
|
||||
message: sanitizeAutotradeError(error, "heartbeat 처리 중 오류가 발생했습니다."),
|
||||
});
|
||||
}
|
||||
}
|
||||
77
app/api/autotrade/sessions/start/route.ts
Normal file
77
app/api/autotrade/sessions/start/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AUTOTRADE_API_ERROR_CODE,
|
||||
createAutotradeErrorResponse,
|
||||
getAutotradeUserId,
|
||||
hasAutotradeKisRuntimeHeaders,
|
||||
readJsonBody,
|
||||
sanitizeAutotradeError,
|
||||
upsertAutotradeSession,
|
||||
} from "@/app/api/autotrade/_shared";
|
||||
|
||||
const startRequestSchema = z.object({
|
||||
symbol: z.string().trim().regex(/^\d{6}$/),
|
||||
leaderTabId: z.string().trim().min(1).max(100),
|
||||
effectiveAllocationAmount: z.number().int().positive(),
|
||||
effectiveDailyLossLimit: z.number().int().positive(),
|
||||
strategySummary: z.string().trim().min(1).max(320),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const userId = await getAutotradeUserId(request.headers);
|
||||
if (!userId) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 401,
|
||||
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasAutotradeKisRuntimeHeaders(request.headers)) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 400,
|
||||
code: AUTOTRADE_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||
message: "자동매매 시작에는 KIS 인증 헤더가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const rawBody = await readJsonBody(request);
|
||||
const parsed = startRequestSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 400,
|
||||
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: parsed.error.issues[0]?.message ?? "세션 시작 입력값이 올바르지 않습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const session = upsertAutotradeSession({
|
||||
userId,
|
||||
sessionId: crypto.randomUUID(),
|
||||
symbol: parsed.data.symbol,
|
||||
runtimeState: "RUNNING",
|
||||
leaderTabId: parsed.data.leaderTabId,
|
||||
startedAt: now,
|
||||
lastHeartbeatAt: now,
|
||||
endedAt: null,
|
||||
stopReason: null,
|
||||
effectiveAllocationAmount: parsed.data.effectiveAllocationAmount,
|
||||
effectiveDailyLossLimit: parsed.data.effectiveDailyLossLimit,
|
||||
strategySummary: parsed.data.strategySummary,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
session,
|
||||
});
|
||||
} catch (error) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 500,
|
||||
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
|
||||
message: sanitizeAutotradeError(error, "자동매매 세션 시작 중 오류가 발생했습니다."),
|
||||
});
|
||||
}
|
||||
}
|
||||
78
app/api/autotrade/sessions/stop/route.ts
Normal file
78
app/api/autotrade/sessions/stop/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AUTOTRADE_API_ERROR_CODE,
|
||||
createAutotradeErrorResponse,
|
||||
getAutotradeSession,
|
||||
getAutotradeUserId,
|
||||
readJsonBody,
|
||||
sanitizeAutotradeError,
|
||||
stopAutotradeSession,
|
||||
} from "@/app/api/autotrade/_shared";
|
||||
import type { AutotradeStopReason } from "@/features/autotrade/types/autotrade.types";
|
||||
|
||||
const stopRequestSchema = z.object({
|
||||
sessionId: z.string().uuid().optional(),
|
||||
reason: z
|
||||
.enum([
|
||||
"browser_exit",
|
||||
"external_leave",
|
||||
"manual",
|
||||
"emergency",
|
||||
"heartbeat_timeout",
|
||||
])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const userId = await getAutotradeUserId(request.headers);
|
||||
if (!userId) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 401,
|
||||
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const rawBody = await readJsonBody(request);
|
||||
const parsed = stopRequestSchema.safeParse(rawBody ?? {});
|
||||
if (!parsed.success) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 400,
|
||||
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: parsed.error.issues[0]?.message ?? "세션 종료 입력값이 올바르지 않습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const session = getAutotradeSession(userId);
|
||||
if (!session) {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
session: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.data.sessionId && parsed.data.sessionId !== session.sessionId) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 409,
|
||||
code: AUTOTRADE_API_ERROR_CODE.CONFLICT,
|
||||
message: "세션 식별자가 일치하지 않습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const reason: AutotradeStopReason = parsed.data.reason ?? "manual";
|
||||
const stopped = stopAutotradeSession(userId, reason);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
session: stopped,
|
||||
});
|
||||
} catch (error) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 500,
|
||||
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
|
||||
message: sanitizeAutotradeError(error, "세션 종료 중 오류가 발생했습니다."),
|
||||
});
|
||||
}
|
||||
}
|
||||
440
app/api/autotrade/signals/generate/route.ts
Normal file
440
app/api/autotrade/signals/generate/route.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* [파일 역할]
|
||||
* 컴파일된 전략 + 시세 스냅샷으로 매수/매도/대기 신호를 생성하는 API 라우트입니다.
|
||||
*
|
||||
* [주요 책임]
|
||||
* - 요청 검증(strategy/snapshot)
|
||||
* - provider 분기(OpenAI/구독형 CLI/fallback)
|
||||
* - 실패 시 fallback 신호로 대체
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AUTOTRADE_API_ERROR_CODE,
|
||||
createAutotradeErrorResponse,
|
||||
getAutotradeUserId,
|
||||
readJsonBody,
|
||||
sanitizeAutotradeError,
|
||||
} from "@/app/api/autotrade/_shared";
|
||||
import { AUTOTRADE_TECHNIQUE_IDS } from "@/features/autotrade/types/autotrade.types";
|
||||
import {
|
||||
generateSignalWithSubscriptionCliDetailed,
|
||||
summarizeSubscriptionCliExecution,
|
||||
} from "@/lib/autotrade/cli-provider";
|
||||
import { generateSignalWithOpenAi, isOpenAiConfigured } from "@/lib/autotrade/openai";
|
||||
import { createFallbackSignalCandidate } from "@/lib/autotrade/strategy";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const strategySchema = z.object({
|
||||
provider: z.enum(["openai", "fallback", "subscription_cli"]),
|
||||
summary: z.string().trim().min(1).max(320),
|
||||
selectedTechniques: z.array(z.enum(AUTOTRADE_TECHNIQUE_IDS)).default([]),
|
||||
confidenceThreshold: z.number().min(0.45).max(0.95),
|
||||
maxDailyOrders: z.number().int().min(1).max(200),
|
||||
cooldownSec: z.number().int().min(10).max(600),
|
||||
maxOrderAmountRatio: z.number().min(0.05).max(1),
|
||||
createdAt: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
const signalRequestSchema = z.object({
|
||||
aiMode: z
|
||||
.enum(["auto", "openai_api", "subscription_cli", "rule_fallback"])
|
||||
.default("auto"),
|
||||
subscriptionCliVendor: z.enum(["auto", "codex", "gemini"]).optional(),
|
||||
subscriptionCliModel: z.string().trim().max(80).optional(),
|
||||
prompt: z.string().trim().max(1200).default(""),
|
||||
strategy: strategySchema,
|
||||
snapshot: z.object({
|
||||
symbol: z.string().trim().regex(/^\d{6}$/),
|
||||
stockName: z.string().trim().max(120).optional(),
|
||||
market: z.enum(["KOSPI", "KOSDAQ"]).optional(),
|
||||
requestAtIso: z.string().trim().max(40).optional(),
|
||||
requestAtKst: z.string().trim().max(40).optional(),
|
||||
tickTime: z.string().trim().max(12).optional(),
|
||||
executionClassCode: z.string().trim().max(10).optional(),
|
||||
isExpected: z.boolean().optional(),
|
||||
trId: z.string().trim().max(32).optional(),
|
||||
currentPrice: z.number().positive(),
|
||||
prevClose: z.number().nonnegative().optional(),
|
||||
changeRate: z.number(),
|
||||
open: z.number().nonnegative(),
|
||||
high: z.number().nonnegative(),
|
||||
low: z.number().nonnegative(),
|
||||
tradeVolume: z.number().nonnegative(),
|
||||
accumulatedVolume: z.number().nonnegative(),
|
||||
tradeStrength: z.number().optional(),
|
||||
askPrice1: z.number().nonnegative().optional(),
|
||||
bidPrice1: z.number().nonnegative().optional(),
|
||||
askSize1: z.number().nonnegative().optional(),
|
||||
bidSize1: z.number().nonnegative().optional(),
|
||||
totalAskSize: z.number().nonnegative().optional(),
|
||||
totalBidSize: z.number().nonnegative().optional(),
|
||||
buyExecutionCount: z.number().int().optional(),
|
||||
sellExecutionCount: z.number().int().optional(),
|
||||
netBuyExecutionCount: z.number().int().optional(),
|
||||
spread: z.number().nonnegative().optional(),
|
||||
spreadRate: z.number().optional(),
|
||||
dayRangePercent: z.number().nonnegative().optional(),
|
||||
dayRangePosition: z.number().min(0).max(1).optional(),
|
||||
volumeRatio: z.number().nonnegative().optional(),
|
||||
recentTradeCount: z.number().int().nonnegative().optional(),
|
||||
recentTradeVolumeSum: z.number().nonnegative().optional(),
|
||||
recentAverageTradeVolume: z.number().nonnegative().optional(),
|
||||
accumulatedVolumeDelta: z.number().nonnegative().optional(),
|
||||
netBuyExecutionDelta: z.number().optional(),
|
||||
orderBookImbalance: z.number().min(-1).max(1).optional(),
|
||||
liquidityDepth: z.number().nonnegative().optional(),
|
||||
topLevelOrderBookImbalance: z.number().min(-1).max(1).optional(),
|
||||
buySellExecutionRatio: z.number().nonnegative().optional(),
|
||||
recentPriceHigh: z.number().positive().optional(),
|
||||
recentPriceLow: z.number().positive().optional(),
|
||||
recentPriceRangePercent: z.number().nonnegative().optional(),
|
||||
recentTradeVolumes: z.array(z.number().nonnegative()).max(20).optional(),
|
||||
recentNetBuyTrail: z.array(z.number()).max(20).optional(),
|
||||
recentTickAgesSec: z.array(z.number().nonnegative()).max(20).optional(),
|
||||
intradayMomentum: z.number().optional(),
|
||||
recentReturns: z.array(z.number()).max(12).optional(),
|
||||
recentPrices: z.array(z.number().positive()).min(3).max(30),
|
||||
marketDataLatencySec: z.number().nonnegative().optional(),
|
||||
recentMinuteCandles: z
|
||||
.array(
|
||||
z.object({
|
||||
time: z.string().trim().max(32),
|
||||
open: z.number().positive(),
|
||||
high: z.number().positive(),
|
||||
low: z.number().positive(),
|
||||
close: z.number().positive(),
|
||||
volume: z.number().nonnegative(),
|
||||
timestamp: z.number().int().optional(),
|
||||
}),
|
||||
)
|
||||
.max(30)
|
||||
.optional(),
|
||||
minutePatternContext: z
|
||||
.object({
|
||||
timeframe: z.literal("1m"),
|
||||
candleCount: z.number().int().min(1).max(30),
|
||||
impulseDirection: z.enum(["up", "down", "flat"]),
|
||||
impulseBarCount: z.number().int().min(1).max(20),
|
||||
consolidationBarCount: z.number().int().min(1).max(12),
|
||||
impulseChangeRate: z.number().optional(),
|
||||
impulseRangePercent: z.number().nonnegative().optional(),
|
||||
consolidationRangePercent: z.number().nonnegative().optional(),
|
||||
consolidationCloseClusterPercent: z.number().nonnegative().optional(),
|
||||
consolidationVolumeRatio: z.number().nonnegative().optional(),
|
||||
breakoutUpper: z.number().positive().optional(),
|
||||
breakoutLower: z.number().positive().optional(),
|
||||
})
|
||||
.optional(),
|
||||
budgetContext: z
|
||||
.object({
|
||||
setupAllocationPercent: z.number().nonnegative(),
|
||||
setupAllocationAmount: z.number().nonnegative(),
|
||||
effectiveAllocationAmount: z.number().nonnegative(),
|
||||
strategyMaxOrderAmountRatio: z.number().min(0).max(1),
|
||||
effectiveOrderBudgetAmount: z.number().nonnegative(),
|
||||
estimatedBuyUnitCost: z.number().nonnegative(),
|
||||
estimatedBuyableQuantity: z.number().int().nonnegative(),
|
||||
})
|
||||
.optional(),
|
||||
portfolioContext: z
|
||||
.object({
|
||||
holdingQuantity: z.number().int().nonnegative(),
|
||||
sellableQuantity: z.number().int().nonnegative(),
|
||||
averagePrice: z.number().nonnegative(),
|
||||
estimatedSellableNetAmount: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
executionCostProfile: z
|
||||
.object({
|
||||
buyFeeRate: z.number().nonnegative(),
|
||||
sellFeeRate: z.number().nonnegative(),
|
||||
sellTaxRate: z.number().nonnegative(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const signalResultSchema = z.object({
|
||||
signal: z.enum(["buy", "sell", "hold"]),
|
||||
confidence: z.number().min(0).max(1),
|
||||
reason: z.string().min(1).max(160),
|
||||
ttlSec: z.number().int().min(5).max(300),
|
||||
riskFlags: z.array(z.string()).max(10).default([]),
|
||||
proposedOrder: z
|
||||
.object({
|
||||
symbol: z.string().trim().regex(/^\d{6}$/),
|
||||
side: z.enum(["buy", "sell"]),
|
||||
orderType: z.enum(["limit", "market"]),
|
||||
price: z.number().positive().optional(),
|
||||
quantity: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const userId = await getAutotradeUserId(request.headers);
|
||||
if (!userId) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 401,
|
||||
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const rawBody = await readJsonBody(request);
|
||||
const parsed = signalRequestSchema.safeParse(rawBody);
|
||||
|
||||
if (!parsed.success) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 400,
|
||||
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: parsed.error.issues[0]?.message ?? "신호 생성 요청값이 올바르지 않습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// [Step 1] 안전망: 우선 규칙 기반 fallback 신호를 준비합니다.
|
||||
const fallbackSignal = createFallbackSignalCandidate({
|
||||
strategy: parsed.data.strategy,
|
||||
snapshot: parsed.data.snapshot,
|
||||
});
|
||||
|
||||
// [Step 2] 규칙 기반 강제 모드
|
||||
if (parsed.data.aiMode === "rule_fallback") {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
signal: fallbackSignal,
|
||||
});
|
||||
}
|
||||
|
||||
// [Step 3] OpenAI 모드(auto/openai_api): 성공 시 해당 신호를 그대로 반환
|
||||
const shouldUseOpenAi = parsed.data.aiMode === "openai_api" || parsed.data.aiMode === "auto";
|
||||
if (shouldUseOpenAi && isOpenAiConfigured()) {
|
||||
const aiSignal = await generateSignalWithOpenAi({
|
||||
prompt: parsed.data.prompt,
|
||||
strategy: parsed.data.strategy,
|
||||
snapshot: parsed.data.snapshot,
|
||||
});
|
||||
|
||||
if (aiSignal) {
|
||||
const localizedReason = ensureKoreanReason(aiSignal.reason, aiSignal.signal);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
signal: {
|
||||
...aiSignal,
|
||||
reason: localizedReason,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// [Step 4] 구독형 CLI 모드(subscription_cli/auto-fallback): codex/gemini CLI 자동판단
|
||||
const shouldUseCli =
|
||||
parsed.data.aiMode === "subscription_cli" ||
|
||||
(parsed.data.aiMode === "auto" && !isOpenAiConfigured());
|
||||
if (shouldUseCli) {
|
||||
const cliResult = await generateSignalWithSubscriptionCliDetailed({
|
||||
prompt: parsed.data.prompt,
|
||||
strategy: parsed.data.strategy,
|
||||
snapshot: parsed.data.snapshot,
|
||||
preferredVendor: parsed.data.subscriptionCliVendor,
|
||||
preferredModel:
|
||||
parsed.data.subscriptionCliVendor && parsed.data.subscriptionCliVendor !== "auto"
|
||||
? parsed.data.subscriptionCliModel
|
||||
: undefined,
|
||||
});
|
||||
const normalizedCliSignal = normalizeCliSignalCandidate(
|
||||
cliResult.parsed,
|
||||
parsed.data.snapshot.symbol,
|
||||
);
|
||||
const cliParsed = signalResultSchema.safeParse(normalizedCliSignal);
|
||||
if (cliParsed.success) {
|
||||
const localizedReason = ensureKoreanReason(
|
||||
cliParsed.data.reason,
|
||||
cliParsed.data.signal,
|
||||
);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
signal: {
|
||||
...cliParsed.data,
|
||||
reason: localizedReason,
|
||||
source: "subscription_cli",
|
||||
providerVendor: cliResult.vendor ?? undefined,
|
||||
providerModel: cliResult.model ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const cliExecutionSummary = summarizeSubscriptionCliExecution(cliResult);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
signal: {
|
||||
...fallbackSignal,
|
||||
// CLI 응답이 비정상이어도 주문 엔진이 멈추지 않도록 fallback 신호로 대체합니다.
|
||||
reason: `구독형 CLI 응답을 해석하지 못해 규칙 기반 신호로 대체했습니다. (${cliExecutionSummary})`,
|
||||
providerVendor: cliResult.vendor ?? undefined,
|
||||
providerModel: cliResult.model ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
signal: fallbackSignal,
|
||||
});
|
||||
} catch (error) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 500,
|
||||
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
|
||||
message: sanitizeAutotradeError(error, "신호 생성 중 오류가 발생했습니다."),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCliSignalCandidate(raw: unknown, defaultSymbol: string) {
|
||||
const source = resolveSignalPayloadSource(raw);
|
||||
if (!source) return raw;
|
||||
|
||||
const signal = normalizeSignalValue(source.signal ?? source.action ?? source.side);
|
||||
const confidence = clampNumber(source.confidence ?? source.score ?? source.probability, 0, 1);
|
||||
const reason = normalizeReasonText(source.reason ?? source.rationale ?? source.comment);
|
||||
const ttlSec = normalizeInteger(source.ttlSec ?? source.ttl, 20, 5, 300);
|
||||
const riskFlags = normalizeRiskFlags(source.riskFlags ?? source.risks);
|
||||
const proposedOrder = normalizeProposedOrder(source.proposedOrder ?? source.order, defaultSymbol);
|
||||
|
||||
return {
|
||||
signal: signal ?? source.signal,
|
||||
confidence,
|
||||
reason,
|
||||
ttlSec,
|
||||
riskFlags,
|
||||
proposedOrder,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSignalPayloadSource(raw: unknown): Record<string, unknown> | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const source = raw as Record<string, unknown>;
|
||||
|
||||
if (source.signal || source.action || source.side || source.proposedOrder || source.order) {
|
||||
return source;
|
||||
}
|
||||
|
||||
const nestedCandidate =
|
||||
source.decision ??
|
||||
source.result ??
|
||||
source.data ??
|
||||
source.output ??
|
||||
source.payload;
|
||||
if (!nestedCandidate || typeof nestedCandidate !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return nestedCandidate as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeSignalValue(raw: unknown) {
|
||||
if (typeof raw !== "string") return null;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (normalized === "buy" || normalized === "sell" || normalized === "hold") {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clampNumber(raw: unknown, min: number, max: number) {
|
||||
const value = typeof raw === "number" ? raw : Number.parseFloat(String(raw ?? ""));
|
||||
if (!Number.isFinite(value)) return 0.5;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function normalizeInteger(raw: unknown, fallback: number, min: number, max: number) {
|
||||
const value = Number.parseInt(String(raw ?? ""), 10);
|
||||
if (!Number.isFinite(value)) return fallback;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function normalizeReasonText(raw: unknown) {
|
||||
const value = typeof raw === "string" ? raw.trim() : "";
|
||||
if (!value) return "신호 사유가 없어 hold로 처리했습니다.";
|
||||
return value.slice(0, 160);
|
||||
}
|
||||
|
||||
function ensureKoreanReason(
|
||||
reason: string,
|
||||
signal: "buy" | "sell" | "hold",
|
||||
) {
|
||||
const normalized = normalizeReasonText(reason);
|
||||
if (/[가-힣]/.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (signal === "buy") {
|
||||
return "상승 신호가 확인되어 매수 관점으로 판단했습니다.";
|
||||
}
|
||||
if (signal === "sell") {
|
||||
return "하락 또는 과열 신호가 확인되어 매도 관점으로 판단했습니다.";
|
||||
}
|
||||
return "명확한 방향성이 부족해 대기 신호로 판단했습니다.";
|
||||
}
|
||||
|
||||
function normalizeRiskFlags(raw: unknown) {
|
||||
if (Array.isArray(raw)) {
|
||||
return raw
|
||||
.map((item) => String(item).trim())
|
||||
.filter((item) => item.length > 0)
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
if (typeof raw === "string") {
|
||||
return raw
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeProposedOrder(raw: unknown, defaultSymbol: string) {
|
||||
if (!raw || typeof raw !== "object") return undefined;
|
||||
const source = raw as Record<string, unknown>;
|
||||
|
||||
const side = normalizeSignalValue(source.side);
|
||||
if (side !== "buy" && side !== "sell") return undefined;
|
||||
|
||||
const orderTypeRaw = String(source.orderType ?? source.type ?? "limit")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const orderType = orderTypeRaw === "market" ? "market" : "limit";
|
||||
const symbolRaw = String(source.symbol ?? defaultSymbol).trim();
|
||||
const symbol = /^\d{6}$/.test(symbolRaw) ? symbolRaw : defaultSymbol;
|
||||
const price = parseOptionalPositiveNumber(source.price);
|
||||
const quantity = parseOptionalPositiveInteger(source.quantity ?? source.qty);
|
||||
|
||||
return {
|
||||
symbol,
|
||||
side,
|
||||
orderType,
|
||||
price,
|
||||
quantity,
|
||||
};
|
||||
}
|
||||
|
||||
function parseOptionalPositiveNumber(raw: unknown) {
|
||||
if (raw === undefined || raw === null || raw === "") return undefined;
|
||||
const value = typeof raw === "number" ? raw : Number.parseFloat(String(raw));
|
||||
if (!Number.isFinite(value) || value <= 0) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseOptionalPositiveInteger(raw: unknown) {
|
||||
if (raw === undefined || raw === null || raw === "") return undefined;
|
||||
const value = Number.parseInt(String(raw), 10);
|
||||
if (!Number.isFinite(value) || value <= 0) return undefined;
|
||||
return value;
|
||||
}
|
||||
411
app/api/autotrade/strategies/compile/route.ts
Normal file
411
app/api/autotrade/strategies/compile/route.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* [파일 역할]
|
||||
* 전략 프롬프트를 실행 가능한 자동매매 전략(JSON)으로 컴파일하는 API 라우트입니다.
|
||||
*
|
||||
* [주요 책임]
|
||||
* - 요청 검증(aiMode/prompt/기법/신뢰도)
|
||||
* - provider 분기(OpenAI/구독형 CLI/fallback)
|
||||
* - 실패 시 fallback 전략으로 안전하게 응답
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AUTOTRADE_API_ERROR_CODE,
|
||||
createAutotradeErrorResponse,
|
||||
getAutotradeUserId,
|
||||
readJsonBody,
|
||||
sanitizeAutotradeError,
|
||||
} from "@/app/api/autotrade/_shared";
|
||||
import {
|
||||
AUTOTRADE_DEFAULT_TECHNIQUES,
|
||||
AUTOTRADE_TECHNIQUE_IDS,
|
||||
} from "@/features/autotrade/types/autotrade.types";
|
||||
import {
|
||||
compileStrategyWithSubscriptionCliDetailed,
|
||||
summarizeSubscriptionCliExecution,
|
||||
} from "@/lib/autotrade/cli-provider";
|
||||
import { compileStrategyWithOpenAi, isOpenAiConfigured } from "@/lib/autotrade/openai";
|
||||
import { createFallbackCompiledStrategy } from "@/lib/autotrade/strategy";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const compileRequestSchema = z.object({
|
||||
aiMode: z
|
||||
.enum(["auto", "openai_api", "subscription_cli", "rule_fallback"])
|
||||
.default("auto"),
|
||||
subscriptionCliVendor: z.enum(["auto", "codex", "gemini"]).optional(),
|
||||
subscriptionCliModel: z.string().trim().max(80).optional(),
|
||||
prompt: z.string().trim().max(1200).default(""),
|
||||
selectedTechniques: z.array(z.enum(AUTOTRADE_TECHNIQUE_IDS)).default([]),
|
||||
confidenceThreshold: z.number().min(0.45).max(0.95).optional(),
|
||||
});
|
||||
|
||||
const compileResultSchema = z.object({
|
||||
summary: z.string().min(1).max(320),
|
||||
confidenceThreshold: z.number().min(0.45).max(0.95),
|
||||
maxDailyOrders: z.number().int().min(1).max(200),
|
||||
cooldownSec: z.number().int().min(10).max(600),
|
||||
maxOrderAmountRatio: z.number().min(0.05).max(1),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const userId = await getAutotradeUserId(request.headers);
|
||||
if (!userId) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 401,
|
||||
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const rawBody = await readJsonBody(request);
|
||||
const parsed = compileRequestSchema.safeParse(rawBody);
|
||||
|
||||
if (!parsed.success) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 400,
|
||||
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: parsed.error.issues[0]?.message ?? "전략 입력값이 올바르지 않습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const selectedTechniques =
|
||||
parsed.data.selectedTechniques.length > 0
|
||||
? parsed.data.selectedTechniques
|
||||
: AUTOTRADE_DEFAULT_TECHNIQUES;
|
||||
|
||||
// [Step 1] 어떤 모드든 공통 최소전략(규칙 기반)을 먼저 준비해 둡니다.
|
||||
const fallback = createFallbackCompiledStrategy({
|
||||
prompt: parsed.data.prompt,
|
||||
selectedTechniques,
|
||||
confidenceThreshold: parsed.data.confidenceThreshold ?? 0.65,
|
||||
});
|
||||
|
||||
// [Step 2] 규칙 기반 강제 모드는 즉시 fallback 전략으로 반환합니다.
|
||||
if (parsed.data.aiMode === "rule_fallback") {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
compiledStrategy: {
|
||||
...fallback,
|
||||
summary: `규칙 기반 모드: ${fallback.summary}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// [Step 3] OpenAI 모드(auto/openai_api): API 키가 있으면 OpenAI 결과를 우선 사용합니다.
|
||||
const shouldUseOpenAi = parsed.data.aiMode === "auto" || parsed.data.aiMode === "openai_api";
|
||||
if (shouldUseOpenAi && isOpenAiConfigured()) {
|
||||
const aiResult = await compileStrategyWithOpenAi({
|
||||
prompt: parsed.data.prompt,
|
||||
selectedTechniques,
|
||||
confidenceThreshold: fallback.confidenceThreshold,
|
||||
});
|
||||
|
||||
if (aiResult) {
|
||||
const finalizedSummary = finalizeCompiledSummary({
|
||||
summary: aiResult.summary,
|
||||
prompt: parsed.data.prompt,
|
||||
selectedTechniques,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
compiledStrategy: {
|
||||
...fallback,
|
||||
provider: "openai",
|
||||
summary: finalizedSummary,
|
||||
confidenceThreshold: aiResult.confidenceThreshold,
|
||||
maxDailyOrders: aiResult.maxDailyOrders,
|
||||
cooldownSec: aiResult.cooldownSec,
|
||||
maxOrderAmountRatio: aiResult.maxOrderAmountRatio,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// [Step 4] 구독형 CLI 모드(subscription_cli/auto-fallback): codex/gemini CLI를 호출합니다.
|
||||
const shouldUseCli =
|
||||
parsed.data.aiMode === "subscription_cli" ||
|
||||
(parsed.data.aiMode === "auto" && !isOpenAiConfigured());
|
||||
if (shouldUseCli) {
|
||||
const cliResult = await compileStrategyWithSubscriptionCliDetailed({
|
||||
prompt: parsed.data.prompt,
|
||||
selectedTechniques,
|
||||
confidenceThreshold: fallback.confidenceThreshold,
|
||||
preferredVendor: parsed.data.subscriptionCliVendor,
|
||||
preferredModel:
|
||||
parsed.data.subscriptionCliVendor && parsed.data.subscriptionCliVendor !== "auto"
|
||||
? parsed.data.subscriptionCliModel
|
||||
: undefined,
|
||||
});
|
||||
const normalizedCliCompile = normalizeCliCompileResult(cliResult.parsed, fallback);
|
||||
const cliParsed = compileResultSchema.safeParse(normalizedCliCompile);
|
||||
if (cliParsed.success) {
|
||||
const finalizedSummary = finalizeCompiledSummary({
|
||||
summary: cliParsed.data.summary,
|
||||
prompt: parsed.data.prompt,
|
||||
selectedTechniques,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
compiledStrategy: {
|
||||
...fallback,
|
||||
provider: "subscription_cli",
|
||||
providerVendor: cliResult.vendor ?? undefined,
|
||||
providerModel: cliResult.model ?? undefined,
|
||||
summary: finalizedSummary,
|
||||
confidenceThreshold: cliParsed.data.confidenceThreshold,
|
||||
maxDailyOrders: cliParsed.data.maxDailyOrders,
|
||||
cooldownSec: cliParsed.data.cooldownSec,
|
||||
maxOrderAmountRatio: cliParsed.data.maxOrderAmountRatio,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const parseSummary = summarizeCompileParseFailure(cliResult.parsed);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
compiledStrategy: {
|
||||
...fallback,
|
||||
provider: "subscription_cli",
|
||||
providerVendor: cliResult.vendor ?? undefined,
|
||||
providerModel: cliResult.model ?? undefined,
|
||||
// CLI가 실패해도 자동매매가 멈추지 않도록 fallback 전략으로 안전하게 유지합니다.
|
||||
summary: `구독형 CLI 응답을 해석하지 못해 규칙 기반 전략으로 동작합니다. (${summarizeSubscriptionCliExecution(cliResult)}; parse=${parseSummary})`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
compiledStrategy: fallback,
|
||||
});
|
||||
} catch (error) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 500,
|
||||
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
|
||||
message: sanitizeAutotradeError(error, "전략 컴파일 중 오류가 발생했습니다."),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCliCompileResult(raw: unknown, fallback: ReturnType<typeof createFallbackCompiledStrategy>) {
|
||||
const source = resolveCompilePayloadSource(raw);
|
||||
if (!source) return raw;
|
||||
|
||||
const summary = normalizeSummaryText(
|
||||
source.summary ??
|
||||
source.strategySummary ??
|
||||
source.description ??
|
||||
source.plan ??
|
||||
source.reason ??
|
||||
fallback.summary,
|
||||
fallback.summary,
|
||||
);
|
||||
const confidenceThreshold = normalizeRatioNumber(
|
||||
source.confidenceThreshold ?? source.confidence ?? source.threshold,
|
||||
fallback.confidenceThreshold,
|
||||
0.45,
|
||||
0.95,
|
||||
);
|
||||
const maxDailyOrders = normalizeIntegerValue(
|
||||
source.maxDailyOrders ?? source.dailyOrderLimit ?? source.maxOrdersPerDay ?? source.orderLimit,
|
||||
fallback.maxDailyOrders,
|
||||
1,
|
||||
200,
|
||||
);
|
||||
const cooldownSec = normalizeIntegerValue(
|
||||
source.cooldownSec ?? source.cooldownSeconds ?? source.cooldown ?? source.minIntervalSec,
|
||||
fallback.cooldownSec,
|
||||
10,
|
||||
600,
|
||||
);
|
||||
const maxOrderAmountRatio = normalizeRatioNumber(
|
||||
source.maxOrderAmountRatio ??
|
||||
source.maxPositionRatio ??
|
||||
source.positionSizeRatio ??
|
||||
source.orderAmountRatio,
|
||||
fallback.maxOrderAmountRatio,
|
||||
0.05,
|
||||
1,
|
||||
);
|
||||
|
||||
return {
|
||||
summary,
|
||||
confidenceThreshold,
|
||||
maxDailyOrders,
|
||||
cooldownSec,
|
||||
maxOrderAmountRatio,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCompilePayloadSource(raw: unknown): Record<string, unknown> | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const source = raw as Record<string, unknown>;
|
||||
if (
|
||||
source.summary ||
|
||||
source.strategySummary ||
|
||||
source.confidenceThreshold ||
|
||||
source.maxDailyOrders ||
|
||||
source.cooldownSec ||
|
||||
source.maxOrderAmountRatio
|
||||
) {
|
||||
return source;
|
||||
}
|
||||
|
||||
const nestedCandidate =
|
||||
source.strategy ??
|
||||
source.compiledStrategy ??
|
||||
source.result ??
|
||||
source.output ??
|
||||
source.data ??
|
||||
source.payload;
|
||||
if (!nestedCandidate || typeof nestedCandidate !== "object") {
|
||||
return source;
|
||||
}
|
||||
|
||||
return nestedCandidate as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeSummaryText(raw: unknown, fallback: string) {
|
||||
const text = typeof raw === "string" ? raw.trim() : "";
|
||||
if (!text) return fallback;
|
||||
return text.slice(0, 320);
|
||||
}
|
||||
|
||||
function normalizeRatioNumber(
|
||||
raw: unknown,
|
||||
fallback: number,
|
||||
min: number,
|
||||
max: number,
|
||||
) {
|
||||
let value = typeof raw === "number" ? raw : Number.parseFloat(String(raw ?? ""));
|
||||
if (!Number.isFinite(value)) return fallback;
|
||||
if (value > 1 && value <= 100) value /= 100;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function normalizeIntegerValue(
|
||||
raw: unknown,
|
||||
fallback: number,
|
||||
min: number,
|
||||
max: number,
|
||||
) {
|
||||
const value = Number.parseInt(String(raw ?? ""), 10);
|
||||
if (!Number.isFinite(value)) return fallback;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function summarizeCompileParseFailure(raw: unknown) {
|
||||
if (raw === null || raw === undefined) return "empty";
|
||||
if (typeof raw === "string") return `string:${raw.slice(0, 80)}`;
|
||||
if (typeof raw !== "object") return typeof raw;
|
||||
try {
|
||||
const keys = Object.keys(raw as Record<string, unknown>).slice(0, 8);
|
||||
return `keys:${keys.join("|") || "none"}`;
|
||||
} catch {
|
||||
return "object";
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeCompiledSummary(params: {
|
||||
summary: string;
|
||||
prompt: string;
|
||||
selectedTechniques: readonly string[];
|
||||
}) {
|
||||
const cleanedSummary = params.summary.trim();
|
||||
const prompt = params.prompt.trim();
|
||||
|
||||
if (!prompt) {
|
||||
return cleanedSummary.slice(0, 320);
|
||||
}
|
||||
|
||||
const loweredSummary = cleanedSummary.toLowerCase();
|
||||
const loweredPrompt = prompt.toLowerCase();
|
||||
const suspiciousPhrases = [
|
||||
"테스트 목적",
|
||||
"테스트용",
|
||||
"sample",
|
||||
"example",
|
||||
"for testing",
|
||||
"test purpose",
|
||||
];
|
||||
const hasSuspiciousPhrase =
|
||||
suspiciousPhrases.some((phrase) => loweredSummary.includes(phrase)) &&
|
||||
!suspiciousPhrases.some((phrase) => loweredPrompt.includes(phrase));
|
||||
|
||||
const hasPromptCoverage = detectPromptCoverage(cleanedSummary, prompt);
|
||||
|
||||
if (hasSuspiciousPhrase) {
|
||||
return buildPromptAnchoredSummary(prompt, params.selectedTechniques, cleanedSummary);
|
||||
}
|
||||
|
||||
if (!hasPromptCoverage) {
|
||||
return buildPromptAnchoredSummary(prompt, params.selectedTechniques, cleanedSummary);
|
||||
}
|
||||
|
||||
return cleanedSummary.slice(0, 320);
|
||||
}
|
||||
|
||||
function detectPromptCoverage(summary: string, prompt: string) {
|
||||
const normalizedSummary = normalizeCoverageText(summary);
|
||||
const keywords = extractPromptKeywords(prompt);
|
||||
if (keywords.length === 0) return true;
|
||||
return keywords.some((keyword) => normalizedSummary.includes(keyword));
|
||||
}
|
||||
|
||||
function normalizeCoverageText(text: string) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9가-힣]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractPromptKeywords(prompt: string) {
|
||||
const stopwords = new Set([
|
||||
"그리고",
|
||||
"그냥",
|
||||
"우선",
|
||||
"위주",
|
||||
"중심",
|
||||
"하게",
|
||||
"하면",
|
||||
"현재",
|
||||
"지금",
|
||||
"please",
|
||||
"with",
|
||||
"from",
|
||||
"that",
|
||||
"this",
|
||||
]);
|
||||
|
||||
return normalizeCoverageText(prompt)
|
||||
.split(" ")
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length >= 2 && !stopwords.has(token))
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
function buildPromptAnchoredSummary(
|
||||
prompt: string,
|
||||
selectedTechniques: readonly string[],
|
||||
aiSummary?: string,
|
||||
) {
|
||||
const promptExcerpt = prompt.replace(/\s+/g, " ").trim().slice(0, 120);
|
||||
const techniquesText =
|
||||
selectedTechniques.length > 0 ? ` (${selectedTechniques.join(", ")})` : "";
|
||||
const aiSummaryText = aiSummary?.replace(/\s+/g, " ").trim().slice(0, 120);
|
||||
if (!aiSummaryText) {
|
||||
return `프롬프트 반영 전략${techniquesText}: ${promptExcerpt}`.slice(0, 320);
|
||||
}
|
||||
return `프롬프트 반영 전략${techniquesText}: ${promptExcerpt} | AI요약: ${aiSummaryText}`.slice(
|
||||
0,
|
||||
320,
|
||||
);
|
||||
}
|
||||
43
app/api/autotrade/strategies/validate/route.ts
Normal file
43
app/api/autotrade/strategies/validate/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AUTOTRADE_API_ERROR_CODE,
|
||||
createAutotradeErrorResponse,
|
||||
getAutotradeUserId,
|
||||
readJsonBody,
|
||||
} from "@/app/api/autotrade/_shared";
|
||||
import { buildRiskEnvelope } from "@/lib/autotrade/risk";
|
||||
|
||||
const validateRequestSchema = z.object({
|
||||
cashBalance: z.number().nonnegative(),
|
||||
allocationPercent: z.number().nonnegative(),
|
||||
allocationAmount: z.number().positive(),
|
||||
dailyLossPercent: z.number().nonnegative(),
|
||||
dailyLossAmount: z.number().positive(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const userId = await getAutotradeUserId(request.headers);
|
||||
if (!userId) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 401,
|
||||
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const rawBody = await readJsonBody(request);
|
||||
const parsed = validateRequestSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 400,
|
||||
code: AUTOTRADE_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: parsed.error.issues[0]?.message ?? "검증 입력값이 올바르지 않습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
validation: buildRiskEnvelope(parsed.data),
|
||||
});
|
||||
}
|
||||
39
app/api/autotrade/worker/tick/route.ts
Normal file
39
app/api/autotrade/worker/tick/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
AUTOTRADE_API_ERROR_CODE,
|
||||
AUTOTRADE_WORKER_TOKEN_HEADER,
|
||||
createAutotradeErrorResponse,
|
||||
isAutotradeWorkerAuthorized,
|
||||
listAutotradeSessions,
|
||||
sanitizeAutotradeError,
|
||||
sweepExpiredAutotradeSessions,
|
||||
} from "@/app/api/autotrade/_shared";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isAutotradeWorkerAuthorized(request.headers)) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 401,
|
||||
code: AUTOTRADE_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: `${AUTOTRADE_WORKER_TOKEN_HEADER} 인증이 필요합니다.`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const sweep = sweepExpiredAutotradeSessions();
|
||||
const sessions = listAutotradeSessions();
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
sweep,
|
||||
runningSessions: sessions.filter((session) => session.runtimeState === "RUNNING").length,
|
||||
stoppedSessions: sessions.filter((session) => session.runtimeState === "STOPPED").length,
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
return createAutotradeErrorResponse({
|
||||
status: 500,
|
||||
code: AUTOTRADE_API_ERROR_CODE.INTERNAL,
|
||||
message: sanitizeAutotradeError(error, "자동매매 워커 점검 중 오류가 발생했습니다."),
|
||||
});
|
||||
}
|
||||
}
|
||||
56
app/api/kis/_response.ts
Normal file
56
app/api/kis/_response.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { KisTradingEnv } from "@/features/trade/types/trade.types";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const KIS_API_ERROR_CODE = {
|
||||
AUTH_REQUIRED: "KIS_AUTH_REQUIRED",
|
||||
INVALID_REQUEST: "KIS_INVALID_REQUEST",
|
||||
CREDENTIAL_REQUIRED: "KIS_CREDENTIAL_REQUIRED",
|
||||
ACCOUNT_REQUIRED: "KIS_ACCOUNT_REQUIRED",
|
||||
UPSTREAM_FAILURE: "KIS_UPSTREAM_FAILURE",
|
||||
UNAUTHORIZED: "KIS_UNAUTHORIZED",
|
||||
} as const;
|
||||
|
||||
export type KisApiErrorCode =
|
||||
(typeof KIS_API_ERROR_CODE)[keyof typeof KIS_API_ERROR_CODE];
|
||||
|
||||
interface CreateKisApiErrorResponseOptions {
|
||||
status: number;
|
||||
code: KisApiErrorCode;
|
||||
message: string;
|
||||
tradingEnv?: KisTradingEnv;
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description KIS API 라우트용 표준 에러 응답을 생성합니다.
|
||||
* @remarks 클라이언트 하위호환을 위해 message/error 키를 동시에 제공합니다.
|
||||
* @see features/trade/apis/kis-stock.api.ts 종목 API 클라이언트는 error 우선 파싱
|
||||
* @see features/settings/apis/kis-auth.api.ts 인증 API 클라이언트는 message 우선 파싱
|
||||
*/
|
||||
export function createKisApiErrorResponse({
|
||||
status,
|
||||
code,
|
||||
message,
|
||||
tradingEnv,
|
||||
extra,
|
||||
}: CreateKisApiErrorResponseOptions) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
message,
|
||||
error: message,
|
||||
errorCode: code,
|
||||
...(tradingEnv ? { tradingEnv } : {}),
|
||||
...(extra ?? {}),
|
||||
},
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description unknown 에러 객체를 사용자 노출용 메시지로 정규화합니다.
|
||||
* @see app/api/kis/domestic/balance/route.ts 서버 예외를 공통 메시지로 변환
|
||||
*/
|
||||
export function toKisApiErrorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
18
app/api/kis/_session.ts
Normal file
18
app/api/kis/_session.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
|
||||
/**
|
||||
* @description KIS API 라우트 접근 전에 Supabase 로그인 세션을 검증합니다.
|
||||
* @returns 로그인 세션 존재 여부
|
||||
* @remarks UI 흐름: 클라이언트 요청 -> KIS API route -> hasKisApiSession -> (실패 시 401, 성공 시 KIS 호출)
|
||||
* @see app/api/kis/domestic/balance/route.ts 잔고 API 세션 가드
|
||||
* @see app/api/kis/validate/route.ts 인증 검증 API 세션 가드
|
||||
*/
|
||||
export async function hasKisApiSession() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
return Boolean(!error && user);
|
||||
}
|
||||
39
app/api/kis/domestic/_shared.ts
Normal file
39
app/api/kis/domestic/_shared.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { parseKisAccountParts } from "@/lib/kis/account";
|
||||
import {
|
||||
normalizeTradingEnv,
|
||||
type KisCredentialInput,
|
||||
} from "@/lib/kis/config";
|
||||
|
||||
/**
|
||||
* @description 요청 헤더에서 KIS 키를 읽어옵니다.
|
||||
* @param headers 요청 헤더
|
||||
* @returns KIS 인증 입력값
|
||||
* @see app/api/kis/domestic/balance/route.ts 대시보드 잔고 API 인증키 파싱
|
||||
* @see app/api/kis/domestic/indices/route.ts 대시보드 지수 API 인증키 파싱
|
||||
*/
|
||||
export function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
|
||||
const appKey = headers.get("x-kis-app-key")?.trim();
|
||||
const appSecret = headers.get("x-kis-app-secret")?.trim();
|
||||
const tradingEnv = normalizeTradingEnv(
|
||||
headers.get("x-kis-trading-env") ?? undefined,
|
||||
);
|
||||
|
||||
return {
|
||||
appKey,
|
||||
appSecret,
|
||||
tradingEnv,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 요청 헤더에서 계좌번호(8-2)를 읽어옵니다.
|
||||
* @param headers 요청 헤더
|
||||
* @returns 계좌번호 파트(8 + 2) 또는 null
|
||||
* @see app/api/kis/domestic/balance/route.ts 잔고 조회 시 필수 계좌정보 파싱
|
||||
*/
|
||||
export function readKisAccountParts(headers: Headers) {
|
||||
const headerAccountNo = headers.get("x-kis-account-no");
|
||||
const headerAccountProductCode = headers.get("x-kis-account-product-code");
|
||||
|
||||
return parseKisAccountParts(headerAccountNo, headerAccountProductCode);
|
||||
}
|
||||
85
app/api/kis/domestic/activity/route.ts
Normal file
85
app/api/kis/domestic/activity/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { DashboardActivityResponse } from "@/features/dashboard/types/dashboard.types";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import { getDomesticDashboardActivity } from "@/lib/kis/dashboard";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import {
|
||||
readKisAccountParts,
|
||||
readKisCredentialsFromHeaders,
|
||||
} from "@/app/api/kis/domestic/_shared";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/activity/route.ts
|
||||
* @description 국내주식 주문내역/매매일지 조회 API
|
||||
*/
|
||||
|
||||
/**
|
||||
* 대시보드 하단(주문내역/매매일지) 조회 API
|
||||
* @returns 주문내역 목록 + 매매일지 목록/요약
|
||||
* @remarks UI 흐름: DashboardContainer -> useDashboardData -> /api/kis/domestic/activity -> ActivitySection 렌더링
|
||||
* @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/새로고침에서 호출합니다.
|
||||
* @see features/dashboard/components/ActivitySection.tsx 주문내역/매매일지 섹션 렌더링
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||
message: "KIS API 키 설정이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const account = readKisAccountParts(request.headers);
|
||||
if (!account) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
|
||||
message:
|
||||
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getDomesticDashboardActivity(account, credentials);
|
||||
const response: DashboardActivityResponse = {
|
||||
source: "kis",
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
orders: result.orders,
|
||||
tradeJournal: result.tradeJournal,
|
||||
journalSummary: result.journalSummary,
|
||||
warnings: result.warnings,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 500,
|
||||
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||
message: toKisApiErrorMessage(
|
||||
error,
|
||||
"주문내역/매매일지 조회 중 오류가 발생했습니다.",
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
78
app/api/kis/domestic/balance/route.ts
Normal file
78
app/api/kis/domestic/balance/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { DashboardBalanceResponse } from "@/features/dashboard/types/dashboard.types";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import { getDomesticDashboardBalance } from "@/lib/kis/dashboard";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import {
|
||||
readKisAccountParts,
|
||||
readKisCredentialsFromHeaders,
|
||||
} from "@/app/api/kis/domestic/_shared";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/balance/route.ts
|
||||
* @description 국내주식 계좌 잔고/보유종목 조회 API
|
||||
*/
|
||||
|
||||
/**
|
||||
* 대시보드 잔고 조회 API
|
||||
* @returns 총자산/손익/보유종목 목록
|
||||
* @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/새로고침에서 호출합니다.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||
message: "KIS API 키 설정이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const account = readKisAccountParts(request.headers);
|
||||
if (!account) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
|
||||
message:
|
||||
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getDomesticDashboardBalance(account, credentials);
|
||||
const response: DashboardBalanceResponse = {
|
||||
source: "kis",
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
summary: result.summary,
|
||||
holdings: result.holdings,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 500,
|
||||
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||
message: toKisApiErrorMessage(error, "잔고 조회 중 오류가 발생했습니다."),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,23 @@
|
||||
import type {
|
||||
DashboardChartTimeframe,
|
||||
DashboardStockChartResponse,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
} from "@/features/trade/types/trade.types";
|
||||
import { hasKisConfig } from "@/lib/kis/config";
|
||||
import { getDomesticChart } from "@/lib/kis/domestic";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
||||
|
||||
const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
|
||||
"1m",
|
||||
"5m",
|
||||
"10m",
|
||||
"15m",
|
||||
"30m",
|
||||
"1h",
|
||||
"1d",
|
||||
@@ -20,6 +29,15 @@ const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
|
||||
* @description 국내주식 차트(분봉/일봉/주봉) 조회 API
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const symbol = (searchParams.get("symbol") ?? "").trim();
|
||||
const timeframe = (
|
||||
@@ -28,28 +46,29 @@ export async function GET(request: NextRequest) {
|
||||
const cursor = (searchParams.get("cursor") ?? "").trim() || undefined;
|
||||
|
||||
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자리 숫자여야 합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!VALID_TIMEFRAMES.includes(timeframe)) {
|
||||
return NextResponse.json(
|
||||
{ error: "지원하지 않는 timeframe입니다." },
|
||||
{ status: 400 },
|
||||
);
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: "지원하지 않는 timeframe입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||
message:
|
||||
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 차트를 조회할 수 없습니다.",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -75,24 +94,10 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "KIS 차트 조회 중 오류가 발생했습니다.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
return createKisApiErrorResponse({
|
||||
status: 500,
|
||||
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||
message: toKisApiErrorMessage(error, "KIS 차트 조회 중 오류가 발생했습니다."),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
64
app/api/kis/domestic/indices/route.ts
Normal file
64
app/api/kis/domestic/indices/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { DashboardIndicesResponse } from "@/features/dashboard/types/dashboard.types";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import { getDomesticDashboardIndices } from "@/lib/kis/dashboard";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/indices/route.ts
|
||||
* @description 국내 주요 지수(KOSPI/KOSDAQ) 조회 API
|
||||
*/
|
||||
|
||||
/**
|
||||
* 대시보드 지수 조회 API
|
||||
* @returns 코스피/코스닥 지수 목록
|
||||
* @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/주기 갱신에서 호출합니다.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||
message: "KIS API 키 설정이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const items = await getDomesticDashboardIndices(credentials);
|
||||
const response: DashboardIndicesResponse = {
|
||||
source: "kis",
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
items,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 500,
|
||||
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||
message: toKisApiErrorMessage(error, "지수 조회 중 오류가 발생했습니다."),
|
||||
});
|
||||
}
|
||||
}
|
||||
72
app/api/kis/domestic/market-hub/route.ts
Normal file
72
app/api/kis/domestic/market-hub/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { DashboardMarketHubResponse } from "@/features/dashboard/types/dashboard.types";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import { getDomesticDashboardMarketHub } from "@/lib/kis/dashboard";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/market-hub/route.ts
|
||||
* @description 국내주식 시장 허브(급등/인기/뉴스) 조회 API
|
||||
*/
|
||||
|
||||
/**
|
||||
* 대시보드 시장 허브 조회 API
|
||||
* @returns 급등주식/인기종목/주요뉴스 목록
|
||||
* @remarks UI 흐름: DashboardContainer -> useDashboardData -> /api/kis/domestic/market-hub -> MarketHubSection 렌더링
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||
message: "KIS API 키 설정이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getDomesticDashboardMarketHub(credentials);
|
||||
const response: DashboardMarketHubResponse = {
|
||||
source: "kis",
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
gainers: result.gainers,
|
||||
losers: result.losers,
|
||||
popularByVolume: result.popularByVolume,
|
||||
popularByValue: result.popularByValue,
|
||||
news: result.news,
|
||||
pulse: result.pulse,
|
||||
warnings: result.warnings,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 500,
|
||||
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||
message: toKisApiErrorMessage(
|
||||
error,
|
||||
"시장 허브 조회 중 오류가 발생했습니다.",
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,53 +1,137 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { executeOrderCash } from "@/lib/kis/trade";
|
||||
import {
|
||||
DashboardStockCashOrderRequest,
|
||||
DashboardStockCashOrderResponse,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
} from "@/features/trade/types/trade.types";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import { parseKisAccountParts } from "@/lib/kis/account";
|
||||
import {
|
||||
KisCredentialInput,
|
||||
hasKisConfig,
|
||||
normalizeTradingEnv,
|
||||
} from "@/lib/kis/config";
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/order-cash/route.ts
|
||||
* @description 국내주식 현금 주문 API
|
||||
*/
|
||||
|
||||
const orderCashBodySchema = z
|
||||
.object({
|
||||
symbol: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^\d{6}$/, "종목코드는 6자리 숫자여야 합니다."),
|
||||
side: z.enum(["buy", "sell"], {
|
||||
message: "주문 구분(side)은 buy/sell만 허용됩니다.",
|
||||
}),
|
||||
orderType: z.enum(["limit", "market"], {
|
||||
message: "주문 유형(orderType)은 limit/market만 허용됩니다.",
|
||||
}),
|
||||
quantity: z.coerce
|
||||
.number()
|
||||
.int("주문수량은 정수여야 합니다.")
|
||||
.positive("주문수량은 1주 이상이어야 합니다."),
|
||||
price: z.coerce.number(),
|
||||
accountNo: z.string().trim().min(1, "계좌번호를 입력해 주세요."),
|
||||
accountProductCode: z.string().trim().optional(),
|
||||
})
|
||||
.superRefine((body, ctx) => {
|
||||
if (body.orderType === "limit" && body.price <= 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["price"],
|
||||
message: "지정가 주문은 주문가격이 0보다 커야 합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (body.orderType === "market" && body.price < 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["price"],
|
||||
message: "시장가 주문은 주문가격이 0 이상이어야 합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const accountParts = parseKisAccountParts(
|
||||
body.accountNo,
|
||||
body.accountProductCode,
|
||||
);
|
||||
if (!accountParts) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["accountNo"],
|
||||
message:
|
||||
"계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
|
||||
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||
message: "KIS API 키 설정이 필요합니다.",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
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.)
|
||||
if (
|
||||
!body.symbol ||
|
||||
!body.accountNo ||
|
||||
!body.accountProductCode ||
|
||||
body.quantity <= 0
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
message:
|
||||
"주문 정보가 올바르지 않습니다. (종목코드, 계좌번호, 수량 확인)",
|
||||
},
|
||||
{ status: 400 },
|
||||
const parsed = orderCashBodySchema.safeParse(rawBody);
|
||||
|
||||
if (!parsed.success) {
|
||||
const firstIssue = parsed.error.issues[0];
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: firstIssue?.message ?? "주문 요청 값이 올바르지 않습니다.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
const body = parsed.data;
|
||||
const accountParts = parseKisAccountParts(
|
||||
body.accountNo,
|
||||
body.accountProductCode,
|
||||
);
|
||||
|
||||
if (!accountParts) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
|
||||
message:
|
||||
"계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
const output = await executeOrderCash(
|
||||
@@ -57,15 +141,15 @@ export async function POST(request: NextRequest) {
|
||||
orderType: body.orderType,
|
||||
quantity: body.quantity,
|
||||
price: body.price,
|
||||
accountNo: body.accountNo,
|
||||
accountProductCode: body.accountProductCode,
|
||||
accountNo: accountParts.accountNo,
|
||||
accountProductCode: accountParts.accountProductCode,
|
||||
},
|
||||
credentials,
|
||||
);
|
||||
|
||||
const response: DashboardStockCashOrderResponse = {
|
||||
ok: true,
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
tradingEnv,
|
||||
message: "주문이 전송되었습니다.",
|
||||
orderNo: output.ODNO,
|
||||
orderTime: output.ORD_TMD,
|
||||
@@ -74,31 +158,11 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "주문 전송 중 오류가 발생했습니다.";
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
tradingEnv: normalizeTradingEnv(credentials.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,
|
||||
return createKisApiErrorResponse({
|
||||
status: 500,
|
||||
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||
message: toKisApiErrorMessage(error, "주문 전송 중 오류가 발생했습니다."),
|
||||
tradingEnv,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
124
app/api/kis/domestic/orderable-cash/route.ts
Normal file
124
app/api/kis/domestic/orderable-cash/route.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { executeInquireOrderableCash } from "@/lib/kis/trade";
|
||||
import type { DashboardStockOrderableCashResponse } from "@/features/trade/types/trade.types";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import {
|
||||
readKisAccountParts,
|
||||
readKisCredentialsFromHeaders,
|
||||
} from "@/app/api/kis/domestic/_shared";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/orderable-cash/route.ts
|
||||
* @description 국내주식 매수가능금액(주문가능현금) 조회 API
|
||||
*/
|
||||
|
||||
const orderableCashBodySchema = z.object({
|
||||
symbol: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^\d{6}$/, "종목코드는 6자리 숫자여야 합니다."),
|
||||
price: z.coerce.number().positive("기준 가격은 0보다 커야 합니다."),
|
||||
orderType: z.enum(["limit", "market"]).default("market"),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
|
||||
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||
message: "KIS API 키 설정이 필요합니다.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
const account = readKisAccountParts(request.headers);
|
||||
if (!account) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
|
||||
message:
|
||||
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
let rawBody: unknown = {};
|
||||
try {
|
||||
rawBody = (await request.json()) as unknown;
|
||||
} catch {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: "요청 본문(JSON)을 읽을 수 없습니다.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = orderableCashBodySchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: parsed.error.issues[0]?.message ?? "요청값이 올바르지 않습니다.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await executeInquireOrderableCash(
|
||||
{
|
||||
symbol: parsed.data.symbol,
|
||||
price: parsed.data.price,
|
||||
orderType: parsed.data.orderType,
|
||||
accountNo: account.accountNo,
|
||||
accountProductCode: account.accountProductCode,
|
||||
},
|
||||
credentials,
|
||||
);
|
||||
|
||||
const response: DashboardStockOrderableCashResponse = {
|
||||
ok: true,
|
||||
tradingEnv,
|
||||
orderableCash: result.orderableCash,
|
||||
noReceivableBuyAmount: result.noReceivableBuyAmount,
|
||||
maxBuyAmount: result.maxBuyAmount,
|
||||
maxBuyQuantity: result.maxBuyQuantity,
|
||||
noReceivableBuyQuantity: result.noReceivableBuyQuantity,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 500,
|
||||
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||
message: toKisApiErrorMessage(error, "매수가능금액 조회 중 오류가 발생했습니다."),
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,19 @@ import {
|
||||
getDomesticOrderBook,
|
||||
KisDomesticOrderBookOutput,
|
||||
} from "@/lib/kis/domestic";
|
||||
import { DashboardStockOrderBookResponse } from "@/features/dashboard/types/dashboard.types";
|
||||
import { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import {
|
||||
KisCredentialInput,
|
||||
hasKisConfig,
|
||||
normalizeTradingEnv,
|
||||
} from "@/lib/kis/config";
|
||||
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
|
||||
parseDomesticKisSession,
|
||||
} from "@/lib/kis/domestic-market-session";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/orderbook/route.ts
|
||||
@@ -16,37 +23,53 @@ import {
|
||||
*/
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const symbol = (searchParams.get("symbol") ?? "").trim();
|
||||
|
||||
if (!/^\d{6}$/.test(symbol)) {
|
||||
return 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);
|
||||
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "KIS API 키 설정이 필요합니다.",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||
message: "KIS API 키 설정이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await getDomesticOrderBook(symbol, credentials);
|
||||
const sessionOverride = readSessionOverrideFromHeaders(request.headers);
|
||||
const raw = await getDomesticOrderBook(symbol, credentials, {
|
||||
sessionOverride,
|
||||
});
|
||||
|
||||
const levels = Array.from({ length: 10 }, (_, i) => {
|
||||
const idx = i + 1;
|
||||
return {
|
||||
askPrice: readOrderBookNumber(raw, `askp${idx}`),
|
||||
bidPrice: readOrderBookNumber(raw, `bidp${idx}`),
|
||||
askSize: readOrderBookNumber(raw, `askp_rsqn${idx}`),
|
||||
bidSize: readOrderBookNumber(raw, `bidp_rsqn${idx}`),
|
||||
askPrice: readOrderBookNumber(raw, `askp${idx}`, `ovtm_untp_askp${idx}`),
|
||||
bidPrice: readOrderBookNumber(raw, `bidp${idx}`, `ovtm_untp_bidp${idx}`),
|
||||
askSize: readOrderBookNumber(
|
||||
raw,
|
||||
`askp_rsqn${idx}`,
|
||||
`ovtm_untp_askp_rsqn${idx}`,
|
||||
),
|
||||
bidSize: readOrderBookNumber(raw, ...resolveBidSizeKeys(idx)),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -54,8 +77,20 @@ export async function GET(request: NextRequest) {
|
||||
symbol,
|
||||
source: "kis",
|
||||
levels,
|
||||
totalAskSize: readOrderBookNumber(raw, "total_askp_rsqn"),
|
||||
totalBidSize: readOrderBookNumber(raw, "total_bidp_rsqn"),
|
||||
totalAskSize: readOrderBookNumber(
|
||||
raw,
|
||||
"total_askp_rsqn",
|
||||
"ovtm_untp_total_askp_rsqn",
|
||||
"ovtm_total_askp_rsqn",
|
||||
),
|
||||
totalBidSize: readOrderBookNumber(
|
||||
raw,
|
||||
"total_bidp_rsqn",
|
||||
"ovtm_untp_total_bidp_rsqn",
|
||||
"ovtm_total_bidp_rsqn",
|
||||
),
|
||||
businessHour: readOrderBookString(raw, "bsop_hour", "ovtm_untp_last_hour"),
|
||||
hourClassCode: readOrderBookString(raw, "hour_cls_code"),
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
@@ -66,37 +101,27 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "호가 조회 중 오류가 발생했습니다.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
return createKisApiErrorResponse({
|
||||
status: 500,
|
||||
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||
message: toKisApiErrorMessage(error, "호가 조회 중 오류가 발생했습니다."),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if (process.env.NODE_ENV === "production") return null;
|
||||
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);
|
||||
return parseDomesticKisSession(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 호가 응답 필드를 대소문자 모두 허용해 숫자로 읽습니다.
|
||||
* @see app/api/kis/domestic/orderbook/route.ts GET에서 output/output1 키 차이 방어 로직으로 사용합니다.
|
||||
*/
|
||||
function readOrderBookNumber(raw: KisDomesticOrderBookOutput, key: string) {
|
||||
function readOrderBookNumber(raw: KisDomesticOrderBookOutput, ...keys: string[]) {
|
||||
const record = raw as Record<string, unknown>;
|
||||
const direct = record[key];
|
||||
const upper = record[key.toUpperCase()];
|
||||
const value = direct ?? upper ?? "0";
|
||||
const value = resolveOrderBookValue(record, keys) ?? "0";
|
||||
const normalized =
|
||||
typeof value === "string"
|
||||
? value.replaceAll(",", "").trim()
|
||||
@@ -104,3 +129,35 @@ function readOrderBookNumber(raw: KisDomesticOrderBookOutput, key: string) {
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 호가 응답 필드를 문자열로 읽습니다.
|
||||
* @see app/api/kis/domestic/orderbook/route.ts GET 응답 생성 시 businessHour/hourClassCode 추출
|
||||
*/
|
||||
function readOrderBookString(raw: KisDomesticOrderBookOutput, ...keys: string[]) {
|
||||
const record = raw as Record<string, unknown>;
|
||||
const value = resolveOrderBookValue(record, keys);
|
||||
if (value === undefined || value === null) return undefined;
|
||||
const text = String(value).trim();
|
||||
return text.length > 0 ? text : undefined;
|
||||
}
|
||||
|
||||
function resolveOrderBookValue(record: Record<string, unknown>, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
const direct = record[key];
|
||||
if (direct !== undefined && direct !== null) return direct;
|
||||
|
||||
const upper = record[key.toUpperCase()];
|
||||
if (upper !== undefined && upper !== null) return upper;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveBidSizeKeys(index: number) {
|
||||
if (index === 2) {
|
||||
return [`bidp_rsqn${index}`, `ovtm_untp_bidp_rsqn${index}`, "ovtm_untp_bidp_rsqn"];
|
||||
}
|
||||
|
||||
return [`bidp_rsqn${index}`, `ovtm_untp_bidp_rsqn${index}`];
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { KOREAN_STOCK_INDEX } from "@/features/dashboard/data/korean-stocks";
|
||||
import type { DashboardStockOverviewResponse } from "@/features/dashboard/types/dashboard.types";
|
||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||
import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks";
|
||||
import type { DashboardStockOverviewResponse } from "@/features/trade/types/trade.types";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import { getDomesticOverview } from "@/lib/kis/domestic";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import {
|
||||
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
|
||||
parseDomesticKisSession,
|
||||
} from "@/lib/kis/domestic-market-session";
|
||||
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/overview/route.ts
|
||||
@@ -16,29 +26,47 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
* @returns 대시보드 상세 모델
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const symbol = (searchParams.get("symbol") ?? "").trim();
|
||||
|
||||
if (!/^\d{6}$/.test(symbol)) {
|
||||
return 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);
|
||||
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||
message:
|
||||
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 시세를 조회할 수 없습니다.",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const fallbackMeta = KOREAN_STOCK_INDEX.find((item) => item.symbol === symbol);
|
||||
|
||||
try {
|
||||
const overview = await getDomesticOverview(symbol, fallbackMeta, credentials);
|
||||
const sessionOverride = readSessionOverrideFromHeaders(request.headers);
|
||||
const overview = await getDomesticOverview(
|
||||
symbol,
|
||||
fallbackMeta,
|
||||
credentials,
|
||||
{ sessionOverride },
|
||||
);
|
||||
|
||||
const response: DashboardStockOverviewResponse = {
|
||||
stock: overview.stock,
|
||||
@@ -55,24 +83,16 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "KIS 조회 중 오류가 발생했습니다.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
return createKisApiErrorResponse({
|
||||
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) {
|
||||
if (process.env.NODE_ENV === "production") return null;
|
||||
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);
|
||||
return parseDomesticKisSession(raw);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { KOREAN_STOCK_INDEX } from "@/features/dashboard/data/korean-stocks";
|
||||
import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks";
|
||||
import type {
|
||||
DashboardStockSearchItem,
|
||||
DashboardStockSearchResponse,
|
||||
KoreanStockIndexItem,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
} from "@/features/trade/types/trade.types";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
} from "@/app/api/kis/_response";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const SEARCH_LIMIT = 10;
|
||||
@@ -15,7 +20,7 @@ const SEARCH_LIMIT = 10;
|
||||
* - [레이어] API Route
|
||||
* - [사용자 행동] 대시보드 검색창 엔터/검색 버튼 클릭 시 호출
|
||||
* - [데이터 흐름] dashboard-main.tsx -> /api/kis/domestic/search -> KOREAN_STOCK_INDEX 필터/정렬 -> JSON 응답
|
||||
* - [연관 파일] features/dashboard/data/korean-stocks.ts, features/dashboard/components/dashboard-main.tsx
|
||||
* - [연관 파일] features/trade/data/korean-stocks.ts, features/trade/components/dashboard-main.tsx
|
||||
* @author jihoon87.lee
|
||||
*/
|
||||
|
||||
@@ -23,9 +28,18 @@ const SEARCH_LIMIT = 10;
|
||||
* 국내주식 검색 API
|
||||
* @param request query string의 q(검색어) 사용
|
||||
* @returns 종목 검색 결과 목록
|
||||
* @see features/dashboard/components/dashboard-main.tsx 검색 폼에서 호출합니다.
|
||||
* @see features/trade/components/dashboard-main.tsx 검색 폼에서 호출합니다.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// [Step 1] query string에서 검색어(q)를 읽고 공백을 제거합니다.
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = (searchParams.get("q") ?? "").trim();
|
||||
|
||||
64
app/api/kis/indices/route.ts
Normal file
64
app/api/kis/indices/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @file app/api/kis/indices/route.ts
|
||||
* @description 국내 KOSPI/KOSDAQ 지수 조회 API
|
||||
*
|
||||
* @description [주요 책임]
|
||||
* - 로그인 및 KIS API 설정 여부 확인
|
||||
* - `getDomesticDashboardIndices` 함수를 호출하여 지수 데이터를 조회
|
||||
* - 조회된 데이터를 클라이언트에 JSON 형식으로 반환
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { hasKisConfig } from "@/lib/kis/config";
|
||||
import { getDomesticDashboardIndices } from "@/lib/kis/dashboard";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||
message: "KIS API 키 설정이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const indices = await getDomesticDashboardIndices(credentials);
|
||||
return NextResponse.json(
|
||||
{
|
||||
indices,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 500,
|
||||
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||
message: toKisApiErrorMessage(
|
||||
error,
|
||||
"지수 조회 중 오류가 발생했습니다.",
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,49 @@
|
||||
import type { DashboardKisRevokeResponse } from "@/features/dashboard/types/dashboard.types";
|
||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import type { DashboardKisRevokeResponse } from "@/features/trade/types/trade.types";
|
||||
import { normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import {
|
||||
parseKisCredentialRequest,
|
||||
validateKisCredentialInput,
|
||||
} from "@/lib/kis/request";
|
||||
import { revokeKisAccessToken } from "@/lib/kis/token";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/revoke/route.ts
|
||||
* @description 사용자 입력 KIS API 키 기반 접근토큰 폐기 라우트
|
||||
* @description 사용자 입력 KIS API 키로 액세스 토큰을 폐기합니다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* KIS API 접근토큰 폐기
|
||||
* @param request appKey/appSecret/tradingEnv JSON 본문
|
||||
* @returns 폐기 성공/실패 정보
|
||||
* @see features/dashboard/components/dashboard-main.tsx handleRevokeKis - 접근 폐기 버튼 클릭 이벤트
|
||||
* @description KIS 액세스 토큰 폐기
|
||||
* @see features/settings/components/KisAuthForm.tsx
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = (await request.json()) as Partial<DashboardKisRevokeRequest>;
|
||||
const credentials = await parseKisCredentialRequest(request);
|
||||
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
|
||||
|
||||
const credentials: KisCredentialInput = {
|
||||
appKey: body.appKey?.trim(),
|
||||
appSecret: body.appSecret?.trim(),
|
||||
tradingEnv: normalizeTradingEnv(body.tradingEnv),
|
||||
};
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
message: "앱 키와 앱 시크릿을 모두 입력해 주세요.",
|
||||
} satisfies DashboardKisRevokeResponse,
|
||||
{ status: 400 },
|
||||
);
|
||||
const invalidMessage = validateKisCredentialInput(credentials);
|
||||
if (invalidMessage) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: invalidMessage,
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -40,25 +51,15 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
tradingEnv,
|
||||
message,
|
||||
} satisfies DashboardKisRevokeResponse);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "API 키 접근 폐기 중 오류가 발생했습니다.";
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
message,
|
||||
} satisfies DashboardKisRevokeResponse,
|
||||
{ status: 401 },
|
||||
);
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.UNAUTHORIZED,
|
||||
message: toKisApiErrorMessage(error, "API 토큰 폐기 중 오류가 발생했습니다."),
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface DashboardKisRevokeRequest {
|
||||
appKey: string;
|
||||
appSecret: string;
|
||||
tradingEnv: "real" | "mock";
|
||||
}
|
||||
|
||||
247
app/api/kis/validate-profile/route.ts
Normal file
247
app/api/kis/validate-profile/route.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import type { DashboardKisProfileValidateResponse } from "@/features/trade/types/trade.types";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import { parseKisAccountParts } from "@/lib/kis/account";
|
||||
import { kisGet } from "@/lib/kis/client";
|
||||
import { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config";
|
||||
import { validateKisCredentialInput } from "@/lib/kis/request";
|
||||
import { getKisAccessToken } from "@/lib/kis/token";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
|
||||
const kisProfileValidateBodySchema = z.object({
|
||||
appKey: z.string().trim().min(1, "앱 키를 입력해 주세요."),
|
||||
appSecret: z.string().trim().min(1, "앱 시크릿을 입력해 주세요."),
|
||||
tradingEnv: z.string().optional(),
|
||||
accountNo: z.string().trim().min(1, "계좌번호를 입력해 주세요."),
|
||||
});
|
||||
|
||||
interface BalanceValidationPreset {
|
||||
inqrDvsn: "01" | "02";
|
||||
prcsDvsn: "00" | "01";
|
||||
}
|
||||
|
||||
const BALANCE_VALIDATION_PRESETS: BalanceValidationPreset[] = [
|
||||
{
|
||||
// 명세 기본 요청값
|
||||
inqrDvsn: "01",
|
||||
prcsDvsn: "01",
|
||||
},
|
||||
{
|
||||
// 일부 계좌/환경 호환값
|
||||
inqrDvsn: "02",
|
||||
prcsDvsn: "00",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @file app/api/kis/validate-profile/route.ts
|
||||
* @description 한국투자증권 계좌번호를 검증합니다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @description 앱키/앱시크릿키 + 계좌번호 유효성을 검증합니다.
|
||||
* @remarks UI 흐름: /settings -> KisProfileForm 확인 버튼 -> /api/kis/validate-profile -> store 반영 -> 대시보드 상태 확장
|
||||
* @see features/settings/components/KisProfileForm.tsx 계좌 확인 버튼에서 호출합니다.
|
||||
* @see features/settings/apis/kis-auth.api.ts validateKisProfile 클라이언트 API 함수
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const fallbackTradingEnv = normalizeTradingEnv(
|
||||
request.headers.get("x-kis-trading-env") ?? undefined,
|
||||
);
|
||||
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
tradingEnv: fallbackTradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
let rawBody: unknown = {};
|
||||
|
||||
try {
|
||||
rawBody = (await request.json()) as unknown;
|
||||
} catch {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: "요청 본문(JSON)을 읽을 수 없습니다.",
|
||||
tradingEnv: fallbackTradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
const parsedBody = kisProfileValidateBodySchema.safeParse(rawBody);
|
||||
if (!parsedBody.success) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message:
|
||||
parsedBody.error.issues[0]?.message ??
|
||||
"요청 본문 값이 올바르지 않습니다.",
|
||||
tradingEnv: fallbackTradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
const body = parsedBody.data;
|
||||
|
||||
const credentials: KisCredentialInput = {
|
||||
appKey: body.appKey.trim(),
|
||||
appSecret: body.appSecret.trim(),
|
||||
tradingEnv: normalizeTradingEnv(body.tradingEnv),
|
||||
};
|
||||
|
||||
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
|
||||
|
||||
const invalidCredentialMessage = validateKisCredentialInput(credentials);
|
||||
if (invalidCredentialMessage) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: invalidCredentialMessage,
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
const accountNoInput = body.accountNo.trim();
|
||||
|
||||
const accountParts = parseKisAccountParts(accountNoInput);
|
||||
if (!accountParts) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
|
||||
message:
|
||||
"계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// 1) 토큰 발급으로 앱키/시크릿 사전 검증
|
||||
try {
|
||||
await getKisAccessToken(credentials);
|
||||
} catch (error) {
|
||||
throw new Error(`앱키 검증 실패: ${toErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
// 2) 계좌 유효성 검증 (실제 계좌 조회 API)
|
||||
try {
|
||||
await validateAccountByBalanceApi(
|
||||
accountParts.accountNo,
|
||||
accountParts.accountProductCode,
|
||||
credentials,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(`계좌 검증 실패: ${toErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
const normalizedAccountNo = `${accountParts.accountNo}-${accountParts.accountProductCode}`;
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
tradingEnv,
|
||||
message: "계좌번호 검증이 완료되었습니다.",
|
||||
account: {
|
||||
normalizedAccountNo,
|
||||
},
|
||||
} satisfies DashboardKisProfileValidateResponse);
|
||||
} catch (error) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.UNAUTHORIZED,
|
||||
message: toKisApiErrorMessage(error, "계좌 검증 중 오류가 발생했습니다."),
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 계좌번호로 잔고 조회 API를 호출해 유효성을 확인합니다.
|
||||
* @param accountNo 계좌번호 앞 8자리
|
||||
* @param accountProductCode 계좌번호 뒤 2자리
|
||||
* @param credentials KIS 인증 정보
|
||||
* @see app/api/kis/validate-profile/route.ts POST
|
||||
*/
|
||||
async function validateAccountByBalanceApi(
|
||||
accountNo: string,
|
||||
accountProductCode: string,
|
||||
credentials: KisCredentialInput,
|
||||
) {
|
||||
const trId = normalizeTradingEnv(credentials.tradingEnv) === "real" ? "TTTC8434R" : "VTTC8434R";
|
||||
const attemptErrors: string[] = [];
|
||||
|
||||
for (const preset of BALANCE_VALIDATION_PRESETS) {
|
||||
try {
|
||||
const response = await kisGet<unknown>(
|
||||
"/uapi/domestic-stock/v1/trading/inquire-balance",
|
||||
trId,
|
||||
{
|
||||
CANO: accountNo,
|
||||
ACNT_PRDT_CD: accountProductCode,
|
||||
AFHR_FLPR_YN: "N",
|
||||
OFL_YN: "",
|
||||
INQR_DVSN: preset.inqrDvsn,
|
||||
UNPR_DVSN: "01",
|
||||
FUND_STTL_ICLD_YN: "N",
|
||||
FNCG_AMT_AUTO_RDPT_YN: "N",
|
||||
PRCS_DVSN: preset.prcsDvsn,
|
||||
CTX_AREA_FK100: "",
|
||||
CTX_AREA_NK100: "",
|
||||
},
|
||||
credentials,
|
||||
);
|
||||
|
||||
validateInquireBalanceResponse(response);
|
||||
return;
|
||||
} catch (error) {
|
||||
attemptErrors.push(
|
||||
`INQR_DVSN=${preset.inqrDvsn}, PRCS_DVSN=${preset.prcsDvsn}: ${toErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`계좌 확인 요청이 모두 실패했습니다. ${attemptErrors.join(" | ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 주식잔고조회 응답 구조를 최소 검증합니다.
|
||||
* @param response KIS 원본 응답
|
||||
* @see app/api/kis/validate-profile/route.ts validateAccountByBalanceApi
|
||||
*/
|
||||
function validateInquireBalanceResponse(
|
||||
response: {
|
||||
output1?: unknown;
|
||||
output2?: unknown;
|
||||
},
|
||||
) {
|
||||
const output1Ok =
|
||||
Array.isArray(response.output1) ||
|
||||
(response.output1 !== null && typeof response.output1 === "object");
|
||||
const output2Ok =
|
||||
Array.isArray(response.output2) ||
|
||||
(response.output2 !== null && typeof response.output2 === "object");
|
||||
|
||||
if (!output1Ok && !output2Ok) {
|
||||
throw new Error("응답에 output1/output2가 없습니다. 요청 파라미터를 확인해 주세요.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Error 객체를 사용자 표시용 문자열로 변환합니다.
|
||||
* @param error unknown 에러
|
||||
* @returns 메시지 문자열
|
||||
* @see app/api/kis/validate-profile/route.ts POST
|
||||
*/
|
||||
function toErrorMessage(error: unknown) {
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
: "알 수 없는 오류가 발생했습니다.";
|
||||
}
|
||||
@@ -1,65 +1,65 @@
|
||||
import type { DashboardKisValidateResponse } from "@/features/dashboard/types/dashboard.types";
|
||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import type { DashboardKisValidateResponse } from "@/features/trade/types/trade.types";
|
||||
import { normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import {
|
||||
parseKisCredentialRequest,
|
||||
validateKisCredentialInput,
|
||||
} from "@/lib/kis/request";
|
||||
import { getKisAccessToken } from "@/lib/kis/token";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/validate/route.ts
|
||||
* @description 사용자 입력 KIS API 키 검증 라우트
|
||||
* @description 사용자 입력 KIS API 키를 검증합니다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* KIS API 키 검증
|
||||
* @param request appKey/appSecret/tradingEnv JSON 본문
|
||||
* @returns 검증 성공/실패 정보
|
||||
* @see features/dashboard/components/dashboard-main.tsx handleValidateKis - 검증 버튼 클릭 시 호출
|
||||
* @description 액세스 토큰 발급 성공 여부로 API 키를 검증합니다.
|
||||
* @see features/settings/components/KisAuthForm.tsx
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = (await request.json()) as Partial<DashboardKisValidateRequest>;
|
||||
const credentials = await parseKisCredentialRequest(request);
|
||||
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
|
||||
|
||||
const credentials: KisCredentialInput = {
|
||||
appKey: body.appKey?.trim(),
|
||||
appSecret: body.appSecret?.trim(),
|
||||
tradingEnv: normalizeTradingEnv(body.tradingEnv),
|
||||
};
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
message: "앱 키와 앱 시크릿을 모두 입력해 주세요.",
|
||||
} satisfies DashboardKisValidateResponse,
|
||||
{ status: 400 },
|
||||
);
|
||||
const invalidMessage = validateKisCredentialInput(credentials);
|
||||
if (invalidMessage) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: invalidMessage,
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// 검증 단계는 토큰 발급 성공 여부만 확인합니다.
|
||||
await getKisAccessToken(credentials);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
tradingEnv,
|
||||
message: "API 키 검증이 완료되었습니다. (토큰 발급 성공)",
|
||||
} satisfies DashboardKisValidateResponse);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "API 키 검증 중 오류가 발생했습니다.";
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
message,
|
||||
} satisfies DashboardKisValidateResponse,
|
||||
{ status: 401 },
|
||||
);
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.UNAUTHORIZED,
|
||||
message: toKisApiErrorMessage(error, "API 키 검증 중 오류가 발생했습니다."),
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface DashboardKisValidateRequest {
|
||||
appKey: string;
|
||||
appSecret: string;
|
||||
tradingEnv: "real" | "mock";
|
||||
}
|
||||
|
||||
@@ -1,38 +1,49 @@
|
||||
import type { DashboardKisWsApprovalResponse } from "@/features/dashboard/types/dashboard.types";
|
||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import type { DashboardKisWsApprovalResponse } from "@/features/trade/types/trade.types";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import { getKisApprovalKey, resolveKisWebSocketUrl } from "@/lib/kis/approval";
|
||||
import { normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import {
|
||||
parseKisCredentialRequest,
|
||||
validateKisCredentialInput,
|
||||
} from "@/lib/kis/request";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/ws/approval/route.ts
|
||||
* @description KIS 웹소켓 approval key 발급 라우트
|
||||
* @description KIS 웹소켓 승인키와 WS URL을 발급합니다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 실시간 웹소켓 승인키 발급
|
||||
* @param request appKey/appSecret/tradingEnv JSON 본문
|
||||
* @returns approval key + ws url
|
||||
* @see features/dashboard/components/dashboard-main.tsx connectKisRealtimePrice - 실시간 체결가 구독 진입점
|
||||
* @description 실시간 웹소켓 연결 정보를 발급합니다.
|
||||
* @see features/trade/hooks/useKisTradeWebSocket.ts
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = (await request.json()) as Partial<DashboardKisWsApprovalRequest>;
|
||||
const credentials = await parseKisCredentialRequest(request);
|
||||
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
|
||||
|
||||
const credentials: KisCredentialInput = {
|
||||
appKey: body.appKey?.trim(),
|
||||
appSecret: body.appSecret?.trim(),
|
||||
tradingEnv: normalizeTradingEnv(body.tradingEnv),
|
||||
};
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
message: "앱 키와 앱 시크릿을 모두 입력해 주세요.",
|
||||
} satisfies DashboardKisWsApprovalResponse,
|
||||
{ status: 400 },
|
||||
);
|
||||
const invalidMessage = validateKisCredentialInput(credentials);
|
||||
if (invalidMessage) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: invalidMessage,
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -41,27 +52,20 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
tradingEnv,
|
||||
approvalKey,
|
||||
wsUrl,
|
||||
message: "KIS 실시간 웹소켓 승인키 발급이 완료되었습니다.",
|
||||
message: "웹소켓 승인키 발급이 완료되었습니다.",
|
||||
} satisfies DashboardKisWsApprovalResponse);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "웹소켓 승인키 발급 중 오류가 발생했습니다.";
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
message,
|
||||
} satisfies DashboardKisWsApprovalResponse,
|
||||
{ status: 401 },
|
||||
);
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.UNAUTHORIZED,
|
||||
message: toKisApiErrorMessage(
|
||||
error,
|
||||
"웹소켓 승인키 발급 중 오류가 발생했습니다.",
|
||||
),
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface DashboardKisWsApprovalRequest {
|
||||
appKey: string;
|
||||
appSecret: string;
|
||||
tradingEnv: "real" | "mock";
|
||||
}
|
||||
|
||||
115
app/globals.css
115
app/globals.css
@@ -1,4 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "tailwindcss-animate";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@@ -6,9 +7,9 @@
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-sans: var(--font-noto-sans-kr);
|
||||
--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-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
@@ -38,16 +39,16 @@
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-brand-50: oklch(0.97 0.02 294);
|
||||
--color-brand-100: oklch(0.93 0.05 294);
|
||||
--color-brand-200: oklch(0.87 0.1 294);
|
||||
--color-brand-300: oklch(0.79 0.15 294);
|
||||
--color-brand-400: oklch(0.7 0.2 294);
|
||||
--color-brand-500: oklch(0.62 0.24 294);
|
||||
--color-brand-600: oklch(0.56 0.26 294);
|
||||
--color-brand-700: oklch(0.49 0.24 295);
|
||||
--color-brand-800: oklch(0.4 0.2 296);
|
||||
--color-brand-900: oklch(0.33 0.14 297);
|
||||
--color-brand-50: var(--brand-50);
|
||||
--color-brand-100: var(--brand-100);
|
||||
--color-brand-200: var(--brand-200);
|
||||
--color-brand-300: var(--brand-300);
|
||||
--color-brand-400: var(--brand-400);
|
||||
--color-brand-500: var(--brand-500);
|
||||
--color-brand-600: var(--brand-600);
|
||||
--color-brand-700: var(--brand-700);
|
||||
--color-brand-800: var(--brand-800);
|
||||
--color-brand-900: var(--brand-900);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
@@ -59,7 +60,8 @@
|
||||
--animate-gradient-x: gradient-x 15s ease infinite;
|
||||
|
||||
@keyframes gradient-x {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
background-size: 200% 200%;
|
||||
background-position: left center;
|
||||
}
|
||||
@@ -71,6 +73,41 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
/* BRAND PALETTE CONTROL
|
||||
* 이 블록만 수정하면 랜딩/대시보드의 보라 톤이 함께 바뀝니다.
|
||||
*/
|
||||
/* 초기 브랜드 보라값(원본 기준) */
|
||||
--brand-50: oklch(0.97 0.02 294);
|
||||
--brand-100: oklch(0.93 0.05 294);
|
||||
--brand-200: oklch(0.87 0.1 294);
|
||||
--brand-300: oklch(0.79 0.15 294);
|
||||
--brand-400: oklch(0.7 0.2 294);
|
||||
--brand-500: oklch(0.62 0.24 294);
|
||||
--brand-600: oklch(0.56 0.26 294);
|
||||
--brand-700: oklch(0.49 0.24 295);
|
||||
--brand-800: oklch(0.4 0.2 296);
|
||||
--brand-900: oklch(0.33 0.14 297);
|
||||
|
||||
/* 차트(canvas): 봉 하락색은 요청대로 파란색 유지 */
|
||||
--brand-chart-background-light: #ffffff;
|
||||
--brand-chart-background-dark: #17131e;
|
||||
--brand-chart-text-light: #6b21a8;
|
||||
--brand-chart-text-dark: #e9d5ff;
|
||||
--brand-chart-border-light: #e9d5ff;
|
||||
--brand-chart-border-dark: rgba(216, 180, 254, 0.3);
|
||||
--brand-chart-grid-light: #f3e8ff;
|
||||
--brand-chart-grid-dark: rgba(216, 180, 254, 0.14);
|
||||
--brand-chart-crosshair-light: #c084fc;
|
||||
--brand-chart-crosshair-dark: rgba(233, 213, 255, 0.75);
|
||||
|
||||
--brand-chart-background: #ffffff;
|
||||
--brand-chart-down: #2563eb;
|
||||
--brand-chart-volume-down: rgba(37, 99, 235, 0.45);
|
||||
--brand-chart-text: #6b21a8;
|
||||
--brand-chart-border: var(--brand-chart-border-light);
|
||||
--brand-chart-grid: var(--brand-chart-grid-light);
|
||||
--brand-chart-crosshair: var(--brand-chart-crosshair-light);
|
||||
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
@@ -78,7 +115,7 @@
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.56 0.26 294);
|
||||
--primary: var(--brand-600);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
@@ -89,7 +126,7 @@
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.62 0.24 294);
|
||||
--ring: var(--brand-500);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
@@ -97,7 +134,7 @@
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.56 0.26 294);
|
||||
--sidebar-primary: var(--brand-600);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
@@ -106,37 +143,45 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
/* 다크 모드 시인성 개선: 배경 대비는 유지하고, 카드/보더/보조 텍스트를 더 읽기 쉽게 조정 */
|
||||
--background: oklch(0.17 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card: oklch(0.235 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover: oklch(0.235 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.56 0.26 294);
|
||||
--primary: var(--brand-600);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary: oklch(0.285 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--muted: oklch(0.285 0 0);
|
||||
--muted-foreground: oklch(0.83 0 0);
|
||||
--accent: oklch(0.285 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.62 0.24 294);
|
||||
--border: oklch(1 0 0 / 18%);
|
||||
--input: oklch(1 0 0 / 22%);
|
||||
--ring: var(--brand-500);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar: oklch(0.235 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.56 0.26 294);
|
||||
--sidebar-primary: var(--brand-600);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent: oklch(0.285 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 18%);
|
||||
--sidebar-ring: oklch(0.78 0 0);
|
||||
|
||||
/* 다크 테마용 차트 배경/격자 대비 */
|
||||
--brand-chart-background: var(--brand-chart-background-dark);
|
||||
--brand-chart-text: var(--brand-chart-text-dark);
|
||||
--brand-chart-border: var(--brand-chart-border-dark);
|
||||
--brand-chart-grid: var(--brand-chart-grid-dark);
|
||||
--brand-chart-crosshair: var(--brand-chart-crosshair-dark);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -146,4 +191,10 @@
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-family: var(--font-jua), var(--font-gowun-sans), sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,16 +9,26 @@
|
||||
*/
|
||||
|
||||
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 { ThemeProvider } from "@/components/theme-provider";
|
||||
import { SessionManager } from "@/features/auth/components/session-manager";
|
||||
import { GlobalAlertModal } from "@/features/layout/components/GlobalAlertModal";
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
const gowunDodum = Gowun_Dodum({
|
||||
weight: "400",
|
||||
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({
|
||||
@@ -26,15 +36,10 @@ const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const outfit = Outfit({
|
||||
variable: "--font-heading",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AutoTrade",
|
||||
description: "Automated Crypto Trading Platform",
|
||||
title: "JOORIN-E (주린이) - 감이 아닌 전략으로 시작하는 자동매매",
|
||||
description:
|
||||
"주린이를 위한 자동매매 파트너 JOORIN-E. 손실 방어 규칙, 데이터 신호 분석, 실시간 자동 실행으로 초보의 첫 수익 루틴을 만듭니다.",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -50,9 +55,14 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className="scroll-smooth" suppressHydrationWarning>
|
||||
<html
|
||||
lang="en"
|
||||
className="scroll-smooth"
|
||||
data-scroll-behavior="smooth"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${outfit.variable} antialiased`}
|
||||
className={`${notoSansKr.variable} ${geistMono.variable} ${gowunDodum.variable} font-sans antialiased`}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
@@ -61,6 +71,7 @@ export default function RootLayout({
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<SessionManager />
|
||||
<GlobalAlertModal />
|
||||
<QueryProvider>{children}</QueryProvider>
|
||||
<Toaster
|
||||
richColors
|
||||
|
||||
30
common-docs/api-reference/kis-error-code-reference.md
Normal file
30
common-docs/api-reference/kis-error-code-reference.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# KIS 오류코드 적용 기준 (2026-02-26)
|
||||
|
||||
## 1) 기준 소스
|
||||
- 공식 오류코드 페이지: `https://apiportal.koreainvestment.com/faq-error-code`
|
||||
- 확인 방식: 실제 브라우저 렌더링 후 테이블 추출
|
||||
- 코드 반영 위치: `lib/kis/error-codes.ts`
|
||||
|
||||
## 2) 코드 반영 목적
|
||||
- `msg_cd`만 보일 때 의미를 바로 알기 어렵기 때문에,
|
||||
코드와 문구를 같이 표시해 장애 원인 파악 속도를 높입니다.
|
||||
- 토큰 발급/폐기, REST 호출, 웹소켓 제어 오류 메시지의 형식을 통일합니다.
|
||||
|
||||
## 3) 적용된 모듈
|
||||
- `lib/kis/error-codes.ts`
|
||||
- 공식 FAQ 코드 문구 매핑
|
||||
- `getKisErrorGuide(msgCode)` 제공
|
||||
- `buildKisErrorDetail(...)` 제공
|
||||
- `lib/kis/client.ts`
|
||||
- REST 실패 메시지에 `msg_cd + 공식 문구` 반영
|
||||
- `lib/kis/token.ts`
|
||||
- 토큰 발급/폐기 실패 메시지에 `msg_cd + 공식 문구` 반영
|
||||
- `lib/kis/approval.ts`
|
||||
- 승인키 발급 실패 메시지에 `msg_cd + 공식 문구` 반영
|
||||
- `features/kis-realtime/stores/kisWebSocketStore.ts`
|
||||
- 실시간 제어 오류(`OPSP*`) 메시지에 공식 문구 반영
|
||||
|
||||
## 4) 운영 시 참고
|
||||
- 화면/로그에 `EGW00103`, `OPSP8996`처럼 코드가 보이면
|
||||
`lib/kis/error-codes.ts`에서 즉시 문구를 확인할 수 있습니다.
|
||||
- 신규 코드가 추가되면 공식 FAQ 기준으로 맵에 추가합니다.
|
||||
466
common-docs/api-reference/kis_api_reference.md
Normal file
466
common-docs/api-reference/kis_api_reference.md
Normal file
@@ -0,0 +1,466 @@
|
||||
# 한국투자증권 Open API 레퍼런스 가이드
|
||||
|
||||
> 이 문서는 Codex, Gemini, Claude 등 AI 어시스턴트가 한국투자증권(KIS) Open API를 기반으로 트레이딩 시스템을 개발하거나 UI/UX를 구성할 때 참고하기 위해 요약된 자료입니다.
|
||||
|
||||
## 📍 공식 사이트 및 주요 도구
|
||||
|
||||
- **공식 Open API 포털 (Main):** [https://apiportal.koreainvestment.com/apiservice-apiservice](https://apiportal.koreainvestment.com/apiservice-apiservice)
|
||||
- **공식 Github (코드 샘플 및 종목 정보):** [https://github.com/koreainvestment/open-trading-api](https://github.com/koreainvestment/open-trading-api)
|
||||
- **공식 챗봇 가이드 (GPTs):** [한국투자증권 Open API 서비스 챗봇](https://chatgpt.com/g/g-68b920ee7afc8191858d3dc05d429571-hangugtujajeunggweon-open-api-seobiseu-gpts)
|
||||
- **API 테스트베드:** [https://apiportal.koreainvestment.com/testbed-intro](https://apiportal.koreainvestment.com/testbed-intro)
|
||||
|
||||
---
|
||||
|
||||
## 📚 API 카테고리별 주요 문서 링크
|
||||
|
||||
한국투자증권 API 포털은 SPA(Single Page Application) 구조로, 각 API 상세 문서는 `https://apiportal.koreainvestment.com/apiservice-apiservice?{API_PATH}` 형태의 파라미터를 사용하여 접근할 수 있습니다.
|
||||
|
||||
### 1. 공통 및 인증 (Essential)
|
||||
|
||||
- **개요 (Summary):** [바로가기](https://apiportal.koreainvestment.com/apiservice-summary)
|
||||
- **종목정보파일 안내:** [바로가기](https://apiportal.koreainvestment.com/apiservice-category)
|
||||
- **OAuth인증 (접근토큰 발급/폐기):** [문서 링크](https://apiportal.koreainvestment.com/apiservice-apiservice?/oauth2/tokenP)
|
||||
- **실시간 (웹소켓) 접속키 발급:** [문서 링크](https://apiportal.koreainvestment.com/apiservice-apiservice?/oauth2/Approval)
|
||||
|
||||
### 2. 국내주식 (Domestic Stocks)
|
||||
|
||||
- **주문/계좌 (현금/신용주문, 잔고조회):** [주식주문(현금) 예시](https://apiportal.koreainvestment.com/apiservice-apiservice?/uapi/domestic-stock/v1/trading/order-cash)
|
||||
- **기본시세 (현재가, 호가, 체결, 일자별):** [주식현재가 시세 예시](https://apiportal.koreainvestment.com/apiservice-apiservice?/uapi/domestic-stock/v1/quotations/inquire-price)
|
||||
- **종목정보 (재무비율, 손익계산서, 대차대조표):** [상품기본조회 예시](https://apiportal.koreainvestment.com/apiservice-apiservice?/uapi/domestic-stock/v1/quotations/search-stock-info)
|
||||
- **시세/순위 분석 (거래량순위, 등락률, 관심종목):** [거래량순위 예시](https://apiportal.koreainvestment.com/apiservice-apiservice?/uapi/domestic-stock/v1/quotations/volume-rank)
|
||||
- **실시간 시세 (Websocket):** [실시간 체결가 (H0STCNT0)](https://apiportal.koreainvestment.com/apiservice-apiservice?/tryitout/H0STCNT0)
|
||||
|
||||
### 3. 해외주식 (Overseas Stocks)
|
||||
|
||||
- **주문/계좌 (미국, 일본, 중국, 홍콩, 베트남):** [해외주식 주문 예시](https://apiportal.koreainvestment.com/apiservice-apiservice?/uapi/overseas-stock/v1/trading/order)
|
||||
- **해외주식 시세 (현재가, 호가, 분봉):** [해외주식 현재가 예시](https://apiportal.koreainvestment.com/apiservice-apiservice?/uapi/overseas-price/v1/quotations/price)
|
||||
- **해외주식 실시간 시세 (Websocket):** [해외주식 실시간체결가 (HDFSCNT0)](https://apiportal.koreainvestment.com/apiservice-apiservice?/tryitout/HDFSCNT0)
|
||||
|
||||
### 4. 기타 금융 상품
|
||||
|
||||
- **국내선물옵션:** 주문, 기본시세, 실시간시세 지원
|
||||
- **해외선물옵션:** 해외선물 종목 상세 및 실시간 시세 지원
|
||||
- **장내채권:** 채권 매수/매도 주문 및 발행정보 시세 지원
|
||||
- **ELW 시세:** 기초자산별 종목 및 LP 매매추이 지원
|
||||
|
||||
---
|
||||
|
||||
## 💡 AI 어시스턴트를 위한 참고 팁
|
||||
|
||||
1. **엔드포인트 조합 규칙:** API 문서상에 표기된 `URL` (`/uapi/...`)을 포털 주소 뒤에 파라미터(`?`)로 붙이면 브라우저에서 해당 문서로 직접 이동이 가능합니다.
|
||||
2. **데이터 타입 주의:** `ORD_QTY`(주문수량), `ORD_UNPR`(주문단가) 등 숫자형 데이터도 **String 가공**이 필요한 경우가 많으므로 문서를 반드시 확인해야 합니다.
|
||||
3. **마스터 데이터:** 종목 코드 및 기본 종목 정보는 API 호출보다는 공지된 전체 종목 마스터 파일(zip)을 다운로드 및 파싱하여 사용하는 것을 KIS에서 권장합니다. 관련 파이썬/Node.js 파싱 코드는 공식 Github 링크를 참고하세요.
|
||||
|
||||
## 📋 KIS API Portal 전체 메뉴 구조 (Reference)
|
||||
|
||||
다음은 한국투자증권 Open API 포털의 전체 좌측 메뉴 구조와 각 API 엔드포인트 URL 리스트입니다. AI가 API 연동 코드를 작성할 때 엔드포인트 참조용으로 사용하세요.
|
||||
|
||||
### 개요
|
||||
|
||||
- 하위 메뉴 없음
|
||||
|
||||
### 종목정보파일
|
||||
|
||||
- 하위 메뉴 없음
|
||||
|
||||
### OAuth인증
|
||||
|
||||
- **접근토큰발급(P)**: `/oauth2/tokenP`
|
||||
- **접근토큰폐기(P)**: `/oauth2/revokeP`
|
||||
- **Hashkey**: `/uapi/hashkey`
|
||||
- **실시간 (웹소켓) 접속키 발급**: `/oauth2/Approval`
|
||||
|
||||
### [국내주식] 주문/계좌
|
||||
|
||||
- **주식주문(현금)**: `/uapi/domestic-stock/v1/trading/order-cash`
|
||||
- **주식주문(신용)**: `/uapi/domestic-stock/v1/trading/order-credit`
|
||||
- **주식주문(정정취소)**: `/uapi/domestic-stock/v1/trading/order-rvsecncl`
|
||||
- **주식정정취소가능주문조회**: `/uapi/domestic-stock/v1/trading/inquire-psbl-rvsecncl`
|
||||
- **주식일별주문체결조회**: `/uapi/domestic-stock/v1/trading/inquire-daily-ccld`
|
||||
- **주식잔고조회**: `/uapi/domestic-stock/v1/trading/inquire-balance`
|
||||
- **매수가능조회**: `/uapi/domestic-stock/v1/trading/inquire-psbl-order`
|
||||
- **매도가능수량조회**: `/uapi/domestic-stock/v1/trading/inquire-psbl-sell`
|
||||
- **신용매수가능조회**: `/uapi/domestic-stock/v1/trading/inquire-credit-psamount`
|
||||
- **주식예약주문**: `/uapi/domestic-stock/v1/trading/order-resv`
|
||||
- **주식예약주문정정취소**: `/uapi/domestic-stock/v1/trading/order-resv-rvsecncl`
|
||||
- **주식예약주문조회**: `/uapi/domestic-stock/v1/trading/order-resv-ccnl`
|
||||
- **퇴직연금 체결기준잔고**: `/uapi/domestic-stock/v1/trading/pension/inquire-present-balance`
|
||||
- **퇴직연금 미체결내역**: `/uapi/domestic-stock/v1/trading/pension/inquire-daily-ccld`
|
||||
- **퇴직연금 매수가능조회**: `/uapi/domestic-stock/v1/trading/pension/inquire-psbl-order`
|
||||
- **퇴직연금 예수금조회**: `/uapi/domestic-stock/v1/trading/pension/inquire-deposit`
|
||||
- **퇴직연금 잔고조회**: `/uapi/domestic-stock/v1/trading/pension/inquire-balance`
|
||||
- **주식잔고조회\_실현손익**: `/uapi/domestic-stock/v1/trading/inquire-balance-rlz-pl`
|
||||
- **투자계좌자산현황조회**: `/uapi/domestic-stock/v1/trading/inquire-account-balance`
|
||||
- **기간별손익일별합산조회**: `/uapi/domestic-stock/v1/trading/inquire-period-profit`
|
||||
- **기간별매매손익현황조회**: `/uapi/domestic-stock/v1/trading/inquire-period-trade-profit`
|
||||
- **주식통합증거금 현황**: `/uapi/domestic-stock/v1/trading/intgr-margin`
|
||||
- **기간별계좌권리현황조회**: `/uapi/domestic-stock/v1/trading/period-rights`
|
||||
|
||||
### [국내주식] 기본시세
|
||||
|
||||
- **주식현재가 시세**: `/uapi/domestic-stock/v1/quotations/inquire-price`
|
||||
- **주식현재가 시세2**: `/uapi/domestic-stock/v1/quotations/inquire-price-2`
|
||||
- **주식현재가 체결**: `/uapi/domestic-stock/v1/quotations/inquire-ccnl`
|
||||
- **주식현재가 일자별**: `/uapi/domestic-stock/v1/quotations/inquire-daily-price`
|
||||
- **주식현재가 호가/예상체결**: `/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn`
|
||||
- **주식현재가 투자자**: `/uapi/domestic-stock/v1/quotations/inquire-investor`
|
||||
- **주식현재가 회원사**: `/uapi/domestic-stock/v1/quotations/inquire-member`
|
||||
- **국내주식기간별시세(일/주/월/년)**: `/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice`
|
||||
- **주식당일분봉조회**: `/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice`
|
||||
- **주식일별분봉조회**: `/uapi/domestic-stock/v1/quotations/inquire-time-dailychartprice`
|
||||
- **주식현재가 당일시간대별체결**: `/uapi/domestic-stock/v1/quotations/inquire-time-itemconclusion`
|
||||
- **주식현재가 시간외일자별주가**: `/uapi/domestic-stock/v1/quotations/inquire-daily-overtimeprice`
|
||||
- **주식현재가 시간외시간별체결**: `/uapi/domestic-stock/v1/quotations/inquire-time-overtimeconclusion`
|
||||
- **국내주식 시간외현재가**: `/uapi/domestic-stock/v1/quotations/inquire-overtime-price`
|
||||
- **국내주식 시간외호가**: `/uapi/domestic-stock/v1/quotations/inquire-overtime-asking-price`
|
||||
- **국내주식 장마감 예상체결가**: `/uapi/domestic-stock/v1/quotations/exp-closing-price`
|
||||
- **ETF/ETN 현재가**: `/uapi/etfetn/v1/quotations/inquire-price`
|
||||
- **ETF 구성종목시세**: `/uapi/etfetn/v1/quotations/inquire-component-stock-price`
|
||||
- **NAV 비교추이(종목)**: `/uapi/etfetn/v1/quotations/nav-comparison-trend`
|
||||
- **NAV 비교추이(일)**: `/uapi/etfetn/v1/quotations/nav-comparison-daily-trend`
|
||||
- **NAV 비교추이(분)**: `/uapi/etfetn/v1/quotations/nav-comparison-time-trend`
|
||||
|
||||
### [국내주식] ELW 시세
|
||||
|
||||
- **ELW 현재가 시세**: `/uapi/domestic-stock/v1/quotations/inquire-elw-price`
|
||||
- **ELW 신규상장종목**: `/uapi/elw/v1/quotations/newly-listed`
|
||||
- **ELW 민감도 순위**: `/uapi/elw/v1/ranking/sensitivity`
|
||||
- **ELW 기초자산별 종목시세**: `/uapi/elw/v1/quotations/udrl-asset-price`
|
||||
- **ELW 종목검색**: `/uapi/elw/v1/quotations/cond-search`
|
||||
- **ELW 당일급변종목**: `/uapi/elw/v1/ranking/quick-change`
|
||||
- **ELW 기초자산 목록조회**: `/uapi/elw/v1/quotations/udrl-asset-list`
|
||||
- **ELW 비교대상종목조회**: `/uapi/elw/v1/quotations/compare-stocks`
|
||||
- **ELW LP매매추이**: `/uapi/elw/v1/quotations/lp-trade-trend`
|
||||
- **ELW 투자지표추이(체결)**: `/uapi/elw/v1/quotations/indicator-trend-ccnl`
|
||||
- **ELW 투자지표추이(분별)**: `/uapi/elw/v1/quotations/indicator-trend-minute`
|
||||
- **ELW 투자지표추이(일별)**: `/uapi/elw/v1/quotations/indicator-trend-daily`
|
||||
- **ELW 변동성 추이(틱)**: `/uapi/elw/v1/quotations/volatility-trend-tick`
|
||||
- **ELW 변동성추이(체결)**: `/uapi/elw/v1/quotations/volatility-trend-ccnl`
|
||||
- **ELW 변동성 추이(일별)**: `/uapi/elw/v1/quotations/volatility-trend-daily`
|
||||
- **ELW 민감도 추이(체결)**: `/uapi/elw/v1/quotations/sensitivity-trend-ccnl`
|
||||
- **ELW 변동성 추이(분별)**: `/uapi/elw/v1/quotations/volatility-trend-minute`
|
||||
- **ELW 민감도 추이(일별)**: `/uapi/elw/v1/quotations/sensitivity-trend-daily`
|
||||
- **ELW 만기예정/만기종목**: `/uapi/elw/v1/quotations/expiration-stocks`
|
||||
- **ELW 지표순위**: `/uapi/elw/v1/ranking/indicator`
|
||||
- **ELW 상승률순위**: `/uapi/elw/v1/ranking/updown-rate`
|
||||
- **ELW 거래량순위**: `/uapi/elw/v1/ranking/volume-rank`
|
||||
|
||||
### [국내주식] 업종/기타
|
||||
|
||||
- **국내업종 현재지수**: `/uapi/domestic-stock/v1/quotations/inquire-index-price`
|
||||
- **국내업종 일자별지수**: `/uapi/domestic-stock/v1/quotations/inquire-index-daily-price`
|
||||
- **국내업종 시간별지수(초)**: `/uapi/domestic-stock/v1/quotations/inquire-index-tickprice`
|
||||
- **국내업종 시간별지수(분)**: `/uapi/domestic-stock/v1/quotations/inquire-index-timeprice`
|
||||
- **업종 분봉조회**: `/uapi/domestic-stock/v1/quotations/inquire-time-indexchartprice`
|
||||
- **국내주식업종기간별시세(일/주/월/년)**: `/uapi/domestic-stock/v1/quotations/inquire-daily-indexchartprice`
|
||||
- **국내업종 구분별전체시세**: `/uapi/domestic-stock/v1/quotations/inquire-index-category-price`
|
||||
- **국내주식 예상체결지수 추이**: `/uapi/domestic-stock/v1/quotations/exp-index-trend`
|
||||
- **국내주식 예상체결 전체지수**: `/uapi/domestic-stock/v1/quotations/exp-total-index`
|
||||
- **변동성완화장치(VI) 현황**: `/uapi/domestic-stock/v1/quotations/inquire-vi-status`
|
||||
- **금리 종합(국내채권/금리)**: `/uapi/domestic-stock/v1/quotations/comp-interest`
|
||||
- **종합 시황/공시(제목)**: `/uapi/domestic-stock/v1/quotations/news-title`
|
||||
- **국내휴장일조회**: `/uapi/domestic-stock/v1/quotations/chk-holiday`
|
||||
- **국내선물 영업일조회**: `/uapi/domestic-stock/v1/quotations/market-time`
|
||||
|
||||
### [국내주식] 종목정보
|
||||
|
||||
- **상품기본조회**: `/uapi/domestic-stock/v1/quotations/search-info`
|
||||
- **주식기본조회**: `/uapi/domestic-stock/v1/quotations/search-stock-info`
|
||||
- **국내주식 대차대조표**: `/uapi/domestic-stock/v1/finance/balance-sheet`
|
||||
- **국내주식 손익계산서**: `/uapi/domestic-stock/v1/finance/income-statement`
|
||||
- **국내주식 재무비율**: `/uapi/domestic-stock/v1/finance/financial-ratio`
|
||||
- **국내주식 수익성비율**: `/uapi/domestic-stock/v1/finance/profit-ratio`
|
||||
- **국내주식 기타주요비율**: `/uapi/domestic-stock/v1/finance/other-major-ratios`
|
||||
- **국내주식 안정성비율**: `/uapi/domestic-stock/v1/finance/stability-ratio`
|
||||
- **국내주식 성장성비율**: `/uapi/domestic-stock/v1/finance/growth-ratio`
|
||||
- **국내주식 당사 신용가능종목**: `/uapi/domestic-stock/v1/quotations/credit-by-company`
|
||||
- **예탁원정보(배당일정)**: `/uapi/domestic-stock/v1/ksdinfo/dividend`
|
||||
- **예탁원정보(주식매수청구일정)**: `/uapi/domestic-stock/v1/ksdinfo/purreq`
|
||||
- **예탁원정보(합병/분할일정)**: `/uapi/domestic-stock/v1/ksdinfo/merger-split`
|
||||
- **예탁원정보(액면교체일정)**: `/uapi/domestic-stock/v1/ksdinfo/rev-split`
|
||||
- **예탁원정보(자본감소일정)**: `/uapi/domestic-stock/v1/ksdinfo/cap-dcrs`
|
||||
- **예탁원정보(상장정보일정)**: `/uapi/domestic-stock/v1/ksdinfo/list-info`
|
||||
- **예탁원정보(공모주청약일정)**: `/uapi/domestic-stock/v1/ksdinfo/pub-offer`
|
||||
- **예탁원정보(실권주일정)**: `/uapi/domestic-stock/v1/ksdinfo/forfeit`
|
||||
- **예탁원정보(의무예치일정)**: `/uapi/domestic-stock/v1/ksdinfo/mand-deposit`
|
||||
- **예탁원정보(유상증자일정)**: `/uapi/domestic-stock/v1/ksdinfo/paidin-capin`
|
||||
- **예탁원정보(무상증자일정)**: `/uapi/domestic-stock/v1/ksdinfo/bonus-issue`
|
||||
- **예탁원정보(주주총회일정)**: `/uapi/domestic-stock/v1/ksdinfo/sharehld-meet`
|
||||
- **국내주식 종목추정실적**: `/uapi/domestic-stock/v1/quotations/estimate-perform`
|
||||
- **당사 대주가능 종목**: `/uapi/domestic-stock/v1/quotations/lendable-by-company`
|
||||
- **국내주식 종목투자의견**: `/uapi/domestic-stock/v1/quotations/invest-opinion`
|
||||
- **국내주식 증권사별 투자의견**: `/uapi/domestic-stock/v1/quotations/invest-opbysec`
|
||||
|
||||
### [국내주식] 시세분석
|
||||
|
||||
- **종목조건검색 목록조회**: `/uapi/domestic-stock/v1/quotations/psearch-title`
|
||||
- **종목조건검색조회**: `/uapi/domestic-stock/v1/quotations/psearch-result`
|
||||
- **관심종목 그룹조회**: `/uapi/domestic-stock/v1/quotations/intstock-grouplist`
|
||||
- **관심종목(멀티종목) 시세조회**: `/uapi/domestic-stock/v1/quotations/intstock-multprice`
|
||||
- **관심종목 그룹별 종목조회**: `/uapi/domestic-stock/v1/quotations/intstock-stocklist-by-group`
|
||||
- **국내기관\_외국인 매매종목가집계**: `/uapi/domestic-stock/v1/quotations/foreign-institution-total`
|
||||
- **외국계 매매종목 가집계**: `/uapi/domestic-stock/v1/quotations/frgnmem-trade-estimate`
|
||||
- **종목별 투자자매매동향(일별)**: `/uapi/domestic-stock/v1/quotations/investor-trade-by-stock-daily`
|
||||
- **시장별 투자자매매동향(시세)**: `/uapi/domestic-stock/v1/quotations/inquire-investor-time-by-market`
|
||||
- **시장별 투자자매매동향(일별)**: `/uapi/domestic-stock/v1/quotations/inquire-investor-daily-by-market`
|
||||
- **종목별 외국계 순매수추이**: `/uapi/domestic-stock/v1/quotations/frgnmem-pchs-trend`
|
||||
- **회원사 실시간 매매동향(틱)**: `/uapi/domestic-stock/v1/quotations/frgnmem-trade-trend`
|
||||
- **주식현재가 회원사 종목매매동향**: `/uapi/domestic-stock/v1/quotations/inquire-member-daily`
|
||||
- **종목별 프로그램매매추이(체결)**: `/uapi/domestic-stock/v1/quotations/program-trade-by-stock`
|
||||
- **종목별 프로그램매매추이(일별)**: `/uapi/domestic-stock/v1/quotations/program-trade-by-stock-daily`
|
||||
- **종목별 외인기관 추정가집계**: `/uapi/domestic-stock/v1/quotations/investor-trend-estimate`
|
||||
- **종목별일별매수매도체결량**: `/uapi/domestic-stock/v1/quotations/inquire-daily-trade-volume`
|
||||
- **프로그램매매 종합현황(시간)**: `/uapi/domestic-stock/v1/quotations/comp-program-trade-today`
|
||||
- **프로그램매매 종합현황(일별)**: `/uapi/domestic-stock/v1/quotations/comp-program-trade-daily`
|
||||
- **프로그램매매 투자자매매동향(당일)**: `/uapi/domestic-stock/v1/quotations/investor-program-trade-today`
|
||||
- **국내주식 신용잔고 일별추이**: `/uapi/domestic-stock/v1/quotations/daily-credit-balance`
|
||||
- **국내주식 예상체결가 추이**: `/uapi/domestic-stock/v1/quotations/exp-price-trend`
|
||||
- **국내주식 공매도 일별추이**: `/uapi/domestic-stock/v1/quotations/daily-short-sale`
|
||||
- **국내주식 시간외예상체결등락률**: `/uapi/domestic-stock/v1/ranking/overtime-exp-trans-fluct`
|
||||
- **국내주식 체결금액별 매매비중**: `/uapi/domestic-stock/v1/quotations/tradprt-byamt`
|
||||
- **국내 증시자금 종합**: `/uapi/domestic-stock/v1/quotations/mktfunds`
|
||||
- **종목별 일별 대차거래추이**: `/uapi/domestic-stock/v1/quotations/daily-loan-trans`
|
||||
- **국내주식 상하한가 포착**: `/uapi/domestic-stock/v1/quotations/capture-uplowprice`
|
||||
- **국내주식 매물대/거래비중**: `/uapi/domestic-stock/v1/quotations/pbar-tratio`
|
||||
|
||||
### [국내주식] 순위분석
|
||||
|
||||
- **거래량순위**: `/uapi/domestic-stock/v1/quotations/volume-rank`
|
||||
- **국내주식 등락률 순위**: `/uapi/domestic-stock/v1/ranking/fluctuation`
|
||||
- **국내주식 호가잔량 순위**: `/uapi/domestic-stock/v1/ranking/quote-balance`
|
||||
- **국내주식 수익자산지표 순위**: `/uapi/domestic-stock/v1/ranking/profit-asset-index`
|
||||
- **국내주식 시가총액 상위**: `/uapi/domestic-stock/v1/ranking/market-cap`
|
||||
- **국내주식 재무비율 순위**: `/uapi/domestic-stock/v1/ranking/finance-ratio`
|
||||
- **국내주식 시간외잔량 순위**: `/uapi/domestic-stock/v1/ranking/after-hour-balance`
|
||||
- **국내주식 우선주/괴리율 상위**: `/uapi/domestic-stock/v1/ranking/prefer-disparate-ratio`
|
||||
- **국내주식 이격도 순위**: `/uapi/domestic-stock/v1/ranking/disparity`
|
||||
- **국내주식 시장가치 순위**: `/uapi/domestic-stock/v1/ranking/market-value`
|
||||
- **국내주식 체결강도 상위**: `/uapi/domestic-stock/v1/ranking/volume-power`
|
||||
- **국내주식 관심종목등록 상위**: `/uapi/domestic-stock/v1/ranking/top-interest-stock`
|
||||
- **국내주식 예상체결 상승/하락상위**: `/uapi/domestic-stock/v1/ranking/exp-trans-updown`
|
||||
- **국내주식 당사매매종목 상위**: `/uapi/domestic-stock/v1/ranking/traded-by-company`
|
||||
- **국내주식 신고/신저근접종목 상위**: `/uapi/domestic-stock/v1/ranking/near-new-highlow`
|
||||
- **국내주식 배당률 상위**: `/uapi/domestic-stock/v1/ranking/dividend-rate`
|
||||
- **국내주식 대량체결건수 상위**: `/uapi/domestic-stock/v1/ranking/bulk-trans-num`
|
||||
- **국내주식 신용잔고 상위**: `/uapi/domestic-stock/v1/ranking/credit-balance`
|
||||
- **국내주식 공매도 상위종목**: `/uapi/domestic-stock/v1/ranking/short-sale`
|
||||
- **국내주식 시간외등락율순위**: `/uapi/domestic-stock/v1/ranking/overtime-fluctuation`
|
||||
- **국내주식 시간외거래량순위**: `/uapi/domestic-stock/v1/ranking/overtime-volume`
|
||||
- **HTS조회상위20종목**: `/uapi/domestic-stock/v1/ranking/hts-top-view`
|
||||
|
||||
### [국내주식] 실시간시세
|
||||
|
||||
- **국내주식 실시간체결가 (KRX)**: `/tryitout/H0STCNT0`
|
||||
- **국내주식 실시간호가 (KRX)**: `/tryitout/H0STASP0`
|
||||
- **국내주식 실시간체결통보**: `/tryitout/H0STCNI0`
|
||||
- **국내주식 실시간예상체결 (KRX)**: `/tryitout/H0STANC0`
|
||||
- **국내주식 실시간회원사 (KRX)**: `/tryitout/H0STMBC0`
|
||||
- **국내주식 실시간프로그램매매 (KRX)**: `/tryitout/H0STPGM0`
|
||||
- **국내주식 장운영정보 (KRX)**: `/tryitout/H0STMKO0`
|
||||
- **국내주식 시간외 실시간호가 (KRX)**: `/tryitout/H0STOAA0`
|
||||
- **국내주식 시간외 실시간체결가 (KRX)**: `/tryitout/H0STOUP0`
|
||||
- **국내주식 시간외 실시간예상체결 (KRX)**: `/tryitout/H0STOAC0`
|
||||
- **국내지수 실시간체결**: `/tryitout/H0UPCNT0`
|
||||
- **국내지수 실시간예상체결**: `/tryitout/H0UPANC0`
|
||||
- **국내지수 실시간프로그램매매**: `/tryitout/H0UPPGM0`
|
||||
- **ELW 실시간호가**: `/tryitout/H0EWASP0`
|
||||
- **ELW 실시간체결가**: `/tryitout/H0EWCNT0`
|
||||
- **ELW 실시간예상체결**: `/tryitout/H0EWANC0`
|
||||
- **국내ETF NAV추이**: `/tryitout/H0STNAV0`
|
||||
- **국내주식 실시간체결가 (통합)**: `/tryitout/H0UNCNT0`
|
||||
- **국내주식 실시간호가 (통합)**: `/tryitout/H0UNASP0`
|
||||
- **국내주식 실시간예상체결 (통합)**: `/tryitout/H0UNANC0`
|
||||
- **국내주식 실시간회원사 (통합)**: `/tryitout/H0UNMBC0`
|
||||
- **국내주식 실시간프로그램매매 (통합)**: `/tryitout/H0UNPGM0`
|
||||
- **국내주식 장운영정보 (통합)**: `/tryitout/H0UNMKO0`
|
||||
- **국내주식 실시간체결가 (NXT)**: `/tryitout/H0NXCNT0`
|
||||
- **국내주식 실시간호가 (NXT)**: `/tryitout/H0NXASP0`
|
||||
- **국내주식 실시간예상체결 (NXT)**: `/tryitout/H0NXANC0`
|
||||
- **국내주식 실시간회원사 (NXT)**: `/tryitout/H0NXMBC0`
|
||||
- **국내주식 실시간프로그램매매 (NXT)**: `/tryitout/H0NXPGM0`
|
||||
- **국내주식 장운영정보 (NXT)**: `/tryitout/H0NXMKO0`
|
||||
|
||||
### [국내선물옵션] 주문/계좌
|
||||
|
||||
- **선물옵션 주문**: `/uapi/domestic-futureoption/v1/trading/order`
|
||||
- **선물옵션 정정취소주문**: `/uapi/domestic-futureoption/v1/trading/order-rvsecncl`
|
||||
- **선물옵션 주문체결내역조회**: `/uapi/domestic-futureoption/v1/trading/inquire-ccnl`
|
||||
- **선물옵션 잔고현황**: `/uapi/domestic-futureoption/v1/trading/inquire-balance`
|
||||
- **선물옵션 주문가능**: `/uapi/domestic-futureoption/v1/trading/inquire-psbl-order`
|
||||
- **(야간)선물옵션 주문체결 내역조회**: `/uapi/domestic-futureoption/v1/trading/inquire-ngt-ccnl`
|
||||
- **(야간)선물옵션 잔고현황**: `/uapi/domestic-futureoption/v1/trading/inquire-ngt-balance`
|
||||
- **(야간)선물옵션 주문가능 조회**: `/uapi/domestic-futureoption/v1/trading/inquire-psbl-ngt-order`
|
||||
- **(야간)선물옵션 증거금 상세**: `/uapi/domestic-futureoption/v1/trading/ngt-margin-detail`
|
||||
- **선물옵션 잔고정산손익내역**: `/uapi/domestic-futureoption/v1/trading/inquire-balance-settlement-pl`
|
||||
- **선물옵션 총자산현황**: `/uapi/domestic-futureoption/v1/trading/inquire-deposit`
|
||||
- **선물옵션 잔고평가손익내역**: `/uapi/domestic-futureoption/v1/trading/inquire-balance-valuation-pl`
|
||||
- **선물옵션 기준일체결내역**: `/uapi/domestic-futureoption/v1/trading/inquire-ccnl-bstime`
|
||||
- **선물옵션기간약정수수료일별**: `/uapi/domestic-futureoption/v1/trading/inquire-daily-amount-fee`
|
||||
|
||||
### [국내선물옵션] 기본시세
|
||||
|
||||
- **선물옵션 시세**: `/uapi/domestic-futureoption/v1/quotations/inquire-price`
|
||||
- **선물옵션 시세호가**: `/uapi/domestic-futureoption/v1/quotations/inquire-asking-price`
|
||||
- **선물옵션기간별시세(일/주/월/년)**: `/uapi/domestic-futureoption/v1/quotations/inquire-daily-fuopchartprice`
|
||||
- **선물옵션 분봉조회**: `/uapi/domestic-futureoption/v1/quotations/inquire-time-fuopchartprice`
|
||||
- **국내옵션전광판\_옵션월물리스트**: `/uapi/domestic-futureoption/v1/quotations/display-board-option-list`
|
||||
- **국내선물 기초자산 시세**: `/uapi/domestic-futureoption/v1/quotations/display-board-top`
|
||||
- **국내옵션전광판\_콜풋**: `/uapi/domestic-futureoption/v1/quotations/display-board-callput`
|
||||
- **국내옵션전광판\_선물**: `/uapi/domestic-futureoption/v1/quotations/display-board-futures`
|
||||
- **선물옵션 일중예상체결추이**: `/uapi/domestic-futureoption/v1/quotations/exp-price-trend`
|
||||
|
||||
### [국내선물옵션] 실시간시세
|
||||
|
||||
- **지수선물 실시간호가**: `/tryitout/H0IFASP0`
|
||||
- **지수선물 실시간체결가**: `/tryitout/H0IFCNT0`
|
||||
- **지수옵션 실시간호가**: `/tryitout/H0IOASP0`
|
||||
- **지수옵션 실시간체결가**: `/tryitout/H0IOCNT0`
|
||||
- **선물옵션 실시간체결통보**: `/tryitout/H0IFCNI0`
|
||||
- **상품선물 실시간호가**: `/tryitout/H0CFASP0`
|
||||
- **상품선물 실시간체결가**: `/tryitout/H0CFCNT0`
|
||||
- **주식선물 실시간호가**: `/tryitout/H0ZFASP0`
|
||||
- **주식선물 실시간체결가**: `/tryitout/H0ZFCNT0`
|
||||
- **주식선물 실시간예상체결**: `/tryitout/H0ZFANC0`
|
||||
- **주식옵션 실시간호가**: `/tryitout/H0ZOASP0`
|
||||
- **주식옵션 실시간체결가**: `/tryitout/H0ZOCNT0`
|
||||
- **주식옵션 실시간예상체결**: `/tryitout/H0ZOANC0`
|
||||
- **KRX야간옵션 실시간호가**: `/tryitout/H0EUASP0`
|
||||
- **KRX야간옵션 실시간체결가**: `/tryitout/H0EUCNT0`
|
||||
- **KRX야간옵션실시간예상체결**: `/tryitout/H0EUANC0`
|
||||
- **KRX야간옵션실시간체결통보**: `/tryitout/H0EUCNI0`
|
||||
- **KRX야간선물 실시간호가**: `/tryitout/H0MFASP0`
|
||||
- **KRX야간선물 실시간종목체결**: `/tryitout/H0MFCNT0`
|
||||
- **KRX야간선물 실시간체결통보**: `/tryitout/H0MFCNI0`
|
||||
|
||||
### [해외주식] 주문/계좌
|
||||
|
||||
- **해외주식 주문**: `/uapi/overseas-stock/v1/trading/order`
|
||||
- **해외주식 정정취소주문**: `/uapi/overseas-stock/v1/trading/order-rvsecncl`
|
||||
- **해외주식 예약주문접수**: `/uapi/overseas-stock/v1/trading/order-resv`
|
||||
- **해외주식 예약주문접수취소**: `/uapi/overseas-stock/v1/trading/order-resv-ccnl`
|
||||
- **해외주식 매수가능금액조회**: `/uapi/overseas-stock/v1/trading/inquire-psamount`
|
||||
- **해외주식 미체결내역**: `/uapi/overseas-stock/v1/trading/inquire-nccs`
|
||||
- **해외주식 잔고**: `/uapi/overseas-stock/v1/trading/inquire-balance`
|
||||
- **해외주식 주문체결내역**: `/uapi/overseas-stock/v1/trading/inquire-ccnl`
|
||||
- **해외주식 체결기준현재잔고**: `/uapi/overseas-stock/v1/trading/inquire-present-balance`
|
||||
- **해외주식 예약주문조회**: `/uapi/overseas-stock/v1/trading/order-resv-list`
|
||||
- **해외주식 결제기준잔고**: `/uapi/overseas-stock/v1/trading/inquire-paymt-stdr-balance`
|
||||
- **해외주식 일별거래내역**: `/uapi/overseas-stock/v1/trading/inquire-period-trans`
|
||||
- **해외주식 기간손익**: `/uapi/overseas-stock/v1/trading/inquire-period-profit`
|
||||
- **해외증거금 통화별조회**: `/uapi/overseas-stock/v1/trading/foreign-margin`
|
||||
- **해외주식 미국주간주문**: `/uapi/overseas-stock/v1/trading/daytime-order`
|
||||
- **해외주식 미국주간정정취소**: `/uapi/overseas-stock/v1/trading/daytime-order-rvsecncl`
|
||||
- **해외주식 지정가주문번호조회**: `/uapi/overseas-stock/v1/trading/algo-ordno`
|
||||
- **해외주식 지정가체결내역조회**: `/uapi/overseas-stock/v1/trading/inquire-algo-ccnl`
|
||||
|
||||
### [해외주식] 기본시세
|
||||
|
||||
- **해외주식 현재가상세**: `/uapi/overseas-price/v1/quotations/price-detail`
|
||||
- **해외주식 현재가 호가**: `/uapi/overseas-price/v1/quotations/inquire-asking-price`
|
||||
- **해외주식 현재체결가**: `/uapi/overseas-price/v1/quotations/price`
|
||||
- **해외주식 체결추이**: `/uapi/overseas-price/v1/quotations/inquire-ccnl`
|
||||
- **해외주식분봉조회**: `/uapi/overseas-price/v1/quotations/inquire-time-itemchartprice`
|
||||
- **해외지수분봉조회**: `/uapi/overseas-price/v1/quotations/inquire-time-indexchartprice`
|
||||
- **해외주식 기간별시세**: `/uapi/overseas-price/v1/quotations/dailyprice`
|
||||
- **해외주식 종목/지수/환율기간별시세(일/주/월/년)**: `/uapi/overseas-price/v1/quotations/inquire-daily-chartprice`
|
||||
- **해외주식조건검색**: `/uapi/overseas-price/v1/quotations/inquire-search`
|
||||
- **해외결제일자조회**: `/uapi/overseas-stock/v1/quotations/countries-holiday`
|
||||
- **해외주식 상품기본정보**: `/uapi/overseas-price/v1/quotations/search-info`
|
||||
- **해외주식 업종별시세**: `/uapi/overseas-price/v1/quotations/industry-theme`
|
||||
- **해외주식 업종별코드조회**: `/uapi/overseas-price/v1/quotations/industry-price`
|
||||
|
||||
### [해외주식] 시세분석
|
||||
|
||||
- **해외주식 가격급등락**: `/uapi/overseas-stock/v1/ranking/price-fluct`
|
||||
- **해외주식 거래량급증**: `/uapi/overseas-stock/v1/ranking/volume-surge`
|
||||
- **해외주식 매수체결강도상위**: `/uapi/overseas-stock/v1/ranking/volume-power`
|
||||
- **해외주식 상승율/하락율**: `/uapi/overseas-stock/v1/ranking/updown-rate`
|
||||
- **해외주식 신고/신저가**: `/uapi/overseas-stock/v1/ranking/new-highlow`
|
||||
- **해외주식 거래량순위**: `/uapi/overseas-stock/v1/ranking/trade-vol`
|
||||
- **해외주식 거래대금순위**: `/uapi/overseas-stock/v1/ranking/trade-pbmn`
|
||||
- **해외주식 거래증가율순위**: `/uapi/overseas-stock/v1/ranking/trade-growth`
|
||||
- **해외주식 거래회전율순위**: `/uapi/overseas-stock/v1/ranking/trade-turnover`
|
||||
- **해외주식 시가총액순위**: `/uapi/overseas-stock/v1/ranking/market-cap`
|
||||
- **해외주식 기간별권리조회**: `/uapi/overseas-price/v1/quotations/period-rights`
|
||||
- **해외뉴스종합(제목)**: `/uapi/overseas-price/v1/quotations/news-title`
|
||||
- **해외주식 권리종합**: `/uapi/overseas-price/v1/quotations/rights-by-ice`
|
||||
- **당사 해외주식담보대출 가능 종목**: `/uapi/overseas-price/v1/quotations/colable-by-company`
|
||||
- **해외속보(제목)**: `/uapi/overseas-price/v1/quotations/brknews-title`
|
||||
|
||||
### [해외주식] 실시간시세
|
||||
|
||||
- **해외주식 실시간호가**: `/tryitout/HDFSASP0`
|
||||
- **해외주식 지연호가(아시아)**: `/tryitout/HDFSASP1`
|
||||
- **해외주식 실시간지연체결가**: `/tryitout/HDFSCNT0`
|
||||
- **해외주식 실시간체결통보**: `/tryitout/H0GSCNI0`
|
||||
|
||||
### [해외선물옵션] 주문/계좌
|
||||
|
||||
- **해외선물옵션 주문**: `/uapi/overseas-futureoption/v1/trading/order`
|
||||
- **해외선물옵션 정정취소주문**: `/uapi/overseas-futureoption/v1/trading/order-rvsecncl`
|
||||
- **해외선물옵션 당일주문내역조회**: `/uapi/overseas-futureoption/v1/trading/inquire-ccld`
|
||||
- **해외선물옵션 미결제내역조회(잔고)**: `/uapi/overseas-futureoption/v1/trading/inquire-unpd`
|
||||
- **해외선물옵션 주문가능조회**: `/uapi/overseas-futureoption/v1/trading/inquire-psamount`
|
||||
- **해외선물옵션 기간계좌손익 일별**: `/uapi/overseas-futureoption/v1/trading/inquire-period-ccld`
|
||||
- **해외선물옵션 일별 체결내역**: `/uapi/overseas-futureoption/v1/trading/inquire-daily-ccld`
|
||||
- **해외선물옵션 예수금현황**: `/uapi/overseas-futureoption/v1/trading/inquire-deposit`
|
||||
- **해외선물옵션 일별 주문내역**: `/uapi/overseas-futureoption/v1/trading/inquire-daily-order`
|
||||
- **해외선물옵션 기간계좌거래내역**: `/uapi/overseas-futureoption/v1/trading/inquire-period-trans`
|
||||
- **해외선물옵션 증거금상세**: `/uapi/overseas-futureoption/v1/trading/margin-detail`
|
||||
|
||||
### [해외선물옵션] 기본시세
|
||||
|
||||
- **해외선물종목현재가**: `/uapi/overseas-futureoption/v1/quotations/inquire-price`
|
||||
- **해외선물종목상세**: `/uapi/overseas-futureoption/v1/quotations/stock-detail`
|
||||
- **해외선물 호가**: `/uapi/overseas-futureoption/v1/quotations/inquire-asking-price`
|
||||
- **해외선물 분봉조회**: `/uapi/overseas-futureoption/v1/quotations/inquire-time-futurechartprice`
|
||||
- **해외선물 체결추이(틱)**: `/uapi/overseas-futureoption/v1/quotations/tick-ccnl`
|
||||
- **해외선물 체결추이(주간)**: `/uapi/overseas-futureoption/v1/quotations/weekly-ccnl`
|
||||
- **해외선물 체결추이(일간)**: `/uapi/overseas-futureoption/v1/quotations/daily-ccnl`
|
||||
- **해외선물 체결추이(월간)**: `/uapi/overseas-futureoption/v1/quotations/monthly-ccnl`
|
||||
- **해외선물 상품기본정보**: `/uapi/overseas-futureoption/v1/quotations/search-contract-detail`
|
||||
- **해외선물 미결제추이**: `/uapi/overseas-futureoption/v1/quotations/investor-unpd-trend`
|
||||
- **해외옵션종목현재가**: `/uapi/overseas-futureoption/v1/quotations/opt-price`
|
||||
- **해외옵션종목상세**: `/uapi/overseas-futureoption/v1/quotations/opt-detail`
|
||||
- **해외옵션 호가**: `/uapi/overseas-futureoption/v1/quotations/opt-asking-price`
|
||||
- **해외옵션 분봉조회**: `/uapi/overseas-futureoption/v1/quotations/inquire-time-optchartprice`
|
||||
- **해외옵션 체결추이(틱)**: `/uapi/overseas-futureoption/v1/quotations/opt-tick-ccnl`
|
||||
- **해외옵션 체결추이(일간)**: `/uapi/overseas-futureoption/v1/quotations/opt-daily-ccnl`
|
||||
- **해외옵션 체결추이(주간)**: `/uapi/overseas-futureoption/v1/quotations/opt-weekly-ccnl`
|
||||
- **해외옵션 체결추이(월간)**: `/uapi/overseas-futureoption/v1/quotations/opt-monthly-ccnl`
|
||||
- **해외옵션 상품기본정보**: `/uapi/overseas-futureoption/v1/quotations/search-opt-detail`
|
||||
- **해외선물옵션 장운영시간**: `/uapi/overseas-futureoption/v1/quotations/market-time`
|
||||
|
||||
### [해외선물옵션]실시간시세
|
||||
|
||||
- **해외선물옵션 실시간체결가**: `/tryitout/HDFFF020`
|
||||
- **해외선물옵션 실시간호가**: `/tryitout/HDFFF010`
|
||||
- **해외선물옵션 실시간주문내역통보**: `/tryitout/HDFFF1C0`
|
||||
- **해외선물옵션 실시간체결내역통보**: `/tryitout/HDFFF2C0`
|
||||
|
||||
### [장내채권] 주문/계좌
|
||||
|
||||
- **장내채권 매수주문**: `/uapi/domestic-bond/v1/trading/buy`
|
||||
- **장내채권 매도주문**: `/uapi/domestic-bond/v1/trading/sell`
|
||||
- **장내채권 정정취소주문**: `/uapi/domestic-bond/v1/trading/order-rvsecncl`
|
||||
- **채권정정취소가능주문조회**: `/uapi/domestic-bond/v1/trading/inquire-psbl-rvsecncl`
|
||||
- **장내채권 주문체결내역**: `/uapi/domestic-bond/v1/trading/inquire-daily-ccld`
|
||||
- **장내채권 잔고조회**: `/uapi/domestic-bond/v1/trading/inquire-balance`
|
||||
- **장내채권 매수가능조회**: `/uapi/domestic-bond/v1/trading/inquire-psbl-order`
|
||||
|
||||
### [장내채권] 기본시세
|
||||
|
||||
- **장내채권현재가(호가)**: `/uapi/domestic-bond/v1/quotations/inquire-asking-price`
|
||||
- **장내채권현재가(시세)**: `/uapi/domestic-bond/v1/quotations/inquire-price`
|
||||
- **장내채권현재가(체결)**: `/uapi/domestic-bond/v1/quotations/inquire-ccnl`
|
||||
- **장내채권현재가(일별)**: `/uapi/domestic-bond/v1/quotations/inquire-daily-price`
|
||||
- **장내채권 기간별시세(일)**: `/uapi/domestic-bond/v1/quotations/inquire-daily-itemchartprice`
|
||||
- **장내채권 평균단가조회**: `/uapi/domestic-bond/v1/quotations/avg-unit`
|
||||
- **장내채권 발행정보**: `/uapi/domestic-bond/v1/quotations/issue-info`
|
||||
- **장내채권 기본조회**: `/uapi/domestic-bond/v1/quotations/search-bond-info`
|
||||
|
||||
### [장내채권] 실시간시세
|
||||
|
||||
- **일반채권 실시간체결가**: `/tryitout/H0BJCNT0`
|
||||
- **일반채권 실시간호가**: `/tryitout/H0BJASP0`
|
||||
- **채권지수 실시간체결가**: `/tryitout/H0BICNT0`
|
||||
BIN
common-docs/api-reference/openapi_all.xlsx
Normal file
BIN
common-docs/api-reference/openapi_all.xlsx
Normal file
Binary file not shown.
266
common-docs/features-autotrade-design.md
Normal file
266
common-docs/features-autotrade-design.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# 브라우저 상주 자동매매 통합 계획서 v3.1 (AI/무저장 정책 반영)
|
||||
|
||||
## 요약
|
||||
1. 자동매매는 브라우저가 켜져 있을 때만 동작합니다.
|
||||
2. 백그라운드 탭(가려진 탭)에서는 동작을 허용합니다.
|
||||
3. 탭 종료, 브라우저 종료, 앱 종료, 외부 페이지 이탈 시 자동주문은 즉시 중지됩니다.
|
||||
4. 종료 직전 강한 경고를 보여주고 중지 이벤트를 서버에 기록합니다.
|
||||
5. 투자금/손실한도는 퍼센트와 금액을 동시에 받고 더 보수적인 값(더 작은 값)을 실적용합니다.
|
||||
6. 전략 선택은 프롬프트 입력, 검수 카탈로그, 온라인 실시간 수집을 모두 지원하며 복수선택 가능합니다.
|
||||
7. 실거래 우선, 장중 기본, 보수적 위험관리 기본값을 유지합니다.
|
||||
8. AI(인공지능)로 매수/매도 신호 후보를 만들고, 최종 주문은 규칙 엔진(고정 검증 로직)이 결정합니다.
|
||||
9. 한국투자증권 API 키/시크릿/계좌번호는 서버 DB에 저장하지 않습니다.
|
||||
10. KIS 민감정보는 브라우저 실행 세션 기준으로만 유지하고, 서버는 요청 처리 시에만 일시 사용합니다.
|
||||
|
||||
## 1) 기술 아키텍처
|
||||
1. 프론트엔드: Next.js 16 App Router + React 19 + TypeScript.
|
||||
2. 상태관리: Zustand 기반 `autotrade-engine-store` 신규.
|
||||
3. 실시간: 기존 KIS WebSocket 스토어 재사용, 자동매매 엔진 훅으로 연결.
|
||||
4. 서버 API: Next.js Route Handler(Node 런타임)로 전략/세션/로그/중지 API 제공.
|
||||
5. 데이터 저장: Supabase Postgres + RLS(행 단위 권한).
|
||||
6. 인증: Supabase Auth 세션 필수.
|
||||
7. 보안: KIS 민감정보는 서버 저장 금지, 요청 단위(한 번 호출)로만 처리.
|
||||
|
||||
## 2) 배포 구조
|
||||
1. 앱 배포: Vercel(기존 유지).
|
||||
2. DB/인증: Supabase(기존 유지).
|
||||
3. 자동매매 엔진: 브라우저 내부 실행(별도 워커 서버 없음).
|
||||
4. 서버 역할: 주문 위임, 상태 기록, 위험한도 검증, 감사로그 저장(민감정보 저장 제외).
|
||||
5. 만료 정리: Vercel Cron(1분 주기) 또는 DB 함수로 heartbeat 만료 세션 `stopped` 전환.
|
||||
6. 장애 로그: Vercel Logs + Supabase Logs + Sentry(권장) 연동.
|
||||
|
||||
## 3) 필수 환경변수
|
||||
1. `NEXT_PUBLIC_SUPABASE_URL`
|
||||
2. `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||
3. `SUPABASE_SERVICE_ROLE_KEY`
|
||||
4. `AUTOTRADE_HEARTBEAT_TTL_SEC` (기본 90)
|
||||
5. `AUTOTRADE_MAX_DAILY_ORDERS_DEFAULT` (기본 20)
|
||||
6. `AUTOTRADE_ONLINE_STRATEGY_ENABLED` (기본 true)
|
||||
7. `ONLINE_STRATEGY_PROVIDER_KEY` (온라인 수집용 키)
|
||||
8. `KIS_SERVER_STORAGE_DISABLED` (고정값 `true`, 서버 저장 차단 가드)
|
||||
|
||||
## 3-1) KIS 키/계좌 무저장 정책(추가)
|
||||
1. 저장 금지 대상: `appKey`, `appSecret`, `accountNo`, `accountProductCode`.
|
||||
2. 서버 DB(Supabase 포함)에는 위 값을 절대 저장하지 않습니다.
|
||||
3. 서버 로그에도 원문을 남기지 않고 마스킹(일부 가리기) 처리합니다.
|
||||
4. 자동매매 요청 시 민감정보는 헤더로 전달하고, 요청 처리 후 즉시 폐기합니다.
|
||||
5. 브라우저 보관은 `sessionStorage` 우선, `localStorage` 영구 저장은 자동매매 모드에서 금지합니다.
|
||||
6. UI 흐름: 설정 UI 입력 -> 메모리/세션 저장 -> API 호출 헤더 전달 -> 서버 즉시 사용 후 폐기.
|
||||
|
||||
## 4) 데이터 모델(Supabase)
|
||||
1. `auto_trade_strategies`
|
||||
2. 주요 컬럼: `user_id`, `name`, `strategy_source_type(prompt|catalog|online)`, `symbols[]`, `allocation_percent`, `allocation_amount`, `effective_allocation_amount`, `daily_loss_percent`, `daily_loss_amount`, `effective_daily_loss_limit`, `resolved_params(jsonb)`, `status`.
|
||||
3. `auto_trade_sessions`
|
||||
4. 주요 컬럼: `strategy_id`, `desired_state`, `runtime_state`, `leader_tab_id`, `last_heartbeat_at`, `started_at`, `ended_at`, `stop_reason`.
|
||||
5. `auto_trade_order_attempts`
|
||||
6. 주요 컬럼: `session_id`, `symbol`, `idempotency_key(unique)`, `request_payload`, `response_payload`, `status`, `blocked_reason`.
|
||||
7. `auto_trade_signal_logs`
|
||||
8. 주요 컬럼: `session_id`, `signal_payload`, `decision(execute|skip|block)`, `decision_reason`, `source_type`, `risk_grade`.
|
||||
9. `auto_trade_online_strategies`
|
||||
10. 주요 컬럼: `title`, `source_url`, `strategy_text`, `fetched_at`, `parser_score`, `risk_grade`, `is_approved`.
|
||||
11. `auto_trade_audit_logs`
|
||||
12. 주요 컬럼: `user_id`, `action`, `payload`, `created_at`.
|
||||
13. `kis_credentials*` 계열 테이블은 만들지 않습니다(무저장 정책).
|
||||
|
||||
## 5) API 설계
|
||||
1. `POST /api/autotrade/strategies/compile`
|
||||
2. 입력: 프롬프트/온라인 텍스트.
|
||||
3. 출력: 표준 규칙(JSON) + 검증결과.
|
||||
4. `POST /api/autotrade/strategies/validate`
|
||||
5. 출력: 실행 가능 여부, 차단 사유.
|
||||
6. `GET /api/autotrade/templates`
|
||||
7. 검수 카탈로그 전략 목록 제공.
|
||||
8. `POST /api/autotrade/strategies/discover`
|
||||
9. 온라인 실시간 수집 전략 목록 제공.
|
||||
10. `POST /api/autotrade/strategies`
|
||||
11. 전략 저장(배분/손실한도 실적용값 계산 포함).
|
||||
12. `POST /api/autotrade/sessions/start`
|
||||
13. 세션 시작 + 리스크 스냅샷 생성.
|
||||
14. `POST /api/autotrade/sessions/heartbeat`
|
||||
15. 리더 탭 생존신호 갱신.
|
||||
16. `POST /api/autotrade/sessions/stop`
|
||||
17. `reason`: `browser_exit|external_leave|manual|emergency|heartbeat_timeout`.
|
||||
18. `GET /api/autotrade/sessions/active`
|
||||
19. 현재 실행 세션/리더 정보 조회.
|
||||
20. `GET /api/autotrade/sessions/{id}/logs`
|
||||
21. 신호/주문/오류 로그 조회.
|
||||
22. 자동매매 관련 API(주문/세션/리스크)는 요청 헤더에 KIS 정보 포함이 필수입니다.
|
||||
23. 서버는 헤더 값 유효성만 검사하고 DB에는 저장하지 않습니다.
|
||||
24. 실패 응답/에러 로그에서도 민감정보는 마스킹합니다.
|
||||
|
||||
## 5-1) AI 자동매매 설계(추가)
|
||||
1. 핵심 원칙: AI는 "신호 후보 생성기", 최종 주문 판단은 "규칙 엔진"이 담당.
|
||||
2. 이유: AI 단독 주문은 일관성(항상 같은 판단)과 추적성이 약해 리스크가 큽니다.
|
||||
3. AI 입력 데이터:
|
||||
4. 실시간 체결/호가, 최근 변동성, 거래량, 전략 파라미터, 장 상태(정규장/시간외).
|
||||
5. AI 출력 데이터:
|
||||
6. `signal`(buy/sell/hold), `confidence`(신뢰도), `reason`(한 줄 근거), `ttlSec`(신호 유효시간).
|
||||
7. 실행 흐름:
|
||||
8. 사용자 전략 선택/프롬프트 입력 -> AI 해석 -> 규칙 JSON 변환 -> 리스크 검증 -> 주문 실행/차단.
|
||||
9. 온라인 유명 단타 기법 처리:
|
||||
10. 실시간 수집 -> 정규화(형식 맞추기) -> 위험등급 부여 -> 사용자 선택 -> 검증 통과 시 활성화.
|
||||
11. AI 장애 대응:
|
||||
12. AI 응답 지연/실패 시 신규 주문 중지 또는 보수 모드(`hold`) 강제.
|
||||
13. AI 드리프트(성능 저하) 대응:
|
||||
14. 최근 N건 성능 추적 후 기준 미달 전략 자동 일시정지.
|
||||
15. UI 흐름:
|
||||
16. 전략 화면 -> "AI 제안 받기" 클릭 -> 제안 전략 목록 표시 -> 사용자 선택/수정 -> 저장/시뮬레이션 -> 시작.
|
||||
17. 운영 기본값:
|
||||
18. `confidence`가 임계치(예: 0.65) 미만이면 주문 차단.
|
||||
19. `reason`이 비어 있으면 주문 차단(설명 없는 주문 금지).
|
||||
20. 동일 종목 반대 신호가 짧은 시간에 반복되면 쿨다운 연장.
|
||||
|
||||
## 5-2) 자동매매 설정 팝업 UX(사용자 요청 반영)
|
||||
1. 진입 흐름:
|
||||
2. 자동매매 버튼 클릭 -> 자동매매 설정 팝업 오픈 -> 설정 입력 -> "자동매매 시작" 클릭.
|
||||
3. 팝업 필수 입력:
|
||||
4. 전략 프롬프트(자유 입력)
|
||||
5. 유명 기법 선택(복수 선택): ORB(시가 범위 돌파), VWAP 되돌림, 거래량 돌파, 이동평균 교차, 갭 돌파.
|
||||
6. 투자금 설정: 퍼센트(%) + 금액(원) 동시 입력.
|
||||
7. 전략별 일일 손실한도: 퍼센트(%) + 금액(원) 동시 입력.
|
||||
8. 거래 대상: 종목 다중 선택(또는 관심종목 가져오기).
|
||||
9. 실행 전 검증:
|
||||
10. AI 해석 결과 미리보기(어떤 근거로 매수/매도할지 요약)
|
||||
11. 리스크 요약(실적용 투자금, 실적용 손실한도, 예상 최대 주문 수)
|
||||
12. 동의 체크(브라우저 종료/외부 이탈 시 즉시 중지)
|
||||
13. 버튼 정책:
|
||||
14. 필수값 누락 또는 검증 실패 시 시작 버튼 비활성화.
|
||||
15. 시작 성공 시 상단 고정 배너와 세션 상태 카드 즉시 표시.
|
||||
|
||||
## 5-3) AI API 선택 권장안(실행 가능한 추천)
|
||||
1. 결론:
|
||||
2. 1차는 OpenAI API를 기본으로 시작하고, 2차에서 Gemini/Claude를 붙일 수 있게 다중 제공자 어댑터(연결 레이어) 구조로 개발합니다.
|
||||
3. 추천 이유(요약):
|
||||
4. Structured Outputs(스키마 고정 출력) + Function Calling(함수 호출) 문서/생태계가 성숙해서 자동매매 검증 파이프라인 구성에 유리합니다.
|
||||
5. 비용/속도 최적화 모델 선택지가 넓어 PoC(개념검증) -> 운영 전환이 쉽습니다.
|
||||
6. 제공자별 특징:
|
||||
7. OpenAI: 엄격 모드(`strict`) 기반 함수 스키마 강제가 명확하고, `parallel_tool_calls=false`로 1회 1액션 제어가 쉽습니다.
|
||||
8. Gemini: 함수 호출 모드(`AUTO`/`ANY`/`NONE`/`VALIDATED`)가 명확하고 JSON 스키마 출력 지원이 좋아 대체 제공자로 적합합니다.
|
||||
9. Claude: `strict: true` 도구 호출과 구조화 출력이 강점이며, 보조/백업 제공자로 적합합니다.
|
||||
10. 운영 권장:
|
||||
11. 1차: OpenAI 단일 운영
|
||||
12. 2차: OpenAI 실패/지연 시 Gemini 폴백(대체 경로)
|
||||
13. 3차: Claude까지 확장하는 3중화(고가용성)
|
||||
|
||||
## 5-4) AI 판단 -> 주문 실행 파이프라인(실전형)
|
||||
1. Step 1. 입력 수집:
|
||||
2. 사용자 프롬프트 + 선택한 유명 기법 + 실시간 시세/호가 + 보유/가용자산 + 리스크 한도.
|
||||
3. Step 2. AI 해석:
|
||||
4. AI가 `signal`, `confidence`, `reason`, `ttlSec`, `proposed_order`를 JSON으로 반환.
|
||||
5. Step 3. 규칙 엔진 검증:
|
||||
6. 스키마 검증(형식), 정책 검증(리스크), 시장상태 검증(장중 여부), 중복주문 검증(idempotency).
|
||||
7. Step 4. 주문 결정:
|
||||
8. 검증 통과 -> KIS 주문 API 호출.
|
||||
9. 검증 실패 -> 주문 차단 + 사유 로그 기록.
|
||||
10. Step 5. 사후 평가:
|
||||
11. 체결/미체결 결과를 AI 평가 입력으로 재사용해 프롬프트/기법 가중치 조정.
|
||||
|
||||
## 5-5) AI 호출 프롬프트/출력 표준(권장 JSON)
|
||||
1. 시스템 프롬프트 핵심:
|
||||
2. "너는 주문 실행기가 아니라 신호 생성기다. 스키마에 맞는 JSON만 반환하고 설명문은 금지한다."
|
||||
3. 출력 스키마:
|
||||
4. `signal`: `buy|sell|hold`
|
||||
5. `confidence`: `0~1`
|
||||
6. `reason`: 짧은 한국어 근거
|
||||
7. `proposed_order`: `{symbol, side, orderType, price, quantity}`
|
||||
8. `risk_flags`: `string[]`
|
||||
9. `ttlSec`: 신호 만료 시간
|
||||
10. 차단 규칙:
|
||||
11. `confidence < threshold` 또는 `reason` 누락 또는 `risk_flags`에 차단 사유 포함 시 주문 금지.
|
||||
|
||||
## 5-6) 서버 무저장 정책과 AI 호출 결합 방식
|
||||
1. KIS 민감정보(`appKey`, `appSecret`, `accountNo`)는 AI API 호출 입력에 넣지 않습니다.
|
||||
2. AI에는 가격/지표/포지션 요약 같은 비식별 데이터(개인 식별이 어려운 데이터)만 전달합니다.
|
||||
3. 실제 주문 직전 단계에서만 브라우저 세션의 KIS 정보로 주문 API를 호출합니다.
|
||||
4. 서버는 주문 처리 중 헤더를 일시 사용 후 폐기하며 DB/로그 저장을 금지합니다.
|
||||
5. 에러 로그/감사로그에는 주문 사유와 결과만 남기고 민감값은 마스킹 처리합니다.
|
||||
|
||||
## 6) 브라우저 엔진 동작
|
||||
1. 엔진 상태: `IDLE`, `ARMED`, `RUNNING`, `STOPPING`, `STOPPED`, `ERROR`.
|
||||
2. 멀티탭 제어: `localStorage` lock + `BroadcastChannel` 동기화.
|
||||
3. 리더 탭만 주문 실행, 팔로워 탭은 조회 전용.
|
||||
4. 주문은 틱 이벤트(WebSocket 수신) 기반으로 처리해 백그라운드 타이머 지연 영향을 줄입니다.
|
||||
5. heartbeat 10초 주기 전송, TTL 90초 초과 시 서버 강제 종료.
|
||||
6. 새로고침 시 로컬 snapshot으로 이어서 실행.
|
||||
7. 브라우저 완전 종료 후 재진입 시 자동 재개 금지, `중지 상태`로 복구 후 사용자 재시작 필요.
|
||||
8. 백그라운드 탭에서도 WebSocket 이벤트 기반으로 신호 계산/주문은 유지합니다.
|
||||
|
||||
## 7) 강한 경고/즉시 중지 UX
|
||||
1. 실행 중 상단 빨간 경고 바 고정: "브라우저/탭 종료 또는 외부 이동 시 자동주문이 즉시 중지됩니다."
|
||||
2. 외부 링크 클릭 시 사전 모달 강제: "이동하면 자동매매가 중지됩니다. 계속할까요?"
|
||||
3. 탭 닫기/브라우저 종료는 `beforeunload` 기본 경고 사용.
|
||||
4. 종료 시퀀스: `STOPPING` 전환 -> 신규 주문 차단 -> `sendBeacon(stop)` -> lock 해제 -> `STOPPED`.
|
||||
5. 브라우저 보안 제한으로 `beforeunload` 커스텀 문구는 사용하지 않습니다(표준 경고만 가능).
|
||||
|
||||
## 8) 자산 배분/손실한도 입력 규칙
|
||||
1. 투자금 입력: `퍼센트(%)` + `금액(원)` 동시 입력.
|
||||
2. 실적용 투자금: `min(가용자산*퍼센트, 금액)`.
|
||||
3. 일일 손실한도 입력: `퍼센트(%)` + `금액(원)` 동시 입력.
|
||||
4. 실적용 손실한도: `min(전략투자금*퍼센트, 금액)`.
|
||||
5. UI에 실적용 값 실시간 계산 표시.
|
||||
6. 유효성 검증: 0보다 큰 값, 최대 퍼센트 상한, 가용자산 초과 금액 차단.
|
||||
7. UI에 "현재 가용자산 기준 실제 주문 가능 금액"을 즉시 표시합니다.
|
||||
|
||||
## 9) 전략 선택 체계(복수선택)
|
||||
1. 소스 탭 3개: `프롬프트`, `검수 카탈로그`, `온라인 실시간 수집`.
|
||||
2. 사용자는 소스별 전략을 여러 개 선택해 하나의 실행세트로 저장 가능.
|
||||
3. 프롬프트 전략: 자연어 입력 -> 컴파일 -> 검증 통과 시 활성화.
|
||||
4. 카탈로그 전략: 운영 검수 완료 버전만 제공.
|
||||
5. 온라인 전략: 실시간 수집 결과를 보여주되 검증 통과 전에는 실행 금지.
|
||||
6. 온라인/프롬프트 전략은 위험등급(`low|mid|high`) 자동 부여 후 실행 제한에 반영.
|
||||
|
||||
## 10) 보수적 위험관리 기본값
|
||||
1. 전략별 일일 손실한도 기본 2%.
|
||||
2. 전략별 일일 최대 주문 20건.
|
||||
3. 종목별 주문 쿨다운 60초.
|
||||
4. 단일 주문 상한: 전략 투자금의 25%.
|
||||
5. 데이터 지연 5초 초과 시 신규 주문 차단.
|
||||
6. 연속 실패 3회 시 자동 중지.
|
||||
7. lock 충돌 2회 이상 시 자동 중지.
|
||||
8. 비상정지 버튼은 언제나 최상단 고정 노출.
|
||||
|
||||
## 11) 구현 파일 범위
|
||||
1. `features/autotrade/components/*` (전략 선택, 배분 입력, 경고 배너, 실행 상태 패널)
|
||||
2. `features/autotrade/hooks/useAutotradeEngine.ts`
|
||||
3. `features/autotrade/stores/use-autotrade-engine-store.ts`
|
||||
4. `features/autotrade/types/autotrade.types.ts`
|
||||
5. `app/api/autotrade/**/route.ts`
|
||||
6. `lib/autotrade/*` (컴파일, 검증, 리스크 게이트, lock 유틸)
|
||||
7. 기존 `TradeContainer`/`OrderForm`에 자동매매 섹션 통합
|
||||
8. `features/settings/store/use-kis-runtime-store.ts` 자동매매 모드에서 민감정보 `persist` 제외
|
||||
9. `app/api/kis/*` 및 `app/api/autotrade/*` 민감정보 마스킹 유틸 공통 적용
|
||||
|
||||
## 12) 테스트 시나리오
|
||||
1. 멀티탭 3개에서 리더 1개만 주문하는지 확인.
|
||||
2. 백그라운드 탭에서 실시간 신호 기반 주문이 유지되는지 확인.
|
||||
3. 외부 링크 이탈 시 강한 경고 후 즉시 중지되는지 확인.
|
||||
4. 탭 종료/브라우저 종료에서 `sendBeacon` + TTL 강제종료가 동작하는지 확인.
|
||||
5. 퍼센트+금액 입력 시 실적용 값이 작은 값으로 계산되는지 확인.
|
||||
6. 전략별 일일 손실한도 초과 시 즉시 차단되는지 확인.
|
||||
7. 온라인 전략 검증 실패 시 실행이 막히는지 확인.
|
||||
8. 새로고침 후 동일 세션이 중복주문 없이 이어지는지 확인.
|
||||
9. 서버 DB/로그에 KIS 키/계좌 원문이 저장되지 않는지 확인.
|
||||
10. AI 응답 누락/지연 시 주문이 차단되는지 확인.
|
||||
11. AI `confidence` 임계치 미만에서 주문 차단되는지 확인.
|
||||
|
||||
## 13) 단계별 배포 계획
|
||||
1. 1주차: DB 마이그레이션 + API 골격 + 타입 정의.
|
||||
2. 2주차: 브라우저 엔진(lock/heartbeat/stop flow) + 기본 UI.
|
||||
3. 3주차: 전략 소스 3종(프롬프트/카탈로그/온라인) + 컴파일/검증.
|
||||
4. 4주차: 리스크 정책 완성 + 통합/E2E + 운영 모니터링.
|
||||
5. 롤아웃: 기능 플래그로 5% 사용자 -> 30% -> 전체 오픈.
|
||||
|
||||
## 14) 수용 기준
|
||||
1. 실행 중 종료 트리거 발생 시 신규 주문이 즉시 0건이어야 합니다.
|
||||
2. 멀티탭에서 중복 주문이 발생하지 않아야 합니다.
|
||||
3. 사용자는 전략별 투자금/손실한도를 퍼센트+금액으로 모두 설정할 수 있어야 합니다.
|
||||
4. 프롬프트/카탈로그/온라인 전략 복수선택 저장과 실행이 가능해야 합니다.
|
||||
5. 로그 화면에서 신호-판단-주문-중지 이유가 연결되어 추적 가능해야 합니다.
|
||||
|
||||
## 15) 명시적 가정/기본값
|
||||
1. "다른 페이지 이동"은 외부 도메인 이탈 기준입니다.
|
||||
2. 앱 내부 라우트 이동은 중지 트리거가 아닙니다.
|
||||
3. 브라우저가 완전히 종료되면 자동매매는 반드시 중지 상태로 종료됩니다.
|
||||
4. 브라우저 재진입 시 자동 재개는 하지 않고 사용자 재시작으로만 실행합니다.
|
||||
5. 온라인 전략은 "실시간 수집 가능"이지만 "검증 통과 후 실행"을 강제합니다.
|
||||
6. KIS API 키/계좌 정보는 서버 저장을 금지하고 요청 단위 처리만 허용합니다.
|
||||
82
common-docs/features/autotrade-model-catalog-runbook.md
Normal file
82
common-docs/features/autotrade-model-catalog-runbook.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# 자동매매 모델 카탈로그 운영 런북 (Codex/Gemini)
|
||||
|
||||
이 문서는 **새 모델이 나왔을 때** 자동매매 모델 선택 UI/서버 설정을 안전하게 갱신하기 위한 운영 절차입니다.
|
||||
|
||||
## 1) 목적
|
||||
|
||||
1. Codex/Gemini 신모델을 빠르게 목록에 반영한다.
|
||||
2. 잘못된 모델 ID로 인해 자동매매가 fallback으로 떨어지는 문제를 줄인다.
|
||||
3. 운영자가 "어디를 고치고 어떻게 검증하는지"를 한 번에 확인할 수 있게 한다.
|
||||
|
||||
## 2) 적용 범위
|
||||
|
||||
1. 자동매매 설정창 모델 드롭다운
|
||||
2. 서버 모델 선택 우선순위(env + UI)
|
||||
3. 전략/신호 응답에서 `providerVendor`, `providerModel` 추적
|
||||
|
||||
## 3) 빠른 절차 (입력 -> 처리 -> 결과)
|
||||
|
||||
1. 입력: 공식 문서에서 신규 모델 ID 확인
|
||||
2. 처리: 모델 옵션 상수 + 안내 문구 + 기본 env 값 점검
|
||||
3. 결과: UI 선택 가능 + 로그/응답에서 실제 모델 확인 가능
|
||||
|
||||
## 4) 공식 소스(항상 여기 먼저 확인)
|
||||
|
||||
1. OpenAI Codex CLI: <https://developers.openai.com/codex/cli>
|
||||
2. OpenAI Models: <https://platform.openai.com/docs/models>
|
||||
3. Gemini CLI model command: <https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/model.md>
|
||||
4. Gemini CLI model routing: <https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/model-routing.md>
|
||||
5. Gemini API models: <https://ai.google.dev/gemini-api/docs/models>
|
||||
|
||||
## 5) 코드 반영 위치
|
||||
|
||||
1. 모델 드롭다운 목록
|
||||
- `features/autotrade/types/autotrade.types.ts`
|
||||
- `AUTOTRADE_SUBSCRIPTION_CLI_MODEL_OPTIONS.codex`
|
||||
- `AUTOTRADE_SUBSCRIPTION_CLI_MODEL_OPTIONS.gemini`
|
||||
2. 기본값/우선순위 점검
|
||||
- `lib/autotrade/strategy.ts` (`resolveDefaultSubscriptionCliModel`)
|
||||
- `lib/autotrade/cli-provider.ts` (`resolveSubscriptionCliModel`)
|
||||
3. 사용자 안내 문구(필요 시)
|
||||
- `features/autotrade/components/AutotradeControlPanel.tsx`
|
||||
4. 샘플 환경변수 문서화
|
||||
- `.env.example`
|
||||
|
||||
## 6) 모델 추가 규칙
|
||||
|
||||
1. 모델 ID는 **공식 문서 표기 그대로** 입력한다.
|
||||
2. preview 모델은 라벨에 `(프리뷰)`를 명시한다.
|
||||
3. 종료 예정 모델은 라벨/설명에 종료 예정일을 남긴다.
|
||||
4. 기존 안정형 모델 1개 이상은 항상 남겨둔다.
|
||||
5. 목록에 없는 모델도 쓸 수 있도록 `직접 입력` 경로는 유지한다.
|
||||
|
||||
## 7) 검증 체크리스트
|
||||
|
||||
- [ ] 드롭다운에 신규 모델이 보인다.
|
||||
- [ ] 신규 모델 선택 후 compile/signal 요청 payload에 `subscriptionCliModel`이 들어간다.
|
||||
- [ ] 응답에 `providerVendor`, `providerModel`이 기대값으로 온다.
|
||||
- [ ] 자동매매 로그에 `subscription_cli:vendor:model`이 표시된다.
|
||||
- [ ] `npm run -s lint` 통과
|
||||
|
||||
## 8) 수동 검증 포인트(화면 기준)
|
||||
|
||||
1. 자동매매 설정 -> 구독형 CLI 엔진 선택(codex 또는 gemini)
|
||||
2. 신규 모델 선택 후 자동매매 시작
|
||||
3. 로그에서 아래 3개 필드 확인
|
||||
- `subscriptionCliVendor`
|
||||
- `subscriptionCliModel`
|
||||
- `providerModel`
|
||||
|
||||
## 9) 장애 대응
|
||||
|
||||
1. 모델 호출 실패 시 우선 `직접 입력`으로 동일 ID 재시도
|
||||
2. 계속 실패하면 직전 안정 모델로 즉시 롤백
|
||||
3. `AUTOTRADE_SUBSCRIPTION_CLI_DEBUG=1`로 서버 로그에서 CLI stderr 확인
|
||||
|
||||
## 10) 변경 이력 템플릿
|
||||
|
||||
```md
|
||||
- YYYY-MM-DD: [vendor] modelA, modelB 추가
|
||||
- YYYY-MM-DD: [vendor] modelX 종료 예정 표기
|
||||
- YYYY-MM-DD: 기본 추천 모델 변경 (old -> new)
|
||||
```
|
||||
144
common-docs/features/autotrade-prompt-flow-guide.md
Normal file
144
common-docs/features/autotrade-prompt-flow-guide.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# 자동매매 프롬프트 흐름 추적 가이드 (UI -> 함수 -> AI -> 주문)
|
||||
|
||||
이 문서는 "전략 프롬프트를 입력하면 실제로 어디 함수로 흘러가고, 어디서 AI가 호출되는지"를 코드 라인 기준으로 설명합니다.
|
||||
|
||||
## 1) 한 줄 요약
|
||||
|
||||
사용자가 UI에 프롬프트를 입력하면, 시작/검증 시점에 `compile` API로 전달되어 전략 JSON으로 바뀌고, 실행 중에는 그 전략 JSON + 실시간 시세로 신호를 생성해 주문 여부를 결정합니다.
|
||||
|
||||
## 2) 구조 그림
|
||||
|
||||
```text
|
||||
[브라우저 UI]
|
||||
AutotradeControlPanel.tsx
|
||||
└─ 프롬프트 입력 + 시작/검증 클릭
|
||||
│
|
||||
▼
|
||||
[브라우저 엔진 훅]
|
||||
useAutotradeEngine.ts
|
||||
└─ prepareStrategy()에서 compile/validate 실행
|
||||
│
|
||||
▼
|
||||
[브라우저 API 클라이언트]
|
||||
autotrade.api.ts
|
||||
└─ /api/autotrade/strategies/compile 호출
|
||||
│
|
||||
▼
|
||||
[Next 서버 route]
|
||||
strategies/compile/route.ts
|
||||
└─ OpenAI / subscription_cli / fallback 분기
|
||||
│
|
||||
▼
|
||||
[AI Provider]
|
||||
openai.ts 또는 cli-provider.ts
|
||||
└─ 전략 JSON 반환
|
||||
│
|
||||
▼
|
||||
[브라우저 엔진 훅]
|
||||
useAutotradeEngine.ts
|
||||
└─ compiledStrategy 저장 후 실행 루프 시작
|
||||
│
|
||||
▼
|
||||
[신호 루프]
|
||||
/api/autotrade/signals/generate -> 리스크 게이트 -> 주문 API
|
||||
```
|
||||
|
||||
## 3) 프롬프트 입력 -> 전략 컴파일 (상세 추적)
|
||||
|
||||
1. 프롬프트 입력 UI
|
||||
- 컴포넌트: [`AutotradeControlPanel.tsx#L335`](../../features/autotrade/components/AutotradeControlPanel.tsx#L335)
|
||||
- 입력 이벤트: [`handlePromptChange`](../../features/autotrade/components/AutotradeControlPanel.tsx#L123)
|
||||
- store 반영: [`patchSetupForm({ prompt })`](../../features/autotrade/components/AutotradeControlPanel.tsx#L126)
|
||||
- 같은 화면에서 구독형 CLI vendor/model도 선택 가능: `subscriptionCliVendor`, `subscriptionCliModel`
|
||||
|
||||
2. 시작/검증 버튼 클릭
|
||||
- 시작 버튼 핸들러: [`handleStartAutotrade`](../../features/autotrade/components/AutotradeControlPanel.tsx#L102)
|
||||
- 검증 버튼 핸들러: [`handlePreviewValidation`](../../features/autotrade/components/AutotradeControlPanel.tsx#L113)
|
||||
|
||||
3. 엔진 훅에서 전략 준비
|
||||
- 함수: [`prepareStrategy()`](../../features/autotrade/hooks/useAutotradeEngine.ts#L138)
|
||||
- compile 호출: [`compileAutotradeStrategy(...)`](../../features/autotrade/hooks/useAutotradeEngine.ts#L153)
|
||||
|
||||
4. 브라우저 API 클라이언트
|
||||
- 함수: [`compileAutotradeStrategy`](../../features/autotrade/apis/autotrade.api.ts#L30)
|
||||
- HTTP 호출: [`POST /api/autotrade/strategies/compile`](../../features/autotrade/apis/autotrade.api.ts#L36)
|
||||
- 전달 필드: `aiMode`, `subscriptionCliVendor`, `subscriptionCliModel`, `prompt`, `selectedTechniques`, `confidenceThreshold`
|
||||
|
||||
5. Next API route에서 provider 분기
|
||||
- 엔드포인트: [`strategies/compile/route.ts#L44`](../../app/api/autotrade/strategies/compile/route.ts#L44)
|
||||
- fallback 전략 준비: [`createFallbackCompiledStrategy`](../../app/api/autotrade/strategies/compile/route.ts#L67)
|
||||
- OpenAI 분기: [`compileStrategyWithOpenAi`](../../app/api/autotrade/strategies/compile/route.ts#L87)
|
||||
- 구독형 CLI 분기: [`compileStrategyWithSubscriptionCliDetailed`](../../app/api/autotrade/strategies/compile/route.ts#L119)
|
||||
|
||||
6. OpenAI 실제 호출 지점
|
||||
- OpenAI 전략 함수: [`compileStrategyWithOpenAi`](../../lib/autotrade/openai.ts#L51)
|
||||
- 공통 호출기: [`callOpenAiJson`](../../lib/autotrade/openai.ts#L203)
|
||||
- 외부 API: [`https://api.openai.com/v1/chat/completions`](../../lib/autotrade/openai.ts#L19)
|
||||
|
||||
7. 컴파일 결과 반영
|
||||
- compiledStrategy 저장: [`setCompiledStrategy(...)`](../../features/autotrade/hooks/useAutotradeEngine.ts#L160)
|
||||
- validate 저장: [`setValidation(...)`](../../features/autotrade/hooks/useAutotradeEngine.ts#L173)
|
||||
|
||||
## 4) 실행 중 "자동 프롬프트"가 도는 방식
|
||||
|
||||
중요: 실행 중 매 틱마다 자연어 프롬프트를 다시 보내지 않습니다.
|
||||
|
||||
1. 시작 시점에만 프롬프트를 전략 JSON으로 컴파일합니다.
|
||||
2. 실행 루프에서는 "컴파일된 전략 JSON + 현재 시세 스냅샷"으로 신호를 만듭니다.
|
||||
|
||||
관련 코드:
|
||||
|
||||
1. 신호 요청 주기(12초): [`SIGNAL_REQUEST_INTERVAL_MS`](../../features/autotrade/hooks/useAutotradeEngine.ts#L51)
|
||||
2. 신호 API 호출: [`generateAutotradeSignal(...)`](../../features/autotrade/hooks/useAutotradeEngine.ts#L495)
|
||||
3. 서버 신호 route: [`signals/generate/route.ts#L74`](../../app/api/autotrade/signals/generate/route.ts#L74)
|
||||
4. 신호 생성 OpenAI 함수: [`generateSignalWithOpenAi`](../../lib/autotrade/openai.ts#L116)
|
||||
|
||||
신호 요청 시 스냅샷 실제 필드:
|
||||
|
||||
1. `symbol`
|
||||
2. `currentPrice`
|
||||
3. `changeRate`
|
||||
4. `open`
|
||||
5. `high`
|
||||
6. `low`
|
||||
7. `tradeVolume`
|
||||
8. `accumulatedVolume`
|
||||
9. `recentPrices`
|
||||
|
||||
## 5) 신호 -> 주문 판단 (자동 실행 핵심)
|
||||
|
||||
1. 신호 생성 결과 수신: [`runtime.setLastSignal(signal)`](../../features/autotrade/hooks/useAutotradeEngine.ts#L504)
|
||||
2. 리스크 게이트 검사: [`evaluateSignalBlockers(...)`](../../features/autotrade/hooks/useAutotradeEngine.ts#L516)
|
||||
3. 통과 시 주문 API 호출: [`fetchOrderCash(...)`](../../features/autotrade/hooks/useAutotradeEngine.ts#L556)
|
||||
|
||||
즉, AI가 `buy/sell`을 주더라도 리스크 게이트를 통과하지 못하면 주문은 실행되지 않습니다.
|
||||
|
||||
## 6) AI를 못 쓰는 경우
|
||||
|
||||
1. 전략 폴백: [`createFallbackCompiledStrategy`](../../lib/autotrade/strategy.ts#L26)
|
||||
2. 신호 폴백: [`createFallbackSignalCandidate`](../../lib/autotrade/strategy.ts#L48)
|
||||
|
||||
AI(OpenAI/CLI) 응답 실패 시에도 시스템이 멈추지 않고 보수적으로 동작하도록 설계되어 있습니다.
|
||||
|
||||
## 7) Codex CLI인지 Gemini CLI인지 확인하는 법
|
||||
|
||||
1. 자동매매 로그에서 확인
|
||||
- `신호 수신 [subscription_cli:codex:gpt-5-codex]` 또는 `신호 수신 [subscription_cli:gemini:flash]`
|
||||
- 로그 코드: [`useAutotradeEngine.ts`](../../features/autotrade/hooks/useAutotradeEngine.ts#L564)
|
||||
|
||||
2. Network 응답에서 확인
|
||||
- 전략 컴파일 응답: `compiledStrategy.providerVendor`
|
||||
- 신호 생성 응답: `signal.providerVendor`
|
||||
|
||||
3. 실패 시 어떤 순서로 시도했는지 확인
|
||||
- 파싱 실패 문구에 `selected=vendor:model; attempts=vendor:model:status` 포함
|
||||
- `status=timeout`이면 CLI 실행시간 초과입니다. `AUTOTRADE_SUBSCRIPTION_CLI_TIMEOUT_MS`를 늘리세요(권장: 60000).
|
||||
- 생성 코드: [`summarizeSubscriptionCliExecution`](../../lib/autotrade/cli-provider.ts#L112)
|
||||
|
||||
4. 모델 선택 환경변수
|
||||
- `AUTOTRADE_CODEX_MODEL` (예: `gpt-5-codex`)
|
||||
- `AUTOTRADE_GEMINI_MODEL` (예: `auto`, `pro`, `flash`, `flash-lite`)
|
||||
- `AUTOTRADE_SUBSCRIPTION_CLI_MODEL` (vendor 전용 값이 없을 때 공통 fallback)
|
||||
|
||||
5. 모델 선택 UI (환경변수보다 우선)
|
||||
- 자동매매 설정창에서 `subscriptionCliVendor`, `subscriptionCliModel` 선택 시 해당 값이 API payload로 전달되어 CLI 실행 인자에 우선 적용됩니다.
|
||||
407
common-docs/features/autotrade-usage-security-guide.md
Normal file
407
common-docs/features/autotrade-usage-security-guide.md
Normal file
@@ -0,0 +1,407 @@
|
||||
# 자동매매 사용/검증/보안 가이드 (3계층 구조)
|
||||
|
||||
이 문서는 자동매매를 아래 3개 영역으로 나눠서 설명합니다.
|
||||
|
||||
1. 사용자 브라우저
|
||||
2. Next.js 서버(API)
|
||||
3. 워커(Node)
|
||||
|
||||
프롬프트 입력값이 실제로 어디 함수/어디 API로 흘러가는지 추적하려면 아래 문서를 같이 보세요.
|
||||
|
||||
- `common-docs/features/autotrade-prompt-flow-guide.md`
|
||||
|
||||
---
|
||||
|
||||
## 1) 한눈에 구조
|
||||
|
||||
```text
|
||||
┌───────────────────────────── 사용자 브라우저 ─────────────────────────────┐
|
||||
│ /trade 자동매매 UI │
|
||||
│ - 설정 입력(전략/투자금/손실한도/임계치) │
|
||||
│ - start/stop/heartbeat/signals 호출 │
|
||||
└───────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────── Next.js 서버 (API) ──────────────────────────┐
|
||||
│ /api/autotrade/strategies/* │
|
||||
│ /api/autotrade/sessions/* │
|
||||
│ /api/autotrade/signals/generate │
|
||||
│ /api/autotrade/worker/tick │
|
||||
└───────────────────────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ x-autotrade-worker-token
|
||||
│
|
||||
┌────────────────────────────── Worker (Node) ─────────────────────────────┐
|
||||
│ scripts/autotrade-worker.mjs │
|
||||
│ - 주기적으로 /api/autotrade/worker/tick 호출 │
|
||||
│ - heartbeat 만료 세션 정리 │
|
||||
└───────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1-1) 개발 실행 시 (내 PC 기준)
|
||||
|
||||
1. 브라우저: React 화면 사용
|
||||
2. Next 개발 서버(`npm run dev`): 화면 + API를 함께 처리
|
||||
3. 워커(`node scripts/autotrade-worker.mjs`): tick 호출 담당
|
||||
|
||||
즉, 개발에서는 보통 `Next 1개 + Worker 1개` 프로세스를 실행합니다.
|
||||
|
||||
## 1-2) 운영 배포 시
|
||||
|
||||
운영은 보통 아래 2가지 중 하나입니다.
|
||||
|
||||
1. 같은 Linux 서버에 Next + Worker 같이 운영
|
||||
2. Next는 배포 플랫폼, Worker는 별도 Linux 서버에서 운영
|
||||
|
||||
공통 원칙:
|
||||
|
||||
1. 브라우저는 Next API를 호출
|
||||
2. 워커도 Next API(`/api/autotrade/worker/tick`)를 호출
|
||||
3. 워커 인증은 `x-autotrade-worker-token`으로 처리
|
||||
|
||||
---
|
||||
|
||||
## 2) 레이어별 역할
|
||||
|
||||
## 2-1) 사용자 브라우저
|
||||
|
||||
하는 일:
|
||||
|
||||
1. 자동매매 설정 입력
|
||||
2. 전략 컴파일/검증 요청
|
||||
3. 세션 시작 후 10초마다 heartbeat 전송
|
||||
4. 신호 요청 후 주문 가능 여부 판단
|
||||
5. 브라우저 종료/외부 이동 시 중지 처리
|
||||
|
||||
핵심 소스:
|
||||
|
||||
1. UI: [`AutotradeControlPanel`](../../features/autotrade/components/AutotradeControlPanel.tsx#L25)
|
||||
2. 엔진: [`useAutotradeEngine`](../../features/autotrade/hooks/useAutotradeEngine.ts#L118)
|
||||
3. heartbeat 루프: [`useAutotradeEngine`](../../features/autotrade/hooks/useAutotradeEngine.ts#L336)
|
||||
4. 주문 직전 게이트+주문 호출: [`useAutotradeEngine`](../../features/autotrade/hooks/useAutotradeEngine.ts#L426)
|
||||
|
||||
## 2-2) Next.js 서버(API)
|
||||
|
||||
하는 일:
|
||||
|
||||
1. 사용자 인증 검사
|
||||
2. 전략 compile/validate 처리
|
||||
3. 세션 start/heartbeat/stop/active 관리
|
||||
4. AI 호출 실패 시 폴백 전략/신호로 대응
|
||||
5. 워커 토큰 인증 후 만료 세션 정리
|
||||
|
||||
핵심 소스:
|
||||
|
||||
1. 공통 유틸: [`_shared.ts`](../../app/api/autotrade/_shared.ts)
|
||||
2. compile: [`POST /strategies/compile`](../../app/api/autotrade/strategies/compile/route.ts#L22)
|
||||
3. validate: [`POST /strategies/validate`](../../app/api/autotrade/strategies/validate/route.ts#L19)
|
||||
4. sessions: [`/sessions/start`](../../app/api/autotrade/sessions/start/route.ts#L21), [`/sessions/heartbeat`](../../app/api/autotrade/sessions/heartbeat/route.ts#L18), [`/sessions/stop`](../../app/api/autotrade/sessions/stop/route.ts#L27), [`/sessions/active`](../../app/api/autotrade/sessions/active/route.ts#L9)
|
||||
5. 신호 생성: [`POST /signals/generate`](../../app/api/autotrade/signals/generate/route.ts#L41)
|
||||
|
||||
## 2-3) 워커(Node)
|
||||
|
||||
하는 일:
|
||||
|
||||
1. 주기적으로 Next API `/api/autotrade/worker/tick` 호출
|
||||
2. heartbeat 끊긴 세션을 timeout 종료
|
||||
3. 정리 결과 로그 출력
|
||||
|
||||
핵심 소스:
|
||||
|
||||
1. 워커 스크립트: [`autotrade-worker.mjs`](../../scripts/autotrade-worker.mjs)
|
||||
2. 워커 API: [`POST /worker/tick`](../../app/api/autotrade/worker/tick/route.ts#L12)
|
||||
3. 만료 정리 함수: [`sweepExpiredAutotradeSessions()`](../../app/api/autotrade/_shared.ts#L147)
|
||||
|
||||
---
|
||||
|
||||
## 3) 가장 헷갈리는 개념 3개
|
||||
|
||||
## 3-1) 폴백 전략(fallback)
|
||||
|
||||
뜻:
|
||||
|
||||
1. AI를 못 쓰는 상황에서 쓰는 대체 규칙
|
||||
2. 자동매매를 완전 중지하지 않고 보수적으로 유지
|
||||
3. 애매하면 `hold`를 더 자주 반환
|
||||
|
||||
관련 소스:
|
||||
|
||||
1. 전략 폴백: [`createFallbackCompiledStrategy()`](../../lib/autotrade/strategy.ts#L16)
|
||||
2. 신호 폴백: [`createFallbackSignalCandidate()`](../../lib/autotrade/strategy.ts#L36)
|
||||
3. AI 호출: [`callOpenAiJson()`](../../lib/autotrade/openai.ts#L187)
|
||||
|
||||
## 3-2) heartbeat
|
||||
|
||||
뜻:
|
||||
|
||||
1. 브라우저가 Next 서버로 보내는 "세션 살아있음" 신호
|
||||
2. 워커가 보내는 신호가 아님
|
||||
|
||||
## 3-3) worker tick
|
||||
|
||||
뜻:
|
||||
|
||||
1. 워커가 Next 서버로 보내는 "만료 세션 정리 요청"
|
||||
2. heartbeat가 끊긴 세션을 timeout 종료
|
||||
|
||||
---
|
||||
|
||||
## 3-4) 구독형 CLI 자동판단(신규)
|
||||
|
||||
뜻:
|
||||
|
||||
1. OpenAI API 키 대신 서버에 설치된 `gemini` 또는 `codex` CLI를 호출해 자동판단
|
||||
2. 자동판단 결과(JSON)를 파싱해 전략/신호에 반영
|
||||
3. CLI 호출 실패 또는 파싱 실패 시 규칙 기반으로 자동 폴백
|
||||
|
||||
UI에서 선택:
|
||||
|
||||
1. 자동매매 설정창에서 `구독형 CLI 엔진`을 `auto/codex/gemini` 중 선택
|
||||
2. `codex` 또는 `gemini` 선택 시 공식 문서 기반 추천 모델 목록을 드롭다운으로 선택
|
||||
3. 목록에 없는 최신 모델은 `직접 입력`으로 설정
|
||||
|
||||
모델 우선순위:
|
||||
|
||||
1. UI에서 선택한 모델(있을 때)
|
||||
2. `AUTOTRADE_CODEX_MODEL` / `AUTOTRADE_GEMINI_MODEL`
|
||||
3. `AUTOTRADE_SUBSCRIPTION_CLI_MODEL`
|
||||
4. 각 CLI 기본 모델
|
||||
|
||||
환경변수:
|
||||
|
||||
```env
|
||||
AUTOTRADE_AI_MODE=subscription_cli
|
||||
AUTOTRADE_SUBSCRIPTION_CLI=auto
|
||||
AUTOTRADE_SUBSCRIPTION_CLI_MODEL=
|
||||
AUTOTRADE_CODEX_MODEL=
|
||||
AUTOTRADE_GEMINI_MODEL=
|
||||
AUTOTRADE_SUBSCRIPTION_CLI_TIMEOUT_MS=60000
|
||||
AUTOTRADE_SUBSCRIPTION_CLI_DEBUG=0
|
||||
AUTOTRADE_CODEX_COMMAND=
|
||||
AUTOTRADE_GEMINI_COMMAND=
|
||||
```
|
||||
|
||||
동작 우선순위:
|
||||
|
||||
1. `AUTOTRADE_SUBSCRIPTION_CLI=auto`면 codex -> gemini 순서로 시도
|
||||
2. 모델 선택 우선순위는 `vendor 전용 모델` -> `AUTOTRADE_SUBSCRIPTION_CLI_MODEL` -> `CLI 기본 모델`
|
||||
3. 둘 다 실패하면 fallback 규칙 신호 사용
|
||||
4. 로그에 `attempts=codex:default:timeout`가 나오면 CLI 타임아웃이므로 `AUTOTRADE_SUBSCRIPTION_CLI_TIMEOUT_MS`를 더 크게 설정
|
||||
5. 로그에 `attempts=codex:gpt-5-codex:error(...)`처럼 괄호가 붙으면 실제 실패 원인(stderr/spawn 에러)입니다.
|
||||
|
||||
어떤 CLI를 썼는지 확인:
|
||||
|
||||
1. 자동매매 로그에서 `신호 수신 [subscription_cli:codex:gpt-5-codex]` 또는 `신호 수신 [subscription_cli:gemini:flash]` 확인
|
||||
2. Network 응답에서 `providerVendor` 확인
|
||||
- `/api/autotrade/strategies/compile` 응답: `compiledStrategy.providerVendor`
|
||||
- `/api/autotrade/signals/generate` 응답: `signal.providerVendor`
|
||||
3. Network 응답에서 `providerModel` 확인
|
||||
- `/api/autotrade/strategies/compile` 응답: `compiledStrategy.providerModel`
|
||||
- `/api/autotrade/signals/generate` 응답: `signal.providerModel`
|
||||
4. 파싱 실패 시 reason/summary에 `selected=vendor:model; attempts=...` 형태로 시도 결과 포함
|
||||
|
||||
`selected=none:default; attempts=codex:gpt-5-codex:error(...)`가 보이면:
|
||||
|
||||
1. `AUTOTRADE_SUBSCRIPTION_CLI_DEBUG=1`로 켜고 `npm run dev`를 재시작합니다.
|
||||
2. Next 서버 콘솔에서 `[autotrade-cli]` 로그를 확인합니다.
|
||||
3. `spawn:ENOENT`가 보이면 `AUTOTRADE_CODEX_COMMAND` 또는 `AUTOTRADE_GEMINI_COMMAND`에 CLI 절대경로를 넣습니다.
|
||||
4. 예: `AUTOTRADE_CODEX_COMMAND=C:\\Users\\<계정>\\AppData\\Roaming\\npm\\codex.cmd`
|
||||
|
||||
모델 지정 예시:
|
||||
|
||||
```env
|
||||
# Codex만 쓸 때
|
||||
AUTOTRADE_SUBSCRIPTION_CLI=codex
|
||||
AUTOTRADE_CODEX_MODEL=gpt-5-codex
|
||||
|
||||
# Gemini만 쓸 때
|
||||
AUTOTRADE_SUBSCRIPTION_CLI=gemini
|
||||
AUTOTRADE_GEMINI_MODEL=flash
|
||||
|
||||
# auto 모드에서 공통 모델 fallback만 쓸 때
|
||||
AUTOTRADE_SUBSCRIPTION_CLI=auto
|
||||
AUTOTRADE_SUBSCRIPTION_CLI_MODEL=auto
|
||||
```
|
||||
|
||||
공식 문서:
|
||||
|
||||
1. Codex CLI 옵션(`--model`): <https://developers.openai.com/codex/cli>
|
||||
2. OpenAI 모델 목록(`gpt-5-codex` 포함): <https://platform.openai.com/docs/models>
|
||||
3. Gemini CLI 모델 선택: <https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/model.md>
|
||||
4. Gemini CLI 모델 우선순위(`--model` > `GEMINI_MODEL`): <https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/model-routing.md>
|
||||
|
||||
모델 갱신 운영 런북:
|
||||
|
||||
1. 새 모델 출시 대응 절차: [`autotrade-model-catalog-runbook.md`](./autotrade-model-catalog-runbook.md)
|
||||
|
||||
관련 소스:
|
||||
|
||||
1. CLI 공급자: [`lib/autotrade/cli-provider.ts`](../../lib/autotrade/cli-provider.ts)
|
||||
2. 전략 compile 라우트: [`/strategies/compile`](../../app/api/autotrade/strategies/compile/route.ts)
|
||||
3. 신호 generate 라우트: [`/signals/generate`](../../app/api/autotrade/signals/generate/route.ts)
|
||||
|
||||
---
|
||||
|
||||
## 4) 환경변수: 어디에 넣는지
|
||||
|
||||
## 4-1) 앱(Next.js 서버)
|
||||
|
||||
위치:
|
||||
|
||||
1. 로컬: `.env.local`
|
||||
2. 운영: 배포 환경변수
|
||||
|
||||
필수:
|
||||
|
||||
```env
|
||||
AUTOTRADE_WORKER_TOKEN=<랜덤시크릿>
|
||||
OPENAI_API_KEY=<옵션, 없으면 폴백 동작>
|
||||
```
|
||||
|
||||
## 4-2) 워커(Node/PM2)
|
||||
|
||||
위치:
|
||||
|
||||
1. PM2 실행 셸 환경변수
|
||||
2. 서버 시스템 환경변수
|
||||
|
||||
필수:
|
||||
|
||||
```env
|
||||
AUTOTRADE_WORKER_TOKEN=<앱과동일값>
|
||||
AUTOTRADE_APP_URL=<Next서버URL>
|
||||
AUTOTRADE_WORKER_POLL_MS=5000
|
||||
```
|
||||
|
||||
중요:
|
||||
|
||||
1. `AUTOTRADE_WORKER_TOKEN`은 사용자별이 아니라 서비스별 시크릿
|
||||
2. 앱과 워커가 같은 값을 써야 인증 통과
|
||||
|
||||
---
|
||||
|
||||
## 5) 실행 순서 (앱/워커 분리)
|
||||
|
||||
## 5-1) 로컬 개발
|
||||
|
||||
터미널 A:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
터미널 B:
|
||||
|
||||
```bash
|
||||
AUTOTRADE_WORKER_TOKEN="<앱과같은값>" \
|
||||
AUTOTRADE_APP_URL="http://127.0.0.1:3001" \
|
||||
node scripts/autotrade-worker.mjs
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
npm run dev
|
||||
# 새 터미널
|
||||
$env:AUTOTRADE_WORKER_TOKEN="<앱과같은값>"
|
||||
$env:AUTOTRADE_APP_URL="http://127.0.0.1:3001"
|
||||
$env:AUTOTRADE_WORKER_POLL_MS="5000"
|
||||
npm run worker:autotrade
|
||||
```
|
||||
|
||||
또는 `.env.local` 기반:
|
||||
|
||||
```powershell
|
||||
npm run worker:autotrade:dev
|
||||
```
|
||||
|
||||
## 5-2) 운영(PM2)
|
||||
|
||||
```bash
|
||||
export AUTOTRADE_WORKER_TOKEN="<앱과같은값>"
|
||||
export AUTOTRADE_APP_URL="https://your-domain.com"
|
||||
export AUTOTRADE_WORKER_POLL_MS="5000"
|
||||
pm2 start scripts/pm2.autotrade-worker.config.cjs
|
||||
pm2 logs autotrade-worker
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6) end-to-end 흐름 (브라우저 -> 서버 -> 워커)
|
||||
|
||||
1. 브라우저: 설정 입력
|
||||
2. 서버: `/strategies/compile` (AI 또는 폴백)
|
||||
3. 서버: `/strategies/validate` (리스크 계산)
|
||||
4. 서버: `/sessions/start`
|
||||
5. 브라우저: 10초마다 `/sessions/heartbeat`
|
||||
6. 브라우저: 주기적으로 `/signals/generate`
|
||||
7. 브라우저: 리스크 게이트 통과 시 주문 API 호출
|
||||
8. 브라우저: 중지 이벤트 시 `/sessions/stop`
|
||||
9. 워커: `/worker/tick`로 heartbeat 만료 세션 정리
|
||||
|
||||
---
|
||||
|
||||
## 6-1) AI가 실제로 받는 판단 데이터
|
||||
|
||||
자동매매는 "자연어 프롬프트만" 보내는 구조가 아닙니다. 실행 중에는 아래 구조화된 데이터가 같이 전달됩니다.
|
||||
|
||||
1. 전략(compile 결과)
|
||||
- `selectedTechniques`
|
||||
- `confidenceThreshold`
|
||||
- `maxDailyOrders`
|
||||
- `cooldownSec`
|
||||
- `maxOrderAmountRatio`
|
||||
2. 시세 스냅샷(signal 요청 시)
|
||||
- `symbol`
|
||||
- `currentPrice`
|
||||
- `changeRate`
|
||||
- `open/high/low`
|
||||
- `tradeVolume`
|
||||
- `accumulatedVolume`
|
||||
- `recentPrices`(최근 체결가 배열)
|
||||
3. 서버 리스크 검증 결과
|
||||
- AI 신호가 `buy/sell`이어도 리스크 게이트 미통과 시 주문 차단
|
||||
|
||||
즉, AI는 "현재 종목 + 현재가 + 가격 흐름 + 전략 제약"을 같이 받아 판단하고, 최종 주문은 리스크 게이트를 통과해야 실행됩니다.
|
||||
|
||||
관련 소스:
|
||||
|
||||
1. 스냅샷 구성: [`useAutotradeEngine.ts`](../../features/autotrade/hooks/useAutotradeEngine.ts)
|
||||
2. 신호 route 검증: [`signals/generate/route.ts`](../../app/api/autotrade/signals/generate/route.ts)
|
||||
3. 리스크 게이트: [`risk.ts`](../../lib/autotrade/risk.ts)
|
||||
|
||||
---
|
||||
|
||||
## 7) 보안: 레이어별 핵심
|
||||
|
||||
## 7-1) 브라우저
|
||||
|
||||
1. KIS 민감정보는 세션 저장소(sessionStorage) 사용
|
||||
2. 브라우저 종료 시 세션 저장소 제거
|
||||
|
||||
## 7-2) Next 서버
|
||||
|
||||
1. 자동매매 API는 사용자 인증 필요
|
||||
2. 워커 API는 `x-autotrade-worker-token` 인증 필요
|
||||
3. 민감정보 문자열 마스킹 처리
|
||||
|
||||
## 7-3) 워커
|
||||
|
||||
1. 토큰이 틀리면 401
|
||||
2. 토큰은 코드 하드코딩 금지
|
||||
|
||||
---
|
||||
|
||||
## 8) 역할별로 어디 보면 되는지
|
||||
|
||||
1. 기획/대표: 1, 2, 6, 7장
|
||||
2. QA: 5, 6, 7장 + worker 문서 6, 7장
|
||||
3. 개발: 2장 소스링크 + worker 문서 전체
|
||||
|
||||
---
|
||||
|
||||
## 9) 추가 문서
|
||||
|
||||
1. 워커 상세 운영: [`autotrade-worker-pm2.md`](./autotrade-worker-pm2.md)
|
||||
269
common-docs/features/autotrade-worker-pm2.md
Normal file
269
common-docs/features/autotrade-worker-pm2.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# 자동매매 워커 운영 가이드 (실행/배포 구조 이해용)
|
||||
|
||||
이 문서는 "앱을 실행하면 뭐가 어디서 도는지"를 먼저 설명하고, 그다음 실행 방법을 설명합니다.
|
||||
|
||||
## 0) 먼저 용어 정리
|
||||
|
||||
1. React 앱: 브라우저에서 보이는 UI (`/trade` 화면)
|
||||
2. Next.js 서버: React 화면 제공 + API(`/api/*`) 처리
|
||||
3. 워커(Node): 백그라운드에서 `/api/autotrade/worker/tick` 호출하는 별도 프로세스
|
||||
|
||||
중요:
|
||||
|
||||
1. React와 API는 보통 같은 Next 프로세스에서 동작합니다.
|
||||
2. 워커는 Next와 별도 프로세스입니다.
|
||||
|
||||
---
|
||||
|
||||
## 1) 개발(local)에서 실제로 어디서 도는가
|
||||
|
||||
```text
|
||||
내 PC
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ 브라우저(Chrome) │
|
||||
│ - /trade 화면 렌더링 │
|
||||
│ - heartbeat 전송 (/sessions/heartbeat) │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
│ http://127.0.0.1:3001
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ 터미널 A: Next 개발 서버 (`npm run dev`) │
|
||||
│ - React 페이지 제공 │
|
||||
│ - /api/autotrade/* API 처리 │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ x-autotrade-worker-token
|
||||
│
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ 터미널 B: 워커 (`node scripts/autotrade-worker.mjs`) │
|
||||
│ - /api/autotrade/worker/tick 주기 호출 │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
외부 클라우드 서비스
|
||||
- Supabase(Auth/DB)
|
||||
- KIS API
|
||||
- OpenAI API(선택)
|
||||
```
|
||||
|
||||
핵심:
|
||||
|
||||
1. 개발에서는 보통 프로세스 2개를 띄웁니다.
|
||||
2. Next 1개 + Worker 1개
|
||||
|
||||
---
|
||||
|
||||
## 2) 운영(prod)에서 실제로 어디서 도는가
|
||||
|
||||
## 2-1) 패턴 A: 같은 Linux 서버에 Next + Worker
|
||||
|
||||
```text
|
||||
사용자 브라우저
|
||||
│ HTTPS
|
||||
▼
|
||||
[Linux 서버]
|
||||
- Next 앱 프로세스 (웹 + API)
|
||||
- Worker 프로세스 (PM2)
|
||||
└─ 내부에서 /api/autotrade/worker/tick 호출
|
||||
```
|
||||
|
||||
장점:
|
||||
|
||||
1. 구성 단순
|
||||
2. 네트워크 경로 짧음
|
||||
|
||||
## 2-2) 패턴 B: Next는 플랫폼(Vercel 등), Worker는 별도 Linux
|
||||
|
||||
```text
|
||||
사용자 브라우저 ──HTTPS──> Next 배포 플랫폼(웹+API)
|
||||
▲
|
||||
│ HTTPS + x-autotrade-worker-token
|
||||
│
|
||||
Linux Worker 서버(PM2)
|
||||
```
|
||||
|
||||
장점:
|
||||
|
||||
1. 앱/워커 분리 운영 가능
|
||||
2. 워커 자원 독립 관리 가능
|
||||
|
||||
주의:
|
||||
|
||||
1. 워커 서버에서 Next 도메인으로 접근 가능해야 함
|
||||
2. 토큰/URL 설정을 양쪽에 정확히 맞춰야 함
|
||||
|
||||
---
|
||||
|
||||
## 3) 서버에서 "무엇이 돌아가는지" 체크표
|
||||
|
||||
| 구성요소 | 실제 실행 위치 | 프로세스 | 시작 명령 예시 | 역할 |
|
||||
|---|---|---|---|---|
|
||||
| React UI | 사용자 브라우저 | Browser Tab | URL 접속 | 화면 렌더링, 사용자 입력 |
|
||||
| Next 서버 | Linux/플랫폼 | Node(Next) | `npm run dev` 또는 `npm run start` | 웹 + `/api/autotrade/*` 처리 |
|
||||
| Worker | Linux/Worker 서버 | Node Script(PM2) | `pm2 start scripts/pm2.autotrade-worker.config.cjs` | 만료 세션 정리 |
|
||||
|
||||
---
|
||||
|
||||
## 4) heartbeat와 worker/tick 차이
|
||||
|
||||
1. heartbeat
|
||||
브라우저 -> Next 서버
|
||||
세션 살아있음 알림
|
||||
|
||||
2. worker/tick
|
||||
워커 -> Next 서버
|
||||
heartbeat 끊긴 세션 정리 요청
|
||||
|
||||
즉:
|
||||
|
||||
1. heartbeat는 "상태 보고"
|
||||
2. tick은 "청소 작업"
|
||||
|
||||
---
|
||||
|
||||
## 5) 토큰/URL: 뭘 어떻게 넣어야 하나
|
||||
|
||||
## 5-1) `AUTOTRADE_WORKER_TOKEN`
|
||||
|
||||
뜻:
|
||||
|
||||
1. 사용자용 토큰 아님
|
||||
2. 앱 서버와 워커 간 내부 인증 시크릿
|
||||
3. 환경별(dev/staging/prod)로 1개 사용
|
||||
|
||||
생성 예시:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
## 5-2) `AUTOTRADE_APP_URL`
|
||||
|
||||
뜻:
|
||||
|
||||
1. 워커가 호출할 Next 서버 주소
|
||||
|
||||
예시:
|
||||
|
||||
1. 로컬: `http://127.0.0.1:3001`
|
||||
2. 운영: `https://your-domain.com`
|
||||
|
||||
---
|
||||
|
||||
## 6) 어디 파일/어디 시스템에 넣나
|
||||
|
||||
## 6-1) 앱(Next 서버)
|
||||
|
||||
위치:
|
||||
|
||||
1. 로컬: `.env.local`
|
||||
2. 운영: 배포 환경변수
|
||||
|
||||
필수:
|
||||
|
||||
```env
|
||||
AUTOTRADE_WORKER_TOKEN=<랜덤시크릿>
|
||||
```
|
||||
|
||||
## 6-2) 워커(Node/PM2)
|
||||
|
||||
위치:
|
||||
|
||||
1. PM2 실행 셸 환경변수
|
||||
2. 서버 시스템 환경변수
|
||||
|
||||
필수:
|
||||
|
||||
```env
|
||||
AUTOTRADE_WORKER_TOKEN=<앱과동일값>
|
||||
AUTOTRADE_APP_URL=<Next서버URL>
|
||||
AUTOTRADE_WORKER_POLL_MS=5000
|
||||
```
|
||||
|
||||
중요:
|
||||
|
||||
1. 앱/워커 토큰 값은 완전히 같아야 합니다.
|
||||
2. 다르면 `/worker/tick`가 401로 실패합니다.
|
||||
|
||||
---
|
||||
|
||||
## 7) 실행 방법
|
||||
|
||||
## 7-1) 로컬 개발
|
||||
|
||||
터미널 A (Next):
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
터미널 B (Worker):
|
||||
|
||||
```bash
|
||||
AUTOTRADE_WORKER_TOKEN="<앱과같은값>" \
|
||||
AUTOTRADE_APP_URL="http://127.0.0.1:3001" \
|
||||
AUTOTRADE_WORKER_POLL_MS="5000" \
|
||||
node scripts/autotrade-worker.mjs
|
||||
```
|
||||
|
||||
## 7-1-a) 로컬 개발 (Windows PowerShell)
|
||||
|
||||
터미널 A (Next):
|
||||
|
||||
```powershell
|
||||
npm run dev
|
||||
```
|
||||
|
||||
터미널 B (Worker):
|
||||
|
||||
```powershell
|
||||
$env:AUTOTRADE_WORKER_TOKEN="<앱과같은값>"
|
||||
$env:AUTOTRADE_APP_URL="http://127.0.0.1:3001"
|
||||
$env:AUTOTRADE_WORKER_POLL_MS="5000"
|
||||
npm run worker:autotrade
|
||||
```
|
||||
|
||||
`.env.local` 값을 바로 쓰고 싶으면:
|
||||
|
||||
```powershell
|
||||
npm run worker:autotrade:dev
|
||||
```
|
||||
|
||||
## 7-2) 운영 서버 (PM2)
|
||||
|
||||
```bash
|
||||
npm i -g pm2
|
||||
export AUTOTRADE_WORKER_TOKEN="<앱과같은값>"
|
||||
export AUTOTRADE_APP_URL="https://your-domain.com"
|
||||
export AUTOTRADE_WORKER_POLL_MS="5000"
|
||||
pm2 start scripts/pm2.autotrade-worker.config.cjs
|
||||
pm2 status
|
||||
pm2 logs autotrade-worker
|
||||
pm2 save
|
||||
pm2 startup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8) 장애 시 빠른 점검
|
||||
|
||||
1. 워커 401
|
||||
원인: 앱/워커 토큰 불일치
|
||||
조치: `AUTOTRADE_WORKER_TOKEN` 동일화
|
||||
|
||||
2. fetch failed
|
||||
원인: `AUTOTRADE_APP_URL` 오타, Next 미기동
|
||||
조치: URL/앱 프로세스 확인
|
||||
|
||||
3. 세션이 안 정리됨
|
||||
원인: heartbeat 정상 수신 중일 수 있음
|
||||
조치: 브라우저 종료 후 TTL 경과 뒤 확인
|
||||
|
||||
---
|
||||
|
||||
## 9) 관련 소스
|
||||
|
||||
1. 워커: [`scripts/autotrade-worker.mjs`](../../scripts/autotrade-worker.mjs)
|
||||
2. PM2 설정: [`scripts/pm2.autotrade-worker.config.cjs`](../../scripts/pm2.autotrade-worker.config.cjs)
|
||||
3. 워커 API: [`app/api/autotrade/worker/tick/route.ts`](../../app/api/autotrade/worker/tick/route.ts)
|
||||
4. heartbeat API: [`app/api/autotrade/sessions/heartbeat/route.ts`](../../app/api/autotrade/sessions/heartbeat/route.ts)
|
||||
5. 세션 만료 정리: [`app/api/autotrade/_shared.ts`](../../app/api/autotrade/_shared.ts#L147)
|
||||
42
common-docs/features/trade-stock-sync.md
Normal file
42
common-docs/features/trade-stock-sync.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Korean Stocks 동기화
|
||||
|
||||
`korean-stocks.json`은 수동 편집 파일이 아니라 자동 생성 파일입니다.
|
||||
직접 수정하지 말고 동기화 스크립트로 갱신하세요.
|
||||
|
||||
## 실행 명령
|
||||
|
||||
```bash
|
||||
npm run sync:stocks
|
||||
```
|
||||
|
||||
- KIS 최신 KOSPI/KOSDAQ 마스터 파일을 내려받아
|
||||
`features/trade/data/korean-stocks.json`을 다시 생성합니다.
|
||||
|
||||
```bash
|
||||
npm run sync:stocks:check
|
||||
```
|
||||
|
||||
- 현재 파일이 최신인지 검사합니다.
|
||||
- 갱신이 필요하면 종료 코드 `1`로 끝납니다.
|
||||
|
||||
```bash
|
||||
npm run sync:stocks -- --dry-run
|
||||
```
|
||||
|
||||
- 원격 파일 파싱/검증만 하고 저장은 하지 않습니다.
|
||||
|
||||
## 권장 운영 방법
|
||||
|
||||
1. 하루 1회(또는 배포 전) `npm run sync:stocks` 실행
|
||||
2. `npm run lint`, `npm run build`로 기본 검증
|
||||
3. 갱신된 `features/trade/data/korean-stocks.json` 커밋
|
||||
|
||||
## 참고
|
||||
|
||||
- 데이터 출처:
|
||||
- `https://new.real.download.dws.co.kr/common/master/kospi_code.mst.zip`
|
||||
- `https://new.real.download.dws.co.kr/common/master/kosdaq_code.mst.zip`
|
||||
- 비정상 데이터 저장을 막기 위해 최소 건수 검증(안전장치)을 넣었습니다.
|
||||
- 임시 파일 저장 후 교체(원자적 저장) 방식이라 중간 손상 위험을 줄입니다.
|
||||
- 공식 문서:
|
||||
- `https://apiportal.koreainvestment.com/apiservice-category`
|
||||
@@ -0,0 +1,134 @@
|
||||
[계획 문서 경로]
|
||||
- common-docs/improvement/plans/dev-plan-2026-02-26-autotrade-ai-mvp.md
|
||||
|
||||
[요구사항 요약]
|
||||
- `features-autotrade-design.md`를 참고해 자동매매 기능을 실제 코드로 추가한다.
|
||||
- 설계 항목 중 현재 코드베이스에서 바로 구현 가능한 범위와 불필요/보류 범위를 구분한다.
|
||||
- "구독형 AI + 유명 기법"(OpenAI 기반 + ORB/VWAP/거래량/이평/갭)을 자동매매 시작 흐름에 반영한다.
|
||||
- OpenAI API 외에도 서버에 설치된 Codex/Gemini CLI를 이용한 구독형 자동판단 경로를 추가한다.
|
||||
- Windows 개발 환경에서 워커 실행 방법을 문서와 스크립트로 제공한다.
|
||||
|
||||
[확인 질문(필요 시 1~3개)]
|
||||
- 없음(우선 MVP 범위로 구현 후 동작 가능한 형태를 제공)
|
||||
|
||||
[가정]
|
||||
- 서버 DB(Supabase) 스키마를 이번 작업에서 새로 만들지 않고, 세션/로그는 서버 메모리 + 클라이언트 상태로 우선 구현한다.
|
||||
- OpenAI 키(`OPENAI_API_KEY`)가 없으면 AI 추론은 휴리스틱 폴백(보수적 hold 중심)으로 동작한다.
|
||||
- 자동매매는 트레이드 화면에서 선택된 종목 기준으로 우선 실행한다(멀티 종목 동시 엔진은 보류).
|
||||
|
||||
[추가/제외 판단]
|
||||
- 즉시 추가:
|
||||
- 자동매매 설정 팝업(UI): 프롬프트, 유명 기법 복수 선택, 투자금/손실한도(퍼센트+금액), 동의 체크
|
||||
- 전략 컴파일/검증 API: `compile`, `validate`
|
||||
- 런타임 세션 API: `start`, `heartbeat`, `stop`, `active`
|
||||
- 브라우저 엔진 훅: 신호 평가, 리스크 게이트, 주문 실행, heartbeat, 중지 처리
|
||||
- 실행 중 경고 배너/상태 카드
|
||||
- 설정 도움말/추천 프리셋(초보/균형/공격) 추가
|
||||
- 백엔드 워커 tick API + 리눅스 PM2 실행 스크립트/문서 추가
|
||||
- 자동 세션 수명주기(start->heartbeat->stop) E2E 스크립트 추가
|
||||
- 이번에 제외(보류):
|
||||
- Supabase 테이블 5종 + 감사로그 영구 저장
|
||||
- 온라인 전략 수집/카탈로그 검수 워크플로우 전체
|
||||
- 멀티탭 리더 선출 lock + BroadcastChannel 완성형
|
||||
- 4주 배포 계획/운영 대시보드/Sentry 통합
|
||||
- AI 다중 제공자(OpenAI/Gemini/Claude) 동시 운영
|
||||
|
||||
[영향 범위]
|
||||
- 수정:
|
||||
- features/trade/components/TradeContainer.tsx
|
||||
- .env.example
|
||||
- utils/supabase/middleware.ts
|
||||
- package.json
|
||||
- common-docs/features/autotrade-usage-security-guide.md
|
||||
- common-docs/features/autotrade-worker-pm2.md
|
||||
- common-docs/improvement/plans/dev-plan-2026-02-26-autotrade-ai-mvp.md
|
||||
- 추가:
|
||||
- features/autotrade/types/autotrade.types.ts
|
||||
- features/autotrade/stores/use-autotrade-engine-store.ts
|
||||
- features/autotrade/hooks/useAutotradeEngine.ts
|
||||
- features/autotrade/components/AutotradeControlPanel.tsx
|
||||
- features/autotrade/components/AutotradeWarningBanner.tsx
|
||||
- features/autotrade/apis/autotrade.api.ts
|
||||
- app/api/autotrade/_shared.ts
|
||||
- app/api/autotrade/strategies/compile/route.ts
|
||||
- app/api/autotrade/strategies/validate/route.ts
|
||||
- app/api/autotrade/sessions/start/route.ts
|
||||
- app/api/autotrade/sessions/heartbeat/route.ts
|
||||
- app/api/autotrade/sessions/stop/route.ts
|
||||
- app/api/autotrade/sessions/active/route.ts
|
||||
- app/api/autotrade/signals/generate/route.ts
|
||||
- app/api/autotrade/worker/tick/route.ts
|
||||
- lib/autotrade/risk.ts
|
||||
- lib/autotrade/strategy.ts
|
||||
- lib/autotrade/openai.ts
|
||||
- lib/autotrade/cli-provider.ts
|
||||
- scripts/autotrade-session-e2e.mjs
|
||||
- scripts/autotrade-worker.mjs
|
||||
- scripts/pm2.autotrade-worker.config.cjs
|
||||
- common-docs/features/autotrade-worker-pm2.md
|
||||
- 삭제:
|
||||
- 없음
|
||||
|
||||
[구현 단계]
|
||||
- [x] 1. 자동매매 타입/리스크 계산 유틸/AI-폴백 전략 컴파일 로직 추가
|
||||
- 근거: `features/autotrade/types/autotrade.types.ts`, `lib/autotrade/risk.ts`, `lib/autotrade/strategy.ts`, `lib/autotrade/openai.ts`
|
||||
- [x] 2. 자동매매 API 라우트(`compile/validate/start/heartbeat/stop/active/signal`) 구현
|
||||
- 근거: `app/api/autotrade/**/route.ts`, `app/api/autotrade/_shared.ts`
|
||||
- [x] 3. 클라이언트 스토어/엔진 훅 구현(상태, heartbeat, 주문 실행, 중지)
|
||||
- 근거: `features/autotrade/stores/use-autotrade-engine-store.ts`, `features/autotrade/hooks/useAutotradeEngine.ts`, `features/autotrade/apis/autotrade.api.ts`
|
||||
- [x] 4. 트레이드 화면에 설정 패널/실행 경고 배너 통합
|
||||
- 근거: `features/autotrade/components/AutotradeControlPanel.tsx`, `features/autotrade/components/AutotradeWarningBanner.tsx`, `features/trade/components/TradeContainer.tsx`
|
||||
- [x] 5. 문서/환경변수(.env.example) 반영 및 계획 체크 업데이트
|
||||
- 근거: `.env.example`, 본 계획 문서 갱신
|
||||
- [x] 6. 설정 팝업 입력값 설명 강화 + 추천 프리셋(초보/균형/공격) 추가
|
||||
- 근거: `features/autotrade/components/AutotradeControlPanel.tsx`
|
||||
- [x] 7. 백엔드 워커 tick API 및 PM2 운영 스크립트/문서 추가
|
||||
- 근거: `app/api/autotrade/worker/tick/route.ts`, `scripts/autotrade-worker.mjs`, `scripts/pm2.autotrade-worker.config.cjs`, `common-docs/features/autotrade-worker-pm2.md`
|
||||
- [x] 8. 자동매매 세션 수명주기 E2E 스크립트 추가 및 실행
|
||||
- 근거: `scripts/autotrade-session-e2e.mjs`, `npm run test:autotrade:lifecycle` PASS
|
||||
- [x] 9. 구독형 CLI 자동판단 모드 추가(codex/gemini CLI)
|
||||
- 근거: `lib/autotrade/cli-provider.ts`, `app/api/autotrade/strategies/compile/route.ts`, `app/api/autotrade/signals/generate/route.ts`, `features/autotrade/components/AutotradeControlPanel.tsx`
|
||||
- [x] 10. Windows 개발 워커 실행 경로 추가
|
||||
- 근거: `package.json(worker:autotrade:dev)`, `common-docs/features/autotrade-worker-pm2.md`, `common-docs/features/autotrade-usage-security-guide.md`
|
||||
|
||||
[사용할 MCP/Skills]
|
||||
- MCP: next-devtools(nextjs_index/nextjs_call), playwright(스모크), shell_command
|
||||
- Skills: dev-auto-pipeline, dev-plan-writer, dev-mcp-implementation, nextjs-app-router-patterns, vercel-react-best-practices, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker
|
||||
|
||||
[참조 문서(common-docs)]
|
||||
- common-docs/ui/GLOBAL_ALERT_SYSTEM.md
|
||||
- common-docs/api-reference/kis_api_reference.md (주문 연동 시 기존 패턴 준수)
|
||||
- common-docs/api-reference/kis-error-code-reference.md (에러 표현 패턴 유지)
|
||||
- 사용자 지정 기획 입력: common-docs/features-autotrade-design.md
|
||||
|
||||
[주석/문서 반영 계획]
|
||||
- 함수 주석: [목적]/[사용처]/[데이터 흐름] 중심으로 핵심 흐름만 보강
|
||||
- 상태 주석: 자동매매 상태 변경이 화면에 미치는 영향 위주
|
||||
- 복잡 로직/핸들러: 신호 생성 -> 리스크 검증 -> 주문 실행 단계 주석
|
||||
- JSX 구역 주석: 설정 패널/경고 배너/상태 카드 구역 분리
|
||||
|
||||
[리스크/회귀 포인트]
|
||||
- 주문 API 호출 빈도 과다 시 중복 주문 위험
|
||||
- 브라우저 종료 시 stop beacon 실패 가능성
|
||||
- AI 출력 포맷 불안정 시 잘못된 신호 처리 위험
|
||||
- 기존 수동 주문 UX와 충돌(버튼/상태 동시 사용)
|
||||
|
||||
[검증 계획]
|
||||
- [x] 1. `npm run lint` 통과
|
||||
- 근거: ESLint 에러/경고 정리 후 재실행 통과
|
||||
- [x] 2. `npm run build` 통과
|
||||
- 근거: Next.js 16.1.6 프로덕션 빌드 성공, 신규 `/api/autotrade/*` 라우트 포함 확인
|
||||
- [x] 3. Playwright 스모크: `/trade` 자동매매 설정 패널 오픈 + 도움말/추천 프리셋 입력 반영 확인
|
||||
- 근거: `자동매매 설정` 모달 오픈, 쉬운 설명 문구 노출, `초보 추천` 클릭 시 수치 자동 반영 확인, 콘솔 error 없음
|
||||
- [x] 4. start -> heartbeat -> stop 상태 전환 검증
|
||||
- 근거: `npm run test:autotrade:lifecycle` PASS (`start -> heartbeat -> active -> stop -> active(null)`)
|
||||
|
||||
[진행 로그]
|
||||
- 2026-02-26: 초안 작성. 설계서 기준 MVP 범위(즉시 구현/보류) 확정.
|
||||
- 2026-02-26: 자동매매 MVP 구현 완료. 타입/유틸/API/스토어/엔진/트레이드 화면 통합 및 `.env.example` 갱신.
|
||||
- 2026-02-26: 검증 완료(`npm run lint`, `npm run build`, Playwright 스모크). 로그인+KIS 인증 기반 수동 E2E는 남은 확인 항목으로 기록.
|
||||
- 2026-02-26: 설정값 도움말/추천 프리셋(초보/균형/공격) 추가로 입력 이해도 개선.
|
||||
- 2026-02-26: 워커 tick API + PM2 운영 스크립트/문서 추가, `worker:autotrade:once` 정상 동작 확인.
|
||||
- 2026-02-26: 수명주기 자동 검증 스크립트(`test:autotrade:lifecycle`) 통과로 검증계획 4 완료.
|
||||
- 2026-02-26: 구독형 CLI 자동판단 모드(`subscription_cli`) 추가. OpenAI 미사용 환경에서 gemini/codex CLI 호출 후 JSON 파싱, 실패 시 규칙 기반 폴백하도록 리팩토링.
|
||||
- 2026-02-26: Windows PowerShell 기준 워커 실행 방법(환경변수 + `worker:autotrade(:dev)`) 문서화.
|
||||
@@ -0,0 +1,50 @@
|
||||
# 자동매매 가용자산 0원 차단 보완 계획
|
||||
|
||||
[계획 문서 경로]
|
||||
- common-docs/improvement/plans/dev-plan-2026-02-26-autotrade-cash-balance-fix.md
|
||||
|
||||
[요구사항 요약]
|
||||
- 자동매매 검증에서 `가용 자산 0원`으로 차단되는 문제를 보완한다.
|
||||
- `내 계좌 기준`으로 매수가능금액을 추가 조회해 검증 금액에 반영한다.
|
||||
- 기존 리스크 검증/주문 흐름은 유지한다.
|
||||
|
||||
[가정]
|
||||
- KIS 인증/계좌번호는 이미 설정되어 있다.
|
||||
- selectedStock의 종목코드와 가격 정보는 자동매매 시작 시점에 확보 가능하다.
|
||||
|
||||
[영향 범위]
|
||||
- 수정: features/autotrade/hooks/useAutotradeEngine.ts
|
||||
- 수정: features/trade/apis/kis-stock.api.ts
|
||||
- 수정: features/trade/types/trade.types.ts
|
||||
- 수정: lib/kis/trade.ts
|
||||
- 추가: app/api/kis/domestic/orderable-cash/route.ts
|
||||
|
||||
[구현 단계]
|
||||
- [x] 1. KIS 매수가능금액 조회 서버 라우트 추가
|
||||
- [x] 2. 프론트 API 클라이언트/타입 추가
|
||||
- [x] 3. 자동매매 prepareStrategy에서 cashBalance 0원 보정 로직 추가
|
||||
- [x] 4. 로그/주석 보강
|
||||
|
||||
[사용할 MCP/Skills]
|
||||
- Skills: dev-auto-pipeline, dev-mcp-implementation, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker
|
||||
|
||||
[참조 문서(common-docs)]
|
||||
- common-docs/api-reference/kis_api_reference.md
|
||||
- common-docs/api-reference/kis-error-code-reference.md
|
||||
|
||||
[리스크/회귀 포인트]
|
||||
- 매수가능금액 조회 실패 시 기존 cashBalance만 사용하도록 폴백 필요
|
||||
- 종목가격이 0 또는 비정상일 때 조회 파라미터 보정 필요
|
||||
|
||||
[검증 계획]
|
||||
- [x] 1. lint 통과
|
||||
- [x] 2. build 통과
|
||||
- [ ] 3. autotrade smoke 테스트 통과
|
||||
|
||||
[진행 로그]
|
||||
- 2026-02-26: 계획 문서 생성
|
||||
- 2026-02-26: `/api/kis/domestic/orderable-cash` 라우트 및 `executeInquireOrderableCash` 구현
|
||||
- 2026-02-26: 자동매매 `prepareStrategy`에서 cashBalance 0원 시 매수가능금액 보정 로직 반영
|
||||
- 2026-02-26: `npx eslint ...` 통과
|
||||
- 2026-02-26: `npm run build` 통과
|
||||
- 2026-02-26: smoke 테스트는 현재 3001 실행 프로세스가 dev bypass를 허용하지 않는 환경으로 로그인 필요 응답 확인(추가 환경 정리 후 재실행 필요)
|
||||
@@ -0,0 +1,58 @@
|
||||
# 자동매매 CLI 모델 선택 + AI 입력 데이터 흐름 보강 계획
|
||||
|
||||
## [계획 문서 경로]
|
||||
- `common-docs/improvement/plans/dev-plan-2026-02-26-autotrade-cli-model-selection.md`
|
||||
|
||||
## [요구사항 요약]
|
||||
- 자동매매가 AI 판단 시 어떤 데이터를 전달하는지 쉽게 설명한다.
|
||||
- Codex/Gemini CLI 모델을 공식 옵션 기준으로 선택 가능하게 만든다.
|
||||
- 로그/응답에서 실제 사용된 vendor/model을 확인 가능하게 만든다.
|
||||
|
||||
## [가정]
|
||||
- 구독형 CLI는 서버(개발/운영)에 설치되어 있고 로그인/인증이 완료되어 있다.
|
||||
- 모델 선택은 UI 입력보다 서버 환경변수 방식이 운영상 안전하다.
|
||||
|
||||
## [영향 범위]
|
||||
- 수정: `lib/autotrade/cli-provider.ts`
|
||||
- 수정: `app/api/autotrade/strategies/compile/route.ts`
|
||||
- 수정: `app/api/autotrade/signals/generate/route.ts`
|
||||
- 수정: `features/autotrade/types/autotrade.types.ts`
|
||||
- 수정: `features/autotrade/hooks/useAutotradeEngine.ts`
|
||||
- 수정: `.env.example`
|
||||
- 수정: `common-docs/features/autotrade-usage-security-guide.md`
|
||||
- 수정: `common-docs/features/autotrade-prompt-flow-guide.md`
|
||||
|
||||
## [구현 단계]
|
||||
- [x] 1. CLI 실행 인자에 vendor별 모델 선택 환경변수를 반영한다. (근거: `lib/autotrade/cli-provider.ts`)
|
||||
- [x] 2. compile/signal 응답에 `providerModel`을 포함해 추적 가능하게 만든다. (근거: `app/api/autotrade/strategies/compile/route.ts`, `app/api/autotrade/signals/generate/route.ts`)
|
||||
- [x] 3. 런타임 로그에 vendor/model을 함께 노출한다. (근거: `features/autotrade/hooks/useAutotradeEngine.ts`)
|
||||
- [x] 4. AI 입력 데이터(시세/전략) 흐름 설명을 문서에 보강한다. (근거: `common-docs/features/autotrade-usage-security-guide.md`, `common-docs/features/autotrade-prompt-flow-guide.md`)
|
||||
|
||||
## [사용할 MCP/Skills]
|
||||
- Skills: `dev-auto-pipeline`, `dev-mcp-implementation`, `dev-refactor-polish`, `dev-test-gate`, `dev-plan-completion-checker`
|
||||
- MCP: 없음(로컬 코드 수정 + 공식 문서 웹 근거 활용)
|
||||
|
||||
## [참조 문서(common-docs)]
|
||||
- `common-docs/features/trade-stock-sync.md` (참고만, 변경 없음)
|
||||
- `common-docs/ui/GLOBAL_ALERT_SYSTEM.md` (참고만, 변경 없음)
|
||||
|
||||
## [주석/문서 반영 계획]
|
||||
- 함수 주석: CLI 모델 선택 우선순위와 데이터 흐름 주석 보강
|
||||
- 상태/로그 주석: vendor/model 로그 의미를 한 줄로 명시
|
||||
- 흐름 문서: UI -> 훅 -> API -> route -> provider 단계 유지
|
||||
|
||||
## [리스크/회귀 포인트]
|
||||
- Codex CLI 모델명이 환경과 불일치하면 CLI 실패 후 fallback으로 전환될 수 있다.
|
||||
- 응답 스키마 필드 추가(`providerModel`)가 프론트 타입과 불일치하면 TS 오류가 날 수 있다.
|
||||
|
||||
## [검증 계획]
|
||||
- [x] 1. 변경 파일 eslint 검사 통과 (결과: 코드 파일 오류 없음, md 파일은 lint 대상 아님 경고)
|
||||
- [x] 2. `npm run build` 통과
|
||||
- [x] 3. 문서의 환경변수/확인 절차가 실제 로그 포맷과 일치
|
||||
|
||||
## [진행 로그]
|
||||
- 2026-02-26: 계획 문서 생성
|
||||
- 2026-02-26: CLI 모델 선택 환경변수(`AUTOTRADE_CODEX_MODEL`, `AUTOTRADE_GEMINI_MODEL`, `AUTOTRADE_SUBSCRIPTION_CLI_MODEL`) 반영
|
||||
- 2026-02-26: provider vendor/model 추적값 응답/로그 반영
|
||||
- 2026-02-26: AI 입력 데이터(시세 스냅샷/전략 제약) 설명 문서 보강
|
||||
- 2026-02-26: `npx eslint` + `npm run build` 검증 완료
|
||||
@@ -0,0 +1,71 @@
|
||||
# 자동매매 모델 선택 + 대시보드 잔고/매도 UX 개선 계획
|
||||
|
||||
## [계획 문서 경로]
|
||||
- `common-docs/improvement/plans/dev-plan-2026-02-26-autotrade-dashboard-ux-cli-models.md`
|
||||
|
||||
## [요구사항 요약]
|
||||
- AI 판단 입력 데이터(시세 스냅샷)가 무엇인지 쉽게 설명한다.
|
||||
- 자동매매 UI에서 구독형 CLI vendor/model을 선택할 수 있게 개선한다.
|
||||
- 대시보드 잔고 표시(총자산/순자산) 혼동을 줄이고, 매도 UX에 매도가능수량 정보를 보강한다.
|
||||
- 보유종목 잔존(전량 매도 후 표시) 문제를 점검하고 수정한다.
|
||||
- 자동매매 리스크 요약 문구를 초보자 기준으로 이해 가능하게 바꾼다.
|
||||
|
||||
## [가정]
|
||||
- 구독형 CLI 모델 목록은 "공식 문서 기준 추천 프리셋 + 직접 입력" 방식이 운영 안정성에 유리하다.
|
||||
- KIS 주식잔고조회 output1의 `ord_psbl_qty`(매도가능수량)를 우선 사용한다.
|
||||
|
||||
## [영향 범위]
|
||||
- 수정: `features/autotrade/types/autotrade.types.ts`
|
||||
- 수정: `lib/autotrade/strategy.ts`
|
||||
- 수정: `features/autotrade/apis/autotrade.api.ts`
|
||||
- 수정: `features/autotrade/hooks/useAutotradeEngine.ts`
|
||||
- 수정: `features/autotrade/components/AutotradeControlPanel.tsx`
|
||||
- 수정: `app/api/autotrade/strategies/compile/route.ts`
|
||||
- 수정: `app/api/autotrade/signals/generate/route.ts`
|
||||
- 수정: `lib/autotrade/cli-provider.ts`
|
||||
- 수정: `lib/kis/dashboard.ts`
|
||||
- 수정: `features/dashboard/types/dashboard.types.ts`
|
||||
- 수정: `features/dashboard/components/StatusHeader.tsx`
|
||||
- 수정: `features/dashboard/components/HoldingsList.tsx`
|
||||
- 수정: `features/trade/components/TradeContainer.tsx`
|
||||
- 수정: `features/trade/components/order/OrderForm.tsx`
|
||||
- 수정: `common-docs/features/autotrade-usage-security-guide.md`
|
||||
- 수정: `common-docs/features/autotrade-prompt-flow-guide.md`
|
||||
|
||||
## [구현 단계]
|
||||
- [x] 1. 자동매매 setup form에 CLI vendor/model 선택 필드를 추가한다.
|
||||
- [x] 2. compile/signal API 요청에 vendor/model 오버라이드를 전달하고 라우트/CLI provider에서 반영한다.
|
||||
- [x] 3. 공식 문서 기반 모델 프리셋(코덱스/제미나이) + 직접입력 UX를 패널에 추가한다.
|
||||
- [x] 4. 대시보드 잔고 파싱에서 수량 0 보유종목 제거/매도가능수량 필드를 반영한다.
|
||||
- [x] 5. 상단 자산 카드 라벨/표시 순서를 총자산 중심으로 개선한다.
|
||||
- [x] 6. 주문 패널 매도 탭에서 매도가능수량 기반 가이드/검증을 추가한다.
|
||||
- [x] 7. 자동매매 리스크 요약 문구를 쉬운 용어로 바꾸고 입력값 대비 계산 근거를 함께 노출한다.
|
||||
- [x] 8. 문서(사용 가이드/흐름 가이드)에 스냅샷 필드 설명과 모델 선택 기준을 반영한다.
|
||||
|
||||
## [사용한 공식 문서]
|
||||
- OpenAI Models: <https://platform.openai.com/docs/models>
|
||||
- OpenAI Codex CLI: <https://developers.openai.com/codex/cli>
|
||||
- Gemini CLI model selection: <https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/model.md>
|
||||
- Gemini CLI model routing precedence: <https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/model-routing.md>
|
||||
- KIS 매도가능수량조회 경로 참고: `common-docs/api-reference/kis_api_reference.md`, `.tmp/open-trading-api/examples_llm/domestic_stock/inquire_psbl_sell/inquire_psbl_sell.py`
|
||||
|
||||
## [리스크/회귀 포인트]
|
||||
- UI 필드 증가로 기존 자동매매 설정 저장/반영 흐름이 깨질 수 있음.
|
||||
- 모델명을 강제로 지정했을 때 vendor와 호환되지 않으면 CLI 실패 후 fallback으로 전환될 수 있음.
|
||||
- 보유종목 필터링 조건이 과도하면 실제 보유 종목이 누락될 수 있음.
|
||||
|
||||
## [검증 계획]
|
||||
- [x] 1. 변경 파일 eslint 통과
|
||||
- [x] 2. `npm run build` 통과
|
||||
- [x] 3. 대시보드에서 수량 0 종목 미노출 로직 반영 확인 (`lib/kis/dashboard.ts` 수량 0 필터)
|
||||
- [x] 4. 매도 탭에서 매도가능수량 초과 입력 차단 로직 반영 확인 (`OrderForm.tsx`)
|
||||
- [x] 5. 자동매매 로그에 vendor/model 노출 유지 확인 (`useAutotradeEngine.ts`)
|
||||
|
||||
## [진행 로그]
|
||||
- 2026-02-26: 계획 문서 생성
|
||||
- 2026-02-26: 자동매매 설정창에 구독형 CLI vendor/model 선택 UI 추가
|
||||
- 2026-02-26: compile/signal route와 CLI provider에 vendor/model override 반영
|
||||
- 2026-02-26: 대시보드 잔고 파싱에 `ord_psbl_qty` 반영, 수량 0 종목 필터링 적용
|
||||
- 2026-02-26: StatusHeader 총자산 중심 표기 개편, 매도 UX(매도가능수량 표시/검증) 개선
|
||||
- 2026-02-26: 리스크 요약 문구를 쉬운 용어로 교체, 스냅샷/모델선택 문서 보강
|
||||
- 2026-02-26: `npx eslint` 및 `npm run build` 통과
|
||||
@@ -0,0 +1,55 @@
|
||||
[계획 문서 경로]
|
||||
- common-docs/improvement/plans/dev-plan-2026-02-26-market-indices-display.md
|
||||
|
||||
[요구사항 요약]
|
||||
- 메인 레이아웃의 헤더에 KOSPI 및 KOSDAQ 지수를 표시한다.
|
||||
- 지수에는 현재가, 전일 대비 등락, 등락률이 포함되어야 한다.
|
||||
- 데이터는 30초마다 자동으로 새로고침되어야 한다.
|
||||
|
||||
[가정]
|
||||
- 사용자는 로그인 상태이며 KIS API 키가 설정되어 있다고 가정한다.
|
||||
- `lib/kis/dashboard.ts`의 `getDomesticDashboardIndices` 함수가 정상 동작한다고 가정한다.
|
||||
|
||||
[영향 범위]
|
||||
- 수정:
|
||||
- `features/layout/components/header.tsx`: `MarketIndices` 컴포넌트를 추가하고 레이아웃을 조정.
|
||||
- 추가:
|
||||
- `app/api/kis/indices/route.ts`: KIS 지수 데이터를 조회하는 새로운 API 라우트.
|
||||
- `features/layout/stores/market-indices-store.ts`: 지수 데이터 상태 관리를 위한 Zustand 스토어.
|
||||
- `features/layout/hooks/use-market-indices.ts`: 지수 데이터를 가져오는 커스텀 훅.
|
||||
- `features/layout/components/market-indices.tsx`: 지수 정보를 표시하는 UI 컴포넌트.
|
||||
- 삭제:
|
||||
- 없음
|
||||
|
||||
[구현 단계]
|
||||
- [x] 1. KIS 지수 API 라우트 생성 (`app/api/kis/indices/route.ts`): `getDomesticDashboardIndices` 함수를 사용하여 KOSPI, KOSDAQ 지수 정보를 반환하는 GET 엔드포인트를 구현.
|
||||
- [x] 2. 상태 관리 스토어 생성 (`features/layout/stores/market-indices-store.ts`): 지수 데이터, 로딩 상태, 에러 상태를 관리하기 위한 Zustand 스토어를 생성.
|
||||
- [x] 3. 커스텀 훅 생성 (`features/layout/hooks/use-market-indices.ts`): 위에서 만든 API 라우트를 호출하고, 스토어의 상태를 업데이트하는 `useMarketIndices` 훅을 구현.
|
||||
- [x] 4. UI 컴포넌트 생성 (`features/layout/components/market-indices.tsx`): `useMarketIndices` 훅을 사용하여 지수 정보를 받아와 화면에 표시하는 컴포넌트를 생성. 30초마다 데이터를 폴링하는 로직을 포함.
|
||||
- [x] 5. 헤더에 컴포넌트 추가 (`features/layout/components/header.tsx`): 생성된 `MarketIndices` 컴포넌트를 헤더 중앙에 추가하고, 로그인 및 `blendWithBackground` 상태에 따라 노출 여부를 제어.
|
||||
|
||||
[사용할 MCP/Skills]
|
||||
- MCP: 없음
|
||||
- Skills: `dev-plan-writer`, `dev-mcp-implementation`, `dev-refactor-polish`, `dev-test-gate`, `dev-plan-completion-checker`
|
||||
|
||||
[참조 문서(common-docs)]
|
||||
- `common-docs/api-reference/kis_api_reference.md`
|
||||
|
||||
[주석/문서 반영 계획]
|
||||
- 각 파일 상단에 파일의 목적과 역할을 설명하는 JSDoc 주석을 추가.
|
||||
- 주요 함수에 파라미터와 반환 값, 역할을 설명하는 주석을 추가.
|
||||
|
||||
[리스크/회귀 포인트]
|
||||
- KIS API 호출 실패 시 에러 처리가 적절히 이루어지지 않으면 UI가 깨지거나 오류 메시지가 표시되지 않을 수 있다.
|
||||
- 자동 새로고침 로직이 메모리 누수를 일으키지 않도록 `useEffect`의 cleanup 함수를 정확히 구현해야 한다.
|
||||
|
||||
[검증 계획]
|
||||
- [ ] 1. **API 라우트 검증**: 브라우저나 API 테스트 도구로 `/api/kis/indices`를 직접 호출하여 정상적인 JSON 응답(지수 데이터, fetchedAt)이 오는지 확인.
|
||||
- [ ] 2. **UI 초기 로딩 검증**: 페이지 로드 시 `MarketIndices` 컴포넌트 영역에 스켈레톤 UI가 먼저 표시되는지 확인.
|
||||
- [ ] 3. **UI 데이터 표시 검증**: 데이터 로딩 완료 후 KOSPI, KOSDAQ 지수 정보(현재가, 등락, 등락률)가 헤더에 정상적으로 표시되는지 확인. 등락에 따라 색상(빨강/파랑)이 올바르게 적용되는지 확인.
|
||||
- [ ] 4. **UI 자동 새로고침 검증**: 약 30초가 지난 후 `fetchedAt` 시간이 갱신되며 데이터가 새로고침되는지 네트워크 탭과 화면 표시를 통해 확인.
|
||||
- [ ] 5. **로그아웃/비로그인 상태 검증**: 로그아웃하거나 비로그인 상태로 접속했을 때, 지수 컴포넌트가 헤더에 표시되지 않는지 확인.
|
||||
- [ ] 6. **홈 랜딩 페이지 검증**: `blendWithBackground` prop이 `true`로 설정된 홈 랜딩 페이지에서 지수 컴포넌트가 표시되지 않는지 확인.
|
||||
|
||||
[진행 로그]
|
||||
- 2026-02-26: 계획 문서 작성 및 기능 구현 완료.
|
||||
@@ -0,0 +1,81 @@
|
||||
[계획 문서 경로]
|
||||
- common-docs/improvement/plans/dev-plan-2026-03-04-dashboard-market-hub-and-orderbook-rate.md
|
||||
|
||||
[요구사항 요약]
|
||||
- 호가창 각 호가 행에 기준가 대비 퍼센트(등락률)를 추가 표시한다.
|
||||
- /dashboard 안에서 내 종목/내 재산/주문내역 같은 개인 자산 정보를 별도 메뉴(탭)로 분리한다.
|
||||
- /dashboard 메인 화면에는 급등주식, 인기종목, 주요 뉴스와 추가 시장 정보 카드를 배치한다.
|
||||
|
||||
[가정]
|
||||
- "메뉴를 하나 새로"는 /dashboard 내부 탭 메뉴(시장 탭/내 자산 탭) 추가로 해석한다.
|
||||
- 기존 KIS 인증/세션 헤더 체계는 유지하고, 신규 데이터도 동일 헤더로 조회한다.
|
||||
- 인기종목은 거래량 기준 상위(필요 시 거래대금 기준 포함)로 제공한다.
|
||||
|
||||
[영향 범위]
|
||||
- 수정:
|
||||
- features/trade/components/orderbook/orderbook-utils.ts
|
||||
- features/trade/components/orderbook/orderbook-sections.tsx
|
||||
- features/dashboard/types/dashboard.types.ts
|
||||
- features/dashboard/apis/dashboard.api.ts
|
||||
- features/dashboard/hooks/use-dashboard-data.ts
|
||||
- features/dashboard/components/DashboardContainer.tsx
|
||||
- lib/kis/dashboard.ts
|
||||
- 추가:
|
||||
- app/api/kis/domestic/market-hub/route.ts
|
||||
- features/dashboard/components/MarketHubSection.tsx
|
||||
- 삭제:
|
||||
- features/dashboard/hooks/use-market-movers-alert.ts
|
||||
|
||||
[구현 단계]
|
||||
- [x] 1. 호가창 퍼센트 표시 로직 추가: 기준가 대비 등락률 계산 유틸을 만들고 호가 행 UI에 퍼센트를 노출한다. (`features/trade/components/orderbook/orderbook-utils.ts`, `features/trade/components/orderbook/orderbook-sections.tsx`)
|
||||
- [x] 2. 대시보드 시장 허브 API 추가: 급등주식/거래량 상위/뉴스(및 보조 지표)를 KIS에서 조회해 단일 응답으로 반환한다. (`lib/kis/dashboard.ts`, `app/api/kis/domestic/market-hub/route.ts`)
|
||||
- [x] 3. 대시보드 데이터 훅 확장: 기존 balance/indices/activity에 market-hub 데이터를 병렬 조회하고 에러 상태를 분리 관리한다. (`features/dashboard/hooks/use-dashboard-data.ts`, `features/dashboard/apis/dashboard.api.ts`, `features/dashboard/types/dashboard.types.ts`)
|
||||
- [x] 4. /dashboard 메뉴 분리: "시장" 탭과 "내 자산" 탭을 만들고 개인 자산 컴포넌트를 "내 자산" 탭으로 이동한다. (`features/dashboard/components/DashboardContainer.tsx`)
|
||||
- [x] 5. 시장 탭 구성: 급등주식, 인기종목, 주요 뉴스, 추가 정보(시장 폭/업다운 카운트)를 카드로 구성한다. (`features/dashboard/components/MarketHubSection.tsx`)
|
||||
|
||||
[사용할 MCP/Skills]
|
||||
- MCP: next-devtools(런타임 점검), web search(요구사항의 검색 반영)
|
||||
- Skills: dev-auto-pipeline, dev-plan-writer, dev-mcp-implementation, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker, nextjs-app-router-patterns, vercel-react-best-practices
|
||||
|
||||
[참조 문서(common-docs)]
|
||||
- common-docs/api-reference/openapi_all.xlsx
|
||||
- common-docs/api-reference/kis_api_reference.md
|
||||
- common-docs/api-reference/kis-error-code-reference.md
|
||||
- common-docs/features/trade-stock-sync.md
|
||||
- common-docs/ui/GLOBAL_ALERT_SYSTEM.md
|
||||
|
||||
[주석/문서 반영 계획]
|
||||
- 함수 주석: [목적]/[사용처]/[데이터 흐름] 중심으로 유지한다.
|
||||
- 상태 주석: market-hub 로딩/오류 상태가 화면에 미치는 영향 한 줄 주석을 추가한다.
|
||||
- 복잡 로직: 시장 허브 응답 정규화는 1,2,3 단계 주석으로 분해한다.
|
||||
|
||||
[리스크/회귀 포인트]
|
||||
- KIS 순위/뉴스 API는 파라미터 조합에 따라 빈 응답이 나올 수 있어 폴백 파라미터가 필요하다.
|
||||
- 신규 시장 API 실패 시에도 기존 내 자산 탭은 정상 동작해야 한다.
|
||||
- 호가 퍼센트 표시가 모바일에서 줄바꿈/폭 깨짐을 유발할 수 있어 반응형 폭 점검이 필요하다.
|
||||
|
||||
[검증 계획]
|
||||
- [x] 1. lint: 타입/린트 오류 없이 통과하는지 확인. (`npm run lint` 통과)
|
||||
- [x] 2. build: Next.js 프로덕션 빌드가 통과하는지 확인. (`npm run build` 통과)
|
||||
- [x] 3. 런타임: /dashboard 진입 후 시장 탭/내 자산 탭 전환이 정상 동작하는지 확인. (Playwright MCP에서 탭 전환 및 화면 반영 확인)
|
||||
- [x] 4. 런타임: 시장 탭에서 급등/인기/뉴스 카드가 실패 시에도 개별 에러 안내로 안전하게 렌더링되는지 확인. (Playwright MCP route abort로 `/api/kis/domestic/market-hub` 실패 주입 후 `Failed to fetch` + 빈 카드 안전 렌더링 확인)
|
||||
- [x] 5. 런타임: /trade 호가창에서 각 가격 행에 퍼센트가 표시되는지 확인. (Playwright MCP로 `/dashboard` 종목 클릭 이동 후 일반호가 행 `±x.xx%` 표기 확인)
|
||||
|
||||
[진행 로그]
|
||||
- 2026-03-04: 계획 문서 작성.
|
||||
- 2026-03-04: 구현 1~5 완료, `npm run lint`/`npm run build` 통과.
|
||||
- 2026-03-04: 브라우저 스모크 실행 시 `/dashboard`, `/trade`, `/settings`가 비로그인 상태에서 `/login`으로 리다이렉트되는 동작 확인.
|
||||
- 2026-03-04: 급등주 미노출 대응(등락률 API 파라미터 폴백 + 거래량 기반 폴백) 적용.
|
||||
- 2026-03-04: 급락주 데이터 및 급등/급락 주기 알림(60초 갱신 + 3분 쿨다운 모달) 추가.
|
||||
- 2026-03-04: 요청 반영으로 급등/급락 전역 모달 알림 훅 제거.
|
||||
- 2026-03-04: KIS 문서/코드 기준 급등·급락 웹소켓 수신 가능성 검토 완료(순위는 REST, WS는 종목 체결/호가 중심).
|
||||
- 2026-03-04: Playwright MCP로 `/dashboard` 시장/내 자산 탭 전환 정상 동작 재검증 완료.
|
||||
- 2026-03-04: Playwright MCP route abort 주입으로 시장 허브 API 실패 시 에러 안내/빈 상태 카드 안전 렌더링 확인.
|
||||
- 2026-03-04: Playwright MCP로 급등/급락/인기/거래대금 카드 종목 클릭 시 `/trade` 이동 및 선택 종목 반영 확인.
|
||||
- 2026-03-04: Playwright MCP로 `/trade` 일반호가 각 가격 행의 퍼센트(등락률) 표기 확인.
|
||||
|
||||
[계획 대비 완료체크]
|
||||
- 완료: 구현 1~5, 검증 1~5
|
||||
- 부분 완료: 없음
|
||||
- 미완료: 없음
|
||||
- 최종 판정: 배포 가능
|
||||
@@ -0,0 +1,50 @@
|
||||
[계획 문서 경로]
|
||||
- common-docs/improvement/plans/dev-plan-2026-03-04-dashboard-modern-brand-layout-refresh.md
|
||||
|
||||
[요구사항 요약]
|
||||
- /dashboard를 모던하고 현대적인 느낌으로 재배치한다.
|
||||
- 브랜드 컬러(brand 토큰)를 적극 활용해 UI 일관성을 높인다.
|
||||
- 핵심 정보를 한눈에 확인할 수 있도록 정보 우선순위를 재정렬한다.
|
||||
|
||||
[가정]
|
||||
- 기존 정보 구조(시장 탭/내 자산 탭, API/데이터 모델)는 유지하고 UI/레이아웃 중심으로 개선한다.
|
||||
- 기존 브랜드 토큰(--brand-*)을 재사용해 전체 앱 디자인 언어와 일관성을 맞춘다.
|
||||
|
||||
[영향 범위]
|
||||
- 수정:
|
||||
- features/dashboard/components/DashboardContainer.tsx
|
||||
- features/dashboard/components/StatusHeader.tsx
|
||||
- features/dashboard/components/MarketSummary.tsx
|
||||
- features/dashboard/components/MarketHubSection.tsx
|
||||
- features/dashboard/components/HoldingsList.tsx
|
||||
- features/dashboard/components/StockDetailPreview.tsx
|
||||
- features/dashboard/components/ActivitySection.tsx
|
||||
|
||||
[구현 단계]
|
||||
- [x] 1. 대시보드 컨테이너 재배치: 상단 브랜드 히어로/상태 칩 추가, 탭 인터랙션 스타일 강화, 탭별 레이아웃 재정렬.
|
||||
- [x] 2. 자산 헤더 리디자인: 총자산/손익 중심 카드 + 연결 상태/액션 패널 + 핵심 지표 4분할 구성.
|
||||
- [x] 3. 시장 지수 카드 리디자인: 실시간 상태 배지, 지수 카드 시각 톤 강화, 카드 대비 개선.
|
||||
- [x] 4. 시장 허브 리디자인: 급등/급락/인기/거래대금 2x2 구성 및 뉴스 가독성 개선.
|
||||
- [x] 5. 자산 하위 카드 톤 정렬: 보유종목/선택종목/활동내역 카드 스타일 일관화 및 탭 버튼 강조.
|
||||
- [x] 6. 사후 버그 수정: 시장 지수 배지의 실시간 상태 판정을 상단 상태칩과 동일 기준으로 통일.
|
||||
- [x] 7. 사후 버그 수정: Next.js `scroll-behavior` 경고 제거를 위한 루트 html 속성 보완.
|
||||
|
||||
[리스크/회귀 포인트]
|
||||
- 카드 높이/스크롤 높이 조정으로 모바일에서 콘텐츠 길이 체감이 달라질 수 있다.
|
||||
- 탭 스타일 커스터마이징이 다크 모드 대비에 영향을 줄 수 있다.
|
||||
|
||||
[검증 계획]
|
||||
- [x] 1. lint: `npm run lint` 통과.
|
||||
- [x] 2. build: `npm run build` 통과.
|
||||
- [x] 3. 런타임: 로그인 상태에서 /dashboard 시각적 배치/반응형 확인. (Playwright로 데스크톱/모바일, 탭 전환, 메인 왕복 동선 확인)
|
||||
- [x] 4. 런타임: 브라우저 콘솔 경고/오류 확인. (`warning`/`error` 비어있음)
|
||||
- [x] 5. 런타임: API 네트워크 응답 확인. (`/api/kis/domestic/indices`, `/api/kis/domestic/market-hub`, `/api/kis/ws/approval` 모두 200)
|
||||
|
||||
[진행 로그]
|
||||
- 2026-03-04: 대시보드 모던 UI 재배치 구현 완료.
|
||||
- 2026-03-04: `npm run lint` 통과.
|
||||
- 2026-03-04: `npm run build` 통과.
|
||||
- 2026-03-04: Playwright로 `/dashboard` 접근 시 `/login` 리다이렉트 동작 및 모바일 뷰포트(390x844) 렌더링 확인.
|
||||
- 2026-03-04: 시장 지수 배지 상태 문구 불일치(실시간 미연결 vs 수신중) 수정.
|
||||
- 2026-03-04: `app/layout.tsx`에 `data-scroll-behavior=\"smooth\"` 추가로 Next 경고 제거.
|
||||
- 2026-03-04: Playwright 재검증(데스크톱/모바일, 로고->메인, 메인->대시보드, 자산 탭 전환) 완료.
|
||||
@@ -0,0 +1,78 @@
|
||||
[계획 문서 경로]
|
||||
- common-docs/improvement/plans/dev-plan-2026-03-05-autotrade-ai-context-layout-boxrange.md
|
||||
|
||||
[요구사항 요약]
|
||||
- 자동매매 신호 생성 시 AI 판단 입력 데이터를 늘린다.
|
||||
- 자동매매 설정창 높이 문제를 해결하고 레이아웃을 더 간결하게 정리한다.
|
||||
- 유명기법을 선택하지 않아도 자동매매가 동작하도록 기본 동작을 완화한다.
|
||||
- "당일 상승 후 박스권 횡보 단타" 기법을 새로 추가한다.
|
||||
|
||||
[가정]
|
||||
- "유명기법 미선택 허용"은 시작 자체 허용 + 서버에서 기본 기법 자동 적용으로 해석한다.
|
||||
- 박스권 단타 기법은 fallback 엔진(규칙 기반)에서 즉시 동작하도록 우선 구현한다.
|
||||
- AI/CLI 모드에도 동일한 추가 스냅샷 데이터를 전달해 판단 품질을 함께 높인다.
|
||||
|
||||
[영향 범위]
|
||||
- 수정:
|
||||
- features/autotrade/types/autotrade.types.ts
|
||||
- features/autotrade/components/AutotradeControlPanel.tsx
|
||||
- features/autotrade/hooks/useAutotradeEngine.ts
|
||||
- features/autotrade/apis/autotrade.api.ts
|
||||
- lib/autotrade/strategy.ts
|
||||
- lib/autotrade/openai.ts
|
||||
- app/api/autotrade/strategies/compile/route.ts
|
||||
- app/api/autotrade/signals/generate/route.ts
|
||||
- 추가:
|
||||
- 없음
|
||||
- 삭제:
|
||||
- 없음
|
||||
|
||||
[구현 단계]
|
||||
- [x] 1. 타입/스키마 확장: 자동매매 스냅샷에 체결/호가/파생 지표 필드를 추가하고 클라이언트/서버 타입을 동기화했다. (`features/autotrade/types/autotrade.types.ts`, `features/autotrade/apis/autotrade.api.ts`, `app/api/autotrade/signals/generate/route.ts`)
|
||||
- [x] 2. AI 입력 데이터 확장: `useAutotradeEngine`에서 추가 지표를 계산해 signal API로 전달하고, OpenAI 프롬프트 안내 문구를 업데이트했다. (`features/autotrade/hooks/useAutotradeEngine.ts`, `lib/autotrade/openai.ts`, `features/autotrade/components/AutotradeControlPanel.tsx`)
|
||||
- [x] 3. 유명기법 미선택 허용: 시작 버튼 조건/사전 검증 제한을 완화하고, compile 라우트에서 기본 기법 자동 적용을 넣었다. (`features/autotrade/components/AutotradeControlPanel.tsx`, `features/autotrade/hooks/useAutotradeEngine.ts`, `app/api/autotrade/strategies/compile/route.ts`)
|
||||
- [x] 4. 박스권 단타 기법 추가: 기법 목록에 항목을 추가하고 fallback 신호 로직에 박스권 왕복 단타 판단을 구현했다. (`features/autotrade/types/autotrade.types.ts`, `lib/autotrade/strategy.ts`)
|
||||
- [x] 5. 자동매매창 레이아웃 개선: 모달 높이 잘림을 없애고(내부 스크롤), 섹션 구조를 간결화했다. (`features/autotrade/components/AutotradeControlPanel.tsx`)
|
||||
|
||||
[사용할 MCP/Skills]
|
||||
- MCP: shell_command, apply_patch, playwright
|
||||
- Skills: dev-auto-pipeline, dev-plan-writer, dev-mcp-implementation, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker, nextjs-app-router-patterns, vercel-react-best-practices
|
||||
|
||||
[참조 문서(common-docs)]
|
||||
- common-docs/api-reference/openapi_all.xlsx
|
||||
- common-docs/api-reference/kis_api_reference.md
|
||||
- common-docs/api-reference/kis-error-code-reference.md
|
||||
- common-docs/features/trade-stock-sync.md
|
||||
- common-docs/ui/GLOBAL_ALERT_SYSTEM.md
|
||||
|
||||
[주석/문서 반영 계획]
|
||||
- 데이터 흐름 주석: "입력 데이터 확장" 구간에 [Step 1]/[Step 2]를 추가한다.
|
||||
- UI 주석: 모달 섹션을 상단 요약/설정 본문/하단 액션으로 분리해 가독성을 유지한다.
|
||||
- 박스권 기법 주석: 조건(상승폭, 박스 범위, 상하단 근접)과 신호 방향을 한글로 명확히 남긴다.
|
||||
|
||||
[리스크/회귀 포인트]
|
||||
- 스냅샷 필드 확장 시 signal 라우트 zod 스키마 불일치가 발생할 수 있다.
|
||||
- 유명기법 미선택 허용 이후에도 과도한 신호가 나오지 않게 fallback 신호 품질을 확인해야 한다.
|
||||
- 설정 모달 레이아웃 변경 시 모바일에서 버튼 접근/스크롤 충돌이 생길 수 있다.
|
||||
|
||||
[검증 계획]
|
||||
- [x] 1. lint: 타입/린트 오류 없이 통과 (`npm run lint` 통과)
|
||||
- [x] 2. build: 프로덕션 빌드 통과 (`npm run build` 통과)
|
||||
- [x] 3. 동작: 기법 미선택 허용 코드 경로 확인 (`canStartAutotrade` 조건 완화, `prepareStrategy` 필수 체크 제거, compile 기본 기법 자동 적용)
|
||||
- [x] 4. 동작: 박스권 단타 기법이 목록/enum/fallback 로직에 반영됨을 코드 경로 확인
|
||||
- [x] 5. 동작: 설정 화면 스모크에서 신규 체크박스/설정 UI 접근 및 콘솔 치명 오류 없음 확인 (Playwright). 자동매매 설정 모달은 KIS 미연결 환경으로 직접 실행 검증은 제한
|
||||
|
||||
[진행 로그]
|
||||
- 2026-03-05: 계획 문서 작성.
|
||||
- 2026-03-05: 자동매매 스냅샷 확장(체결/호가/파생 지표) 및 signal API 스키마 동기화 완료.
|
||||
- 2026-03-05: 유명기법 미선택 허용(기본 기법 자동 적용) 반영 완료.
|
||||
- 2026-03-05: "상승 후 박스권 단타" 기법 추가 및 fallback 신호 로직 구현 완료.
|
||||
- 2026-03-05: 자동매매 설정 모달 레이아웃 간소화/높이 잘림 개선(내부 스크롤) 적용.
|
||||
- 2026-03-05: `npm run lint`, `npm run build` 통과.
|
||||
- 2026-03-05: Playwright 스모크(`/trade`, `/dashboard`, `/settings`) 확인, 콘솔 치명 오류 없음.
|
||||
|
||||
[계획 대비 완료체크]
|
||||
- 완료: 구현 1~5, 검증 1~5
|
||||
- 부분 완료: 없음
|
||||
- 미완료: 없음
|
||||
- 최종 판정: 배포 가능
|
||||
@@ -0,0 +1,87 @@
|
||||
[계획 문서 경로]
|
||||
- common-docs/improvement/plans/dev-plan-2026-03-05-autotrade-observability-momentum-scalp.md
|
||||
|
||||
[요구사항 요약]
|
||||
- 자동매매 로그에 AI 진행상태/응답 근거/주요 수치가 보이도록 개선한다.
|
||||
- 자동매매에서 AI로 보내는 데이터 항목을 코드 기준으로 명확히 보여준다(로그/설명 근거 강화).
|
||||
- 1분봉 상승구간 단타(눌림-재돌파) 기법을 새로 추가한다.
|
||||
|
||||
[가정]
|
||||
- "좋은 데이터 로그"는 사용자 화면에서 즉시 확인 가능한 런타임 로그 품질 개선(단계, 공급자, 핵심 수치, AI 근거)으로 해석한다.
|
||||
- AI 전송 데이터 "상세" 요구는 코드 반영(진단 로그) + 최종 보고에서 필드 목록/흐름 설명으로 충족한다.
|
||||
- 신규 전략은 규칙 기반 fallback에서 즉시 동작하고, OpenAI/CLI 프롬프트 가이드에도 동일 기법명을 반영한다.
|
||||
|
||||
[영향 범위]
|
||||
- 수정:
|
||||
- features/autotrade/types/autotrade.types.ts
|
||||
- features/autotrade/stores/use-autotrade-engine-store.ts
|
||||
- features/autotrade/hooks/useAutotradeEngine.ts
|
||||
- features/autotrade/components/AutotradeControlPanel.tsx
|
||||
- lib/autotrade/strategy.ts
|
||||
- lib/autotrade/openai.ts
|
||||
- app/api/autotrade/signals/generate/route.ts (필요 시)
|
||||
- common-docs/improvement/plans/dev-plan-2026-03-05-autotrade-observability-momentum-scalp.md
|
||||
- 추가:
|
||||
- 없음
|
||||
- 삭제:
|
||||
- 없음
|
||||
|
||||
[구현 단계]
|
||||
- [x] 1. 로그 타입 확장: 런타임 로그에 단계(stage)와 상세 데이터(detail)를 담을 수 있게 타입/스토어를 확장한다. (`features/autotrade/types/autotrade.types.ts`, `features/autotrade/stores/use-autotrade-engine-store.ts`)
|
||||
- [x] 2. 엔진 로그 강화: compile/signal/risk/order 흐름에서 "요청 전송", "AI 응답", "주문 차단/실행"을 구조화 로그로 남긴다. (`features/autotrade/hooks/useAutotradeEngine.ts`)
|
||||
- [x] 3. 로그 UI 개선: 상단 최근 로그 영역에서 단계/레벨/상세 데이터를 읽기 쉽게 표시한다. (`features/autotrade/components/AutotradeControlPanel.tsx`)
|
||||
- [x] 4. 상승구간 단타 기법 추가: 기법 enum/옵션 추가 + fallback 로직(추세 필터, 눌림 구간, 재돌파, 거래량 확인) 구현. (`features/autotrade/types/autotrade.types.ts`, `lib/autotrade/strategy.ts`)
|
||||
- [x] 5. OpenAI 가이드 반영: 신규 기법 설명과 판단 제약을 프롬프트에 반영한다. (`lib/autotrade/openai.ts`)
|
||||
|
||||
[사용할 MCP/Skills]
|
||||
- MCP: shell_command, apply_patch, tavily-remote, playwright
|
||||
- Skills: dev-auto-pipeline, dev-plan-writer, dev-mcp-implementation, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker, vercel-react-best-practices
|
||||
|
||||
[참조 문서(common-docs)]
|
||||
- common-docs/api-reference/openapi_all.xlsx
|
||||
- common-docs/api-reference/kis_api_reference.md
|
||||
- common-docs/api-reference/kis-error-code-reference.md
|
||||
- common-docs/features/trade-stock-sync.md
|
||||
- common-docs/ui/GLOBAL_ALERT_SYSTEM.md
|
||||
|
||||
[외부 근거(전략 설계)]
|
||||
- Investopedia Flag Pattern: 상승 추세 + 조정 중 거래량 축소 + 돌파 시 거래량 확인
|
||||
- Investopedia Low Volume Pullback: 저거래량 눌림 후 추세 재개 확률
|
||||
- Fidelity Technical Analysis(학습 PDF): 이동평균 기반 추세/눌림 해석, 거래량/모멘텀 보조 확인
|
||||
|
||||
[주석/문서 반영 계획]
|
||||
- 함수 주석: [목적]/[데이터 흐름] 유지
|
||||
- 상태 주석: 로그 stage/detail 도입 영향 표시
|
||||
- 복잡 로직: 신규 상승구간 단타 판단 함수를 [Step 1~3] 주석으로 분리
|
||||
- JSX 구역 주석: 로그 카드 영역을 단계/상세 구분 렌더링으로 분리
|
||||
|
||||
[리스크/회귀 포인트]
|
||||
- 로그 데이터가 과도하면 UI 가독성이 저하될 수 있어 길이 제한/요약이 필요하다.
|
||||
- 신규 전략이 기존 박스권 단타와 동시에 점수를 높여 과매수 신호가 늘 수 있어 임계값을 보수적으로 둔다.
|
||||
- 타입 확장 시 기존 appendLog 호출과의 호환성을 유지해야 한다.
|
||||
|
||||
[검증 계획]
|
||||
- [x] 1. lint: 타입/린트 오류 없는지 확인 (`npm run lint` 통과)
|
||||
- [x] 2. build: Next 빌드 통과 확인 (`npm run build` 통과)
|
||||
- [x] 3. 동작: 자동매매 로그에 stage/detail이 표시되는지 코드 경로/UI 확인 (`useAutotradeEngine` 로그 작성 + `AutotradeControlPanel` 렌더 반영)
|
||||
- [x] 4. 동작: 신규 기법이 목록과 fallback 로직에 반영됐는지 확인 (`intraday_breakout_scalp` enum/옵션/룰 추가)
|
||||
- [x] 5. 동작: /trade 화면 스모크에서 콘솔 오류 없이 렌더링 확인 (Playwright). 비로그인 환경으로 `/login` 리다이렉트 확인, 콘솔 error 없음
|
||||
|
||||
[진행 로그]
|
||||
- 2026-03-05: 계획 문서 작성.
|
||||
- 2026-03-05: 런타임 로그 구조(stage/detail) 확장 및 로그 UI 상세표시 반영.
|
||||
- 2026-03-05: 로그 UI를 기본 접힘(차트 가림 최소화) + 쉬운 문장 요약 + 개발자 상세 토글 + 라이브 커서 표시로 개선.
|
||||
- 2026-03-05: AI 신호 요청/응답/리스크게이트/주문실행 흐름 구조화 로그 반영.
|
||||
- 2026-03-05: 상승구간 눌림-재돌파 단타(`intraday_breakout_scalp`) 기법 추가.
|
||||
- 2026-03-05: AI 신호 사유 한글 강제(프롬프트 + 서버 후처리) 반영.
|
||||
- 2026-03-05: 상단 예산 카드에 검증 전 입력 기준 예산 표시 추가.
|
||||
- 2026-03-05: 신호 API 호출을 in-flight 순차 처리로 변경(이전 응답 완료 전 재호출 차단).
|
||||
- 2026-03-05: 상단 로그를 `입력 -> 답변` 1쌍 고정 표시로 개선(응답 대기 상태 포함).
|
||||
- 2026-03-05: `npm run lint`, `npm run build` 통과.
|
||||
- 2026-03-05: Playwright 스모크(`/trade`, `/dashboard`, `/settings`) 실행, 비로그인 리다이렉트 경로에서 콘솔 error 없음.
|
||||
|
||||
[계획 대비 완료체크]
|
||||
- 완료: 구현 1~5, 검증 1~5
|
||||
- 부분 완료: 없음
|
||||
- 미완료: 없음
|
||||
- 최종 판정: 배포 가능
|
||||
@@ -0,0 +1,63 @@
|
||||
[계획 문서 경로]
|
||||
- common-docs/improvement/plans/dev-plan-2026-03-05-autotrade-risk-input-simplify.md
|
||||
|
||||
[요구사항 요약]
|
||||
- 자동매매 투자금/손실 설정의 기존 복잡한 계산 로직(작은 값 선택)을 제거한다.
|
||||
- 사용자가 입력한 투자금 금액/손실 금액이 실제 거래 기준으로 직접 반영되게 한다.
|
||||
- 퍼센트 입력은 유지하되, 이해하기 쉬운 기준(경고/참고)으로 단순화한다.
|
||||
|
||||
[가정]
|
||||
- "로직 없애고"는 `min(퍼센트 계산값, 금액)` 기반 자동 축소 로직 제거로 해석한다.
|
||||
- 실제 주문 예산은 `투자금 금액(allocationAmount)` 그대로 사용한다.
|
||||
- 자동중지 손실선은 `손실 금액(dailyLossAmount)` 그대로 사용한다.
|
||||
- 퍼센트 입력값은 유지하고, 금액과 충돌 시 차단 대신 경고로 안내한다.
|
||||
|
||||
[영향 범위]
|
||||
- 수정:
|
||||
- lib/autotrade/risk.ts
|
||||
- features/autotrade/components/AutotradeControlPanel.tsx
|
||||
- common-docs/improvement/plans/dev-plan-2026-03-05-autotrade-risk-input-simplify.md
|
||||
- 추가:
|
||||
- 없음
|
||||
- 삭제:
|
||||
- 없음
|
||||
|
||||
[구현 단계]
|
||||
- [x] 1. 리스크 계산식 단순화: 실적용 투자금/손실한도를 입력 금액 그대로 쓰도록 변경했다. (`lib/autotrade/risk.ts`)
|
||||
- [x] 2. 퍼센트 해석 단순화: 퍼센트는 참고 기준 경고로만 반영했다. (`lib/autotrade/risk.ts`, `app/api/autotrade/strategies/validate/route.ts`)
|
||||
- [x] 3. UI 문구 정리: "중 작은 값" 설명을 제거하고 "입력값 직접 적용"으로 변경했다. (`features/autotrade/components/AutotradeControlPanel.tsx`)
|
||||
- [x] 4. 리스크 요약 카드 문구를 새 계산식에 맞게 정리했다. (`features/autotrade/components/AutotradeControlPanel.tsx`)
|
||||
|
||||
[사용할 MCP/Skills]
|
||||
- MCP: shell_command, apply_patch
|
||||
- Skills: dev-auto-pipeline, dev-plan-writer, dev-mcp-implementation, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker
|
||||
|
||||
[참조 문서(common-docs)]
|
||||
- common-docs/api-reference/openapi_all.xlsx
|
||||
- common-docs/api-reference/kis_api_reference.md
|
||||
- common-docs/api-reference/kis-error-code-reference.md
|
||||
- common-docs/features/trade-stock-sync.md
|
||||
- common-docs/ui/GLOBAL_ALERT_SYSTEM.md
|
||||
|
||||
[리스크/회귀 포인트]
|
||||
- 기존보다 공격적으로 주문이 나갈 수 있어(자동 축소 제거) 금액 입력 검증이 중요하다.
|
||||
- 퍼센트 필드가 무의미하게 보이지 않도록 경고 기준 문구를 명확히 해야 한다.
|
||||
|
||||
[검증 계획]
|
||||
- [x] 1. lint 통과 (`npm run lint` 통과)
|
||||
- [x] 2. build 통과 (`npm run build` 통과)
|
||||
- [x] 3. 코드 경로 확인: 주문 수량 계산에 쓰이는 `effectiveAllocationAmount`가 입력 금액 기준으로 세팅됨 확인 (`lib/autotrade/risk.ts` -> `features/autotrade/hooks/useAutotradeEngine.ts` -> `resolveOrderQuantity`)
|
||||
- [x] 4. UI 문구 확인: "작은 값" 문구 제거 확인 (`features/autotrade/components/AutotradeControlPanel.tsx`)
|
||||
|
||||
[진행 로그]
|
||||
- 2026-03-05: 계획 문서 작성.
|
||||
- 2026-03-05: 투자금/손실 계산 로직을 입력 금액 직접 적용 방식으로 단순화.
|
||||
- 2026-03-05: 퍼센트 필드를 참고 경고용으로 전환하고 검증 스키마를 nonnegative로 완화.
|
||||
- 2026-03-05: 자동매매 설정/리스크 요약 문구를 새 계산식 기준으로 업데이트.
|
||||
- 2026-03-05: `npm run lint`, `npm run build` 통과.
|
||||
|
||||
[계획 대비 완료체크]
|
||||
- 완료: 구현 1~4, 검증 1~4
|
||||
- 부분 완료: 없음
|
||||
- 미완료: 없음
|
||||
- 최종 판정: 배포 가능
|
||||
@@ -0,0 +1,69 @@
|
||||
[계획 문서 경로]
|
||||
- common-docs/improvement/plans/dev-plan-2026-03-05-kis-remember-credentials-checkbox.md
|
||||
|
||||
[요구사항 요약]
|
||||
- 설정 화면에서 앱토큰(앱키), 앱시크릿키, 계좌번호에 대해 "기억하기" 체크박스를 제공한다.
|
||||
- 체크한 항목만 브라우저 재시작 후에도 복원되도록 로컬 저장을 추가한다.
|
||||
- 기존 KIS 검증/계좌인증 동작은 그대로 유지한다.
|
||||
|
||||
[가정]
|
||||
- 사용자 요청의 "앱토큰"은 현재 화면 필드명 기준 "앱키(appKey)"로 해석한다.
|
||||
- "기억하기"는 장기 저장(localStorage), 미체크는 저장하지 않음으로 해석한다.
|
||||
- 기존 세션값이 있으면(이미 입력/검증된 상태) 기억값 자동 복원으로 덮어쓰지 않는다.
|
||||
|
||||
[영향 범위]
|
||||
- 수정:
|
||||
- features/settings/components/KisAuthForm.tsx
|
||||
- features/settings/components/KisProfileForm.tsx
|
||||
- features/layout/components/user-menu.tsx
|
||||
- features/auth/components/session-manager.tsx
|
||||
- 추가:
|
||||
- features/settings/lib/kis-remember-storage.ts
|
||||
- 삭제:
|
||||
- 없음
|
||||
|
||||
[구현 단계]
|
||||
- [x] 1. 기억하기 저장 유틸 추가: 앱키/앱시크릿/계좌별 체크 상태/값을 localStorage로 읽기/쓰기/삭제하는 공통 함수를 만들었다. (`features/settings/lib/kis-remember-storage.ts`)
|
||||
- [x] 2. 앱키/앱시크릿 체크박스 UI 추가: 인증 폼에 2개 체크박스를 추가하고, 체크 여부에 따라 자동 저장/삭제를 연결했다. (`features/settings/components/KisAuthForm.tsx`)
|
||||
- [x] 3. 계좌번호 체크박스 UI 추가: 계좌 인증 폼에 체크박스를 추가하고 동일한 저장/복원 흐름을 연결했다. (`features/settings/components/KisProfileForm.tsx`)
|
||||
- [x] 4. 로그아웃/세션만료 시 정리 연동: 기존 세션 정리 루틴에 기억값 키를 포함해 민감 정보가 남지 않게 했다. (`features/layout/components/user-menu.tsx`, `features/auth/components/session-manager.tsx`)
|
||||
|
||||
[사용할 MCP/Skills]
|
||||
- MCP: shell_command(코드 탐색/수정), apply_patch(파일 수정)
|
||||
- Skills: dev-auto-pipeline, dev-plan-writer, dev-mcp-implementation, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker, nextjs-app-router-patterns, vercel-react-best-practices
|
||||
|
||||
[참조 문서(common-docs)]
|
||||
- common-docs/api-reference/openapi_all.xlsx
|
||||
- common-docs/api-reference/kis_api_reference.md
|
||||
- common-docs/api-reference/kis-error-code-reference.md
|
||||
- common-docs/features/trade-stock-sync.md
|
||||
- common-docs/ui/GLOBAL_ALERT_SYSTEM.md
|
||||
|
||||
[주석/문서 반영 계획]
|
||||
- 상태 주석: 기억하기 체크 상태가 입력 필드 자동복원/저장에 미치는 영향을 한 줄 주석으로 추가한다.
|
||||
- 복잡 로직: "초기 복원"과 "변경 저장"을 [Step 1], [Step 2] 주석으로 분리한다.
|
||||
- JSX 구역 주석: 입력/체크박스 구역을 나눠 화면 구조를 더 쉽게 읽게 유지한다.
|
||||
|
||||
[리스크/회귀 포인트]
|
||||
- 체크박스 초기화 시 하이드레이션 타이밍 차이로 깜빡임이 생길 수 있다.
|
||||
- store 입력 setter 호출은 인증 상태를 리셋하므로, 복원 시 기존 세션값을 덮어쓰지 않도록 조건이 필요하다.
|
||||
- 민감값 장기 저장 정책 변경이므로 로그아웃 시 정리 누락이 없어야 한다.
|
||||
|
||||
[검증 계획]
|
||||
- [x] 1. lint: 타입/린트 오류 없이 통과했다. (`npm run lint` 통과)
|
||||
- [x] 2. build: Next.js 프로덕션 빌드가 통과했다. (`npm run build` 통과)
|
||||
- [x] 3. 동작: 체크박스/저장 로직을 코드 경로로 검증했다. (기억하기 on/off -> `setKisRememberEnabled` -> `setRememberedKisValue`)
|
||||
- [x] 4. 동작: 복원 로직을 코드 경로로 검증했다. (`hasHydrated` 이후 입력값 비어 있을 때만 `getRememberedKisValue` 복원)
|
||||
- [x] 5. 동작: 로그아웃/세션만료 시 기억값 정리 키 포함을 반영했다. (`SESSION_RELATED_STORAGE_KEYS`에 `KIS_REMEMBER_LOCAL_STORAGE_KEYS` 추가)
|
||||
|
||||
[진행 로그]
|
||||
- 2026-03-05: 계획 문서 작성.
|
||||
- 2026-03-05: 구현 1~4 완료 (기억하기 체크박스 + localStorage 유틸 + 세션 정리 키 반영).
|
||||
- 2026-03-05: `npm run lint`, `npm run build` 통과.
|
||||
- 2026-03-05: Playwright 스모크에서 `/settings` 접근 시 `/login` 리다이렉트 및 콘솔 치명 오류 없음 확인(인증 미보유로 설정 폼 직접 상호작용은 환경상 제한).
|
||||
|
||||
[계획 대비 완료체크]
|
||||
- 완료: 구현 1~4, 검증 1~5
|
||||
- 부분 완료: 없음
|
||||
- 미완료: 없음
|
||||
- 최종 판정: 배포 가능
|
||||
@@ -0,0 +1,71 @@
|
||||
# [계획 문서]
|
||||
- 경로: `common-docs/improvement/plans/dev-plan-2026-03-05-trade-chart-timeframes-and-history.md`
|
||||
|
||||
## [요구사항 요약]
|
||||
- 차트 표시/상호작용을 개선한다. (공식 문서 기준 반영)
|
||||
- 분봉 옵션에 5분/10분/15분을 추가한다.
|
||||
- 1시간봉 과거 데이터가 짧게 보이는 원인을 수정한다.
|
||||
|
||||
## [가정]
|
||||
- 기존 차트 라이브러리는 `lightweight-charts@5.1.0`을 유지한다.
|
||||
- KIS 분봉 API는 당일/일별 분봉 API를 조합해 과거 데이터를 이어 붙인다.
|
||||
- UI 레이아웃 전체 재설계보다 차트 영역 중심 개선을 우선한다.
|
||||
|
||||
## [영향 범위]
|
||||
- 수정: `features/trade/types/trade.types.ts`
|
||||
- 수정: `features/trade/components/chart/stock-line-chart-meta.ts`
|
||||
- 수정: `features/trade/components/chart/chart-utils.ts`
|
||||
- 수정: `features/trade/components/chart/StockLineChart.tsx`
|
||||
- 수정: `lib/kis/domestic-helpers.ts`
|
||||
- 수정: `app/api/kis/domestic/chart/route.ts`
|
||||
|
||||
## [구현 단계]
|
||||
- [x] 1. 차트/타임프레임 타입 확장 (`1m/5m/10m/15m/30m/1h/1d/1w`)
|
||||
- 근거: `features/trade/types/trade.types.ts`
|
||||
- [x] 2. 분봉 버킷 계산 로직 확장 (5/10/15분 지원)
|
||||
- 근거: `lib/kis/domestic-helpers.ts`, `features/trade/components/chart/chart-utils.ts`, `app/api/kis/domestic/chart/route.ts`
|
||||
- [x] 3. 차트 초기 과거 로드량을 시간프레임별로 확장해 1시간봉 과거 구간 부족 개선
|
||||
- 근거: `features/trade/components/chart/stock-line-chart-meta.ts`, `features/trade/components/chart/StockLineChart.tsx`
|
||||
- [x] 4. infinite history 로딩 트리거를 공식 문서 권장 패턴(`barsInLogicalRange`)으로 보강
|
||||
- 근거: `features/trade/components/chart/StockLineChart.tsx`
|
||||
- [x] 5. 차트 가시성 옵션(축 여백/우측 여백/가격선) 미세 개선
|
||||
- 근거: `features/trade/components/chart/StockLineChart.tsx` (`timeScale.rightOffset/barSpacing/minBarSpacing/rightBarStaysOnScroll`)
|
||||
|
||||
## [사용할 MCP/Skills]
|
||||
- MCP: `tavily-remote` (lightweight-charts 공식 문서 확인)
|
||||
- MCP: `mcp:kis-code-assistant-mcp` (KIS 분봉 API 파라미터/제약 확인)
|
||||
- Skills: `dev-auto-pipeline`, `vercel-react-best-practices`
|
||||
|
||||
## [참조 문서(common-docs)]
|
||||
- `common-docs/api-reference/openapi_all.xlsx`
|
||||
- `common-docs/api-reference/kis_api_reference.md`
|
||||
- `common-docs/api-reference/kis-error-code-reference.md`
|
||||
- `common-docs/features/trade-stock-sync.md`
|
||||
- `common-docs/ui/GLOBAL_ALERT_SYSTEM.md`
|
||||
|
||||
## [리스크/회귀 포인트]
|
||||
- 분봉 추가 후 기존 30분/1시간 정렬 경계가 깨질 수 있음
|
||||
- 과거 로드량 증가 시 초기 로딩 시간이 늘 수 있음
|
||||
- 무한 스크롤 조건 변경 시 중복 API 호출이 발생할 수 있음
|
||||
|
||||
## [검증 계획]
|
||||
- [x] 1. 타입/빌드 검증: `npm run lint`
|
||||
- 근거: 통과
|
||||
- [x] 2. 프로덕션 빌드 검증: `npm run build`
|
||||
- 근거: 통과
|
||||
- [x] 3. 수동 점검: 분봉 드롭다운(1/5/10/15/30/60분) 노출 확인
|
||||
- 근거: Playwriter 스냅샷에서 `1분/5분/10분/15분/30분/1시간` 버튼 노출 확인
|
||||
- [x] 4. 수동 점검: 1시간봉 진입 직후 과거 구간 확장 여부 확인
|
||||
- 근거: `/api/kis/domestic/chart?timeframe=1h` 초기 요청 19건 확인, 최소 시각 `2026-02-26 09:00:00(KST)`까지 로드
|
||||
- [x] 5. 수동 점검: 좌측 스크롤 시 과거 데이터 추가 로딩 유지 확인
|
||||
- 근거: 차트 드래그 후 `timeframe=1h` 추가 요청 5건 발생, 최소 시각 `2026-02-25 09:00:00(KST)`로 확장
|
||||
|
||||
## [진행 로그]
|
||||
- 2026-03-05: 계획 문서 생성.
|
||||
- 2026-03-05: `lightweight-charts` 공식 문서 확인 (`subscribeVisibleLogicalRangeChange`, `barsInLogicalRange`, infinite history 데모).
|
||||
- 2026-03-05: `kis-code-assistant-mcp`로 `inquire_time_itemchartprice`, `inquire_time_dailychartprice` 예제 확인 (당일/과거 분봉 API 호출 제약 확인).
|
||||
- 2026-03-05: 차트 타임프레임 확장(5/10/15분) + 과거 로드 로직 개선 + KIS 분봉 cursor 파싱 보강 적용.
|
||||
- 2026-03-05: `npm run lint`, `npm run build` 통과.
|
||||
- 2026-03-05: Playwriter 실브라우저 검증 수행(`/trade`), 분봉 메뉴/1시간봉 과거 로드/좌측 스크롤 추가 로드 확인.
|
||||
- 2026-03-05: 1시간봉 초기 과거 로드 상한 추가 상향(페이지 수 + 목표 봉 수 + 12초 예산), 재검증 시 최소 시각 `2026-02-05 09:00:00(KST)`까지 자동 로드 확인.
|
||||
- 2026-03-05: 창 확장 시 좌측 공백 보완 로직 추가(초기 fitContent 보강 + left whitespace 자동 추가 로드), 1920px 기준 재검증 시 최소 시각 `2026-01-30 13:00:00(KST)`까지 자동 로드 확인.
|
||||
@@ -0,0 +1,71 @@
|
||||
[계획 문서 경로]
|
||||
- common-docs/improvement/plans/dev-plan-2026-03-06-autotrade-ai-signal-context.md
|
||||
|
||||
[요구사항 요약]
|
||||
- 자동매매에서 AI에 넘기는 신호 생성 입력값이 신규 프롬프트 요구사항을 만족하는지 점검한다.
|
||||
- 부족한 데이터가 있으면 실제 신호 요청 payload에 추가한다.
|
||||
- 변경 후 검증 결과까지 남긴다.
|
||||
|
||||
[가정]
|
||||
- 신규 프롬프트의 핵심 요구는 `직전 강한 움직임 + 최근 1분봉 압축 구간` 판단이다.
|
||||
- 현재 전달 중인 최근 체결/호가 파생값만으로는 캔들 구조 판단이 부족하다.
|
||||
- 실시간 주문 루프는 유지하되, 추가 데이터는 기존 KIS 차트 API를 재사용해 보강한다.
|
||||
|
||||
[영향 범위]
|
||||
- 수정: features/autotrade/hooks/useAutotradeEngine.ts
|
||||
- 수정: features/autotrade/apis/autotrade.api.ts
|
||||
- 수정: features/autotrade/types/autotrade.types.ts
|
||||
- 수정: app/api/autotrade/signals/generate/route.ts
|
||||
- 수정: lib/autotrade/openai.ts
|
||||
- 수정: lib/autotrade/cli-provider.ts
|
||||
- 수정: common-docs/improvement/plans/dev-plan-2026-03-06-autotrade-ai-signal-context.md
|
||||
- 추가: 없음
|
||||
- 삭제: 없음
|
||||
|
||||
[구현 단계]
|
||||
- [x] 1. 현재 신호 생성 입력값과 신규 프롬프트 요구사항 차이를 정리한다.
|
||||
- 근거: 기존 signal payload에는 틱/호가/체결 파생값만 있고, 최근 1분봉 OHLCV와 원본 사용자 prompt가 빠져 있었음.
|
||||
- [x] 2. 최근 1분봉 OHLCV와 관련 파생값을 담을 타입/요청 스키마를 추가한다.
|
||||
- 근거: `features/autotrade/types/autotrade.types.ts`, `app/api/autotrade/signals/generate/route.ts`
|
||||
- [x] 3. 자동매매 훅에서 최근 1분봉 데이터를 조회/캐시하고 신호 요청 snapshot에 포함한다.
|
||||
- 근거: `features/autotrade/hooks/useAutotradeEngine.ts`
|
||||
- [x] 4. OpenAI/구독형 CLI 프롬프트가 새 입력값을 활용하도록 지시문을 보강한다.
|
||||
- 근거: `lib/autotrade/openai.ts`, `lib/autotrade/cli-provider.ts`
|
||||
- [x] 5. 로그 요약에 새 입력 데이터가 보이도록 정리한다.
|
||||
- 근거: `features/autotrade/hooks/useAutotradeEngine.ts`
|
||||
|
||||
[사용할 MCP/Skills]
|
||||
- MCP: next-devtools(init), update_plan
|
||||
- Skills: dev-auto-pipeline, dev-plan-writer, dev-mcp-implementation, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker, vercel-react-best-practices
|
||||
|
||||
[참조 문서(common-docs)]
|
||||
- common-docs/api-reference/kis_api_reference.md
|
||||
- common-docs/api-reference/kis-error-code-reference.md
|
||||
- common-docs/features/trade-stock-sync.md
|
||||
- common-docs/ui/GLOBAL_ALERT_SYSTEM.md
|
||||
|
||||
[주석/문서 반영 계획]
|
||||
- 함수 주석: [목적]/[사용처]/[데이터 흐름] 유지
|
||||
- 상태 주석: 값 변경 시 화면/주문 루프 영향 한 줄 설명
|
||||
- 복잡 로직/핸들러: [Step 1], [Step 2], [Step 3] 구조 유지
|
||||
- JSX 구역 주석: 기존 구조 유지, 필요 시 최소 보강
|
||||
|
||||
[리스크/회귀 포인트]
|
||||
- 1분봉 조회를 신호 루프마다 과도하게 호출하면 응답 지연이 늘 수 있다.
|
||||
- 차트 조회 실패 시 신호 생성 자체가 막히지 않도록 기존 snapshot fallback을 유지해야 한다.
|
||||
- 타입 확장 후 route/request schema가 불일치하면 신호 요청이 400으로 실패할 수 있다.
|
||||
|
||||
[검증 계획]
|
||||
- [x] 1. 타입/요청 스키마가 일치하는지 `npm run lint`로 확인한다.
|
||||
- 결과: 통과
|
||||
- [x] 2. OpenAI/CLI 프롬프트에 1분봉 데이터와 압축 구간 판단 지시가 반영됐는지 코드로 확인한다.
|
||||
- 결과: `operatorPrompt`, `recentMinuteCandles`, `minutePatternContext` 활용 지시 반영 완료
|
||||
- [x] 3. 신호 요청 snapshot 로그에 새 필드가 노출되는지 코드 기준으로 확인한다.
|
||||
- 결과: `snapshotSummary`, `snapshot` 로그에 minutePattern/recentMinuteCandlesTail 반영 완료
|
||||
|
||||
[진행 로그]
|
||||
- 2026-03-06: 기존 snapshot은 틱/호가/체결 파생값은 충분하지만, 1분봉 캔들 구조 데이터가 없어 신규 패턴 프롬프트 기준으로는 입력이 부족하다고 판단함.
|
||||
- 2026-03-06: 신호 요청에 원본 사용자 prompt를 추가해, 전략 요약으로 축약되던 세부 규칙이 신호 생성 단계에도 직접 전달되도록 수정함.
|
||||
- 2026-03-06: 최근 1분봉 OHLCV 24개와 minutePatternContext(직전 추세/압축 범위/압축 거래량비/박스 상하단)를 snapshot에 추가함.
|
||||
- 2026-03-06: `npm run lint`, `npm run build` 통과. `nextjs_call(get_errors)` 기준 3001 개발 서버에서 브라우저 세션 오류 없음 확인. 브라우저 자동화 스모크는 로컬 Chrome 프로필 충돌로 미실행.
|
||||
- 2026-03-06: BUY 신호인데 주문이 나가지 않는 원인을 추가 점검한 결과, `maxOrderAmountRatio`가 낮으면 전체 예산으로 1주를 살 수 있어도 주문 수량이 0주가 되는 문제가 확인됨. `lib/autotrade/risk.ts`에서 최소 1주 보정 로직을 추가하고 `npm run lint`, `npm run build` 재통과 확인.
|
||||
@@ -0,0 +1,126 @@
|
||||
[계획 문서 경로]
|
||||
- common-docs/improvement/plans/dev-plan-2026-03-06-autotrade-real-execution-budget-tax.md
|
||||
|
||||
[요구사항 요약]
|
||||
- 내 예산 기준으로 실제 몇 주를 살 수 있는지 계산하고, 자동매매 설정창에서 정한 비율대로 매수 수량이 정해지게 만든다.
|
||||
- 매도는 현재 보유/매도가능 수량과 비교해서 가능한 수량만 나가게 한다.
|
||||
- 수수료/세금/실현손익까지 고려해 진짜 자동매매처럼 동작하게 만든다.
|
||||
|
||||
[가정]
|
||||
- 자동매매 설정창의 `allocationPercent`는 "이번 종목/이번 주문에 실제로 쓸 비율"로 사용한다.
|
||||
- `allocationAmount`는 절대 상한(최대 투자금)으로 사용한다.
|
||||
- 수수료/세금은 계좌/환경/정책에 따라 달라질 수 있으므로, 구현 시 하드코딩보다 `설정값 + KIS 실제 체결/매매일지 값`을 함께 쓴다.
|
||||
- 국내주식 단주가 아닌 1주 단위 주문 기준으로 계획한다.
|
||||
|
||||
[영향 범위]
|
||||
- 수정: features/autotrade/hooks/useAutotradeEngine.ts
|
||||
- 수정: lib/autotrade/risk.ts
|
||||
- 수정: features/autotrade/types/autotrade.types.ts
|
||||
- 수정: features/autotrade/components/AutotradeControlPanel.tsx
|
||||
- 수정: app/api/autotrade/signals/generate/route.ts
|
||||
- 수정: lib/autotrade/openai.ts
|
||||
- 수정: lib/autotrade/cli-provider.ts
|
||||
- 수정: package.json
|
||||
- 추가: lib/autotrade/execution-cost.ts
|
||||
- 추가: lib/autotrade/executable-order-quantity.ts
|
||||
- 추가: tests/autotrade/risk-budget.test.ts
|
||||
- 추가: tests/autotrade/order-guard-cost.test.ts
|
||||
- 추가: common-docs/improvement/plans/dev-plan-2026-03-06-autotrade-real-execution-budget-tax.md
|
||||
- 삭제: 없음
|
||||
|
||||
[현재 코드 기준 핵심 문제]
|
||||
- `allocationPercent`가 실주문 계산 기준이 아니라 참고 경고 수준으로만 쓰이고 있다.
|
||||
- 쉬운 말: 설정창에서 10%, 25%를 바꿔도 실제 자동매매 수량 계산에는 약하게만 반영된다.
|
||||
- 매수 수량은 `effectiveAllocationAmount`와 `maxOrderAmountRatio` 중심이라, 내 예산/비율/호가/예상 비용을 함께 계산하는 구조가 아니다.
|
||||
- 매도는 `보유수량/매도가능수량` 차단은 있지만, 포지션 기준 목표 청산 비율, 부분 청산, 순손익 기준 청산 조건이 없다.
|
||||
- 세금/수수료는 대시보드 조회/표시에는 일부 있지만, 자동매매의 진입/청산/손실 한도 계산에는 거의 반영되지 않는다.
|
||||
- 일일 손실 한도는 입력 금액 기준이고, 실제 체결 후 순손익(수수료/세금 포함)과 연결되지 않는다.
|
||||
|
||||
[구현 단계]
|
||||
- [x] 1. 주문 가능 예산 모델 재정의
|
||||
- 입력: 가용 예수금, 매수가능금액, allocationPercent, allocationAmount, 전략별 maxOrderAmountRatio
|
||||
- 처리: `실주문가능예산 = min(매수가능금액, allocationAmount 상한, 예수금 * allocationPercent)` 구조로 통일
|
||||
- 결과: "현재 이 종목에 실제로 쓸 수 있는 예산" 1개 값으로 고정
|
||||
- [x] 2. 매수 수량 계산 로직 교체
|
||||
- 입력: 실주문가능예산, 현재가/주문가, 예상 수수료, 최소 안전여유금
|
||||
- 처리: 비용 포함 기준으로 최대 주문 가능 수량 계산
|
||||
- 결과: "내 예산 기준으로 지금 몇 주 살 수 있는지"를 로그와 UI에 함께 표시
|
||||
- [x] 3. 매도 수량 계산 로직을 포지션 기준으로 확장
|
||||
- 입력: 보유수량, 매도가능수량, 평균단가, 평가손익, AI 제안 수량/비율
|
||||
- 처리: 없는 주식은 절대 매도 금지, 보유보다 큰 수량 금지, 부분 매도 허용
|
||||
- 결과: "실제 보유 중인 수량 안에서만 매도" 보장
|
||||
- [x] 4. 수수료/세금 추정 모듈 추가
|
||||
- 입력: 주문금액, 매수/매도 구분, 계좌/환경 정책
|
||||
- 처리: 주문 전 예상 비용 계산, 주문 후 실제 체결/매매일지로 정산값 보정
|
||||
- 결과: 순손익 기준 판단 가능
|
||||
- [x] 5. 자동매매 위험 관리 기준을 순손익 기준으로 보강
|
||||
- 입력: 실현손익, 평가손익, 누적 수수료, 누적 세금
|
||||
- 처리: 일일 손실선/청산 조건을 총손익이 아니라 순손익 기준으로 갱신
|
||||
- 결과: 세금/수수료 때문에 실제 손실이 커지는 상황 반영
|
||||
- [x] 6. AI 입력값도 포지션/비용 기준으로 보강
|
||||
- 입력: holdingQuantity, sellableQuantity, averagePrice, estimatedFee, estimatedTax, netProfitEstimate
|
||||
- 처리: AI가 매도 시 "팔 수 있는지/팔면 순손익이 어떤지"를 함께 보게 함
|
||||
- 결과: 보유 없는 SELL, 손익 무시 SELL/BUY 감소
|
||||
- [x] 7. UI/로그 보강
|
||||
- 자동매매 설정창/로그에 아래 항목 노출
|
||||
- 현재 주문 가능 예산
|
||||
- 현재 매수 가능 수량
|
||||
- 현재 보유 수량 / 매도 가능 수량
|
||||
- 예상 수수료 / 예상 세금 / 예상 순손익
|
||||
- [x] 8. 체결 후 실제값 동기화
|
||||
- 주문 후 잔고/활동 API 재조회
|
||||
- 체결 후 보유수량, sellableQuantity, realized fee/tax, realized profit을 스토어에 반영
|
||||
- 다음 주문은 이 최신값을 기준으로 계산
|
||||
|
||||
[사용할 MCP/Skills]
|
||||
- MCP: next-devtools, sequential-thinking, mcp:kis-code-assistant-mcp
|
||||
- Skills: dev-plan-writer, dev-mcp-implementation, dev-refactor-polish, dev-test-gate, dev-plan-completion-checker, vercel-react-best-practices
|
||||
|
||||
[참조 문서(common-docs)]
|
||||
- common-docs/api-reference/kis_api_reference.md
|
||||
- common-docs/api-reference/kis-error-code-reference.md
|
||||
- common-docs/features/trade-stock-sync.md
|
||||
- common-docs/ui/GLOBAL_ALERT_SYSTEM.md
|
||||
|
||||
[주석/문서 반영 계획]
|
||||
- 함수 주석: [목적]/[사용처]/[데이터 흐름] 유지
|
||||
- 수량 계산/비용 계산 함수에는 입력 -> 처리 -> 결과 주석 추가
|
||||
- 자동매매 로그에는 "왜 주문됐는지/왜 차단됐는지" 숫자 기준 노출
|
||||
|
||||
[리스크/회귀 포인트]
|
||||
- 계좌별 수수료 정책이 다르면 세금/수수료 추정이 실제와 다를 수 있다.
|
||||
- 매수가능금액/잔고/매매일지 API 응답 타이밍이 어긋나면 체결 직후 수량이 잠깐 다르게 보일 수 있다.
|
||||
- 모의투자는 실전과 세금/수수료/매매일지 지원 방식이 다를 수 있다.
|
||||
- 주문 전 추정 비용과 주문 후 실제 비용이 다를 수 있으므로, 최종 손익 기준은 실제 체결/매매일지 값으로 재정산해야 한다.
|
||||
|
||||
[검증 계획]
|
||||
- [x] 1. `allocationPercent`, `allocationAmount`, `매수가능금액` 조합별로 매수 수량이 기대값대로 계산되는지 단위 테스트 추가
|
||||
- [x] 2. 보유 없음 / 보유 1주 / 매도가능수량 부족 상황에서 SELL이 차단되는지 테스트
|
||||
- [x] 3. 수수료/세금 추정 로직과 실제 activity API 정산값 연결 테스트
|
||||
- [x] 4. `npm run lint`
|
||||
- [x] 5. `npm run build`
|
||||
- [x] 6. 자동매매 스모크 시나리오
|
||||
- 예산 30만원, 비율 10%, 주가 16,000원일 때 매수 가능 수량 계산 확인
|
||||
- 보유 5주, 매도가능 3주일 때 SELL 수량 제한 확인
|
||||
- 체결 후 잔고/활동 재조회로 보유/손익이 갱신되는지 확인
|
||||
- Playwright 인증 필요 구간에서는 사용자(본인)가 로그인/앱키/계좌 인증을 완료할 때까지 테스트를 대기하고, 완료 신호를 받은 뒤 다음 단계를 진행
|
||||
|
||||
[진행 로그]
|
||||
- 2026-03-06: 현재 자동매매 코드를 점검한 결과, 매도가능수량 비교는 일부 구현되어 있으나 `allocationPercent` 실주문 반영, 세금/수수료 반영, 순손익 기준 손실 관리, 체결 후 정산 반영은 미흡한 상태로 판단함.
|
||||
- 2026-03-06: 구현 방향을 `예산 계산 -> 주문 수량 계산 -> 보유/매도가능 수량 검증 -> 비용 추정 -> 체결 후 실제 정산` 순서로 재설계하기로 함.
|
||||
- 2026-03-06: `lib/autotrade/risk.ts`에서 `allocationPercent`를 실주문 예산 계산에 강제 반영하도록 변경하고, BUY/SELL 수량 계산 경로를 분리함.
|
||||
- 2026-03-06: `useAutotradeEngine.ts`에 비용 추정(수수료/세금), 체결 전후 활동/잔고 재조회, 누적 손실 한도 자동중지 로직을 반영함.
|
||||
- 2026-03-06: AI 신호 스냅샷에 `budgetContext`, `portfolioContext`, `executionCostProfile`을 추가하고 OpenAI/CLI 프롬프트 규칙에 예산/보유/비용 제약을 반영함.
|
||||
- 2026-03-06: 검증 결과 `npm run lint`, `npm run build` 통과. `npm run test:autotrade:smoke`는 로그인 필요(개발 우회 토큰 미적용 환경)로 실패함.
|
||||
- 2026-03-06: Playwright 스모크로 `/`, `/trade`(로그인 리다이렉트 확인), `/settings`(로그인 리다이렉트 확인) 화면 로드 및 콘솔 error 없음 확인.
|
||||
- 2026-03-06: Playwright 테스트 협업 규칙 추가 - 로그인/앱키/계좌 인증은 사용자가 직접 완료하고, 완료 전에는 테스트를 대기하도록 문서에 명시함.
|
||||
- 2026-03-06: `lib/autotrade/executable-order-quantity.ts` 순수 clamp 유틸을 추가하고, `useAutotradeEngine.ts`의 실제 주문수량 검증에 연결함.
|
||||
- 2026-03-06: 단위 테스트 추가(`tests/autotrade/risk-budget.test.ts`, `tests/autotrade/order-guard-cost.test.ts`) 후 `npm run test:autotrade:unit` 통과.
|
||||
- 2026-03-06: `.env.local`의 실제 `AUTOTRADE_DEV_BYPASS_TOKEN`, `AUTOTRADE_WORKER_TOKEN`으로 스모크 재실행하여 `npm run test:autotrade:smoke` 통과.
|
||||
- 2026-03-06: Playwriter 실브라우저 디버깅으로 `/trade` 화면에서 `내 설정 점검 -> 자동매매 시작 -> 수동 중지` 흐름 확인(세션 시작/중지 로그 정상, 브라우저 콘솔 error 없음). 장중 실시간 틱 부재로 신호요청/주문실행 로그는 미발생.
|
||||
- 2026-03-06: AI 스냅샷의 `estimatedBuyableQuantity` 계산을 실제 주문 함수(`resolveOrderQuantity`)와 동일하게 통일해, 비율 예산으로 0주가 나와도 전체 예산 1주 가능 시 `1주`가 전달되도록 핫픽스함.
|
||||
- 2026-03-06: Playwriter 네트워크 검증으로 `/api/autotrade/signals/generate` 요청 본문에 `estimatedBuyableQuantity=1`, `effectiveAllocationAmount=21631`, `effectiveOrderBudgetAmount=7570`, `currentPrice=16790`이 전달되는 것을 확인함(수량 0 전달 이슈 해소).
|
||||
- 2026-03-06: 검증 자금 산정 로직을 `예수금 + 매수가능금액` 동시 조회 기반으로 변경하고, 두 값이 모두 있을 때는 더 보수적인 값(min)을 사용하도록 반영함.
|
||||
- 2026-03-06: 자동매매 설정창의 투자비율 입력 UX를 퍼센트 프리셋 버튼 + 슬라이더 + 금액 자동입력 버튼으로 개선하고, 안전 점검 라벨을 `가용 예수금`에서 `주문 기준 자금`으로 변경함.
|
||||
- 2026-03-06: `setNumberField`를 필드별 범위(clamp) 보정 방식으로 바꿔 퍼센트/신뢰도 입력이 비정상 값(음수, 100% 초과, 임계값 범위 이탈)으로 저장되지 않도록 정리함.
|
||||
- 2026-03-06: 회귀 검증으로 `npm run test:autotrade:unit`, `npm run lint`, `npm run build` 재실행 모두 통과함.
|
||||
146
common-docs/ui/GLOBAL_ALERT_SYSTEM.md
Normal file
146
common-docs/ui/GLOBAL_ALERT_SYSTEM.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Global Alert System 사용 가이드
|
||||
|
||||
이 문서는 애플리케이션 전역에서 사용 가능한 `Global Alert System`의 설계 목적, 설치 방법, 그리고 사용법을 설명합니다.
|
||||
|
||||
## 1. 개요 (Overview)
|
||||
|
||||
Global Alert System은 Zustand 상태 관리 라이브러리와 Shadcn/ui의 `AlertDialog` 컴포넌트를 결합하여 만든 전역 알림 시스템입니다.
|
||||
복잡한 모달 로직을 매번 구현할 필요 없이, `useGlobalAlert` 훅 하나로 어디서든 일관된 UI의 알림 및 확인 창을 호출할 수 있습니다.
|
||||
|
||||
### 주요 특징
|
||||
|
||||
- **전역 상태 관리**: `useGlobalAlertStore`를 통해 모달의 상태를 중앙에서 관리합니다.
|
||||
- **간편한 Hook**: `useGlobalAlert` 훅을 통해 직관적인 API (`alert.success`, `alert.confirm` 등)를 제공합니다.
|
||||
- **다양한 타입 지원**: Success, Error, Warning, Info 등 상황에 맞는 스타일과 아이콘을 자동으로 적용합니다.
|
||||
- **비동기 지원**: 확인/취소 버튼 클릭 시 콜백 함수를 실행할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 설치 및 설정 (Setup)
|
||||
|
||||
이미 `app/layout.tsx`에 설정되어 있으므로, 개발자는 별도의 설정 없이 바로 사용할 수 있습니다.
|
||||
|
||||
### 파일 구조
|
||||
|
||||
```
|
||||
features/layout/
|
||||
├── components/
|
||||
│ └── GlobalAlertModal.tsx # 실제 렌더링되는 모달 컴포넌트
|
||||
├── hooks/
|
||||
│ └── use-global-alert.ts # 개발자가 사용하는 Custom Hook
|
||||
└── stores/
|
||||
└── use-global-alert-store.ts # Zustand Store
|
||||
```
|
||||
|
||||
### Layout 통합
|
||||
|
||||
`app/layout.tsx`에 `GlobalAlertModal`이 이미 등록되어 있습니다.
|
||||
|
||||
```tsx
|
||||
// app/layout.tsx
|
||||
import { GlobalAlertModal } from "@/features/layout/components/GlobalAlertModal";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<GlobalAlertModal /> {/* 전역 모달 등록 */}
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 사용법 (Usage)
|
||||
|
||||
### Hook 가져오기
|
||||
|
||||
```tsx
|
||||
import { useGlobalAlert } from "@/features/layout/hooks/use-global-alert";
|
||||
|
||||
const { alert } = useGlobalAlert();
|
||||
```
|
||||
|
||||
### 기본 알림 (Alert)
|
||||
|
||||
사용자에게 단순히 정보를 전달하고 확인 버튼만 있는 알림입니다.
|
||||
|
||||
```tsx
|
||||
// 1. 성공 알림
|
||||
alert.success("저장이 완료되었습니다.");
|
||||
|
||||
// 2. 에러 알림
|
||||
alert.error("데이터 불러오기에 실패했습니다.");
|
||||
|
||||
// 3. 경고 알림
|
||||
alert.warning("입력 값이 올바르지 않습니다.");
|
||||
|
||||
// 4. 정보 알림
|
||||
alert.info("새로운 버전이 업데이트되었습니다.");
|
||||
```
|
||||
|
||||
옵션을 추가하여 제목이나 버튼 텍스트를 변경할 수 있습니다.
|
||||
|
||||
```tsx
|
||||
alert.success("저장 완료", {
|
||||
title: "성공", // 기본값: 타입에 따른 제목 (예: "성공", "오류")
|
||||
confirmLabel: "닫기", // 기본값: "확인"
|
||||
});
|
||||
```
|
||||
|
||||
### 확인 대화상자 (Confirm)
|
||||
|
||||
사용자의 선택(확인/취소)을 요구하는 대화상자입니다.
|
||||
|
||||
```tsx
|
||||
alert.confirm("정말로 삭제하시겠습니까?", {
|
||||
type: "warning", // 기본값: warning (아이콘과 색상 변경됨)
|
||||
confirmLabel: "삭제",
|
||||
cancelLabel: "취소",
|
||||
onConfirm: () => {
|
||||
console.log("삭제 버튼 클릭됨");
|
||||
// 여기에 삭제 로직 추가
|
||||
},
|
||||
onCancel: () => {
|
||||
console.log("취소 버튼 클릭됨");
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API Reference
|
||||
|
||||
### `useGlobalAlert()`
|
||||
|
||||
Hook은 `alert` 객체와 `close` 함수를 반환합니다.
|
||||
|
||||
#### `alert` Methods
|
||||
|
||||
| 메서드 | 설명 | 파라미터 |
|
||||
| --------- | ----------------------- | ---------------------------------------------- |
|
||||
| `success` | 성공 알림 표시 | `(message: ReactNode, options?: AlertOptions)` |
|
||||
| `error` | 오류 알림 표시 | `(message: ReactNode, options?: AlertOptions)` |
|
||||
| `warning` | 경고 알림 표시 | `(message: ReactNode, options?: AlertOptions)` |
|
||||
| `info` | 정보 알림 표시 | `(message: ReactNode, options?: AlertOptions)` |
|
||||
| `confirm` | 확인/취소 대화상자 표시 | `(message: ReactNode, options?: AlertOptions)` |
|
||||
|
||||
#### `AlertOptions` Interface
|
||||
|
||||
```typescript
|
||||
interface AlertOptions {
|
||||
title?: ReactNode; // 모달 제목 (생략 시 타입에 맞는 기본 제목)
|
||||
confirmLabel?: string; // 확인 버튼 텍스트 (기본: "확인")
|
||||
cancelLabel?: string; // 취소 버튼 텍스트 (Confirm 모드에서 기본: "취소")
|
||||
onConfirm?: () => void; // 확인 버튼 클릭 시 실행될 콜백
|
||||
onCancel?: () => void; // 취소 버튼 클릭 시 실행될 콜백
|
||||
type?: AlertType; // 알림 타입 ("success" | "error" | "warning" | "info")
|
||||
}
|
||||
```
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* @file components/theme-toggle.tsx
|
||||
* @description 라이트/다크/시스템 테마 전환 토글 버튼
|
||||
* @description 라이트/다크 테마 즉시 전환 토글 버튼
|
||||
* @remarks
|
||||
* - [레이어] Components/UI
|
||||
* - [사용자 행동] 버튼 클릭 -> 드롭다운 메뉴 -> 테마 선택 -> 즉시 반영
|
||||
* - [사용자 행동] 버튼 클릭 -> 라이트/다크 즉시 전환
|
||||
* - [연관 파일] theme-provider.tsx (next-themes)
|
||||
*/
|
||||
|
||||
@@ -15,12 +15,6 @@ import { useTheme } from "next-themes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface ThemeToggleProps {
|
||||
className?: string;
|
||||
@@ -30,24 +24,34 @@ interface ThemeToggleProps {
|
||||
/**
|
||||
* 테마 토글 컴포넌트
|
||||
* @remarks next-themes의 useTheme 훅 사용
|
||||
* @returns Dropdown 메뉴 형태의 테마 선택기
|
||||
* @returns 단일 클릭으로 라이트/다크를 전환하는 버튼
|
||||
* @see features/layout/components/header.tsx Header 액션 영역 - 사용자 테마 전환 버튼
|
||||
*/
|
||||
export function ThemeToggle({ className, iconClassName }: ThemeToggleProps) {
|
||||
const { setTheme } = useTheme();
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
|
||||
const handleToggleTheme = React.useCallback(() => {
|
||||
// 시스템 테마 사용 중에도 현재 화면 기준으로 명확히 라이트/다크를 토글합니다.
|
||||
setTheme(resolvedTheme === "dark" ? "light" : "dark");
|
||||
}, [resolvedTheme, setTheme]);
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
{/* ========== 트리거 버튼 ========== */}
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className={className}>
|
||||
{/* 라이트 모드 아이콘 (회전 애니메이션) */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={className}
|
||||
onClick={handleToggleTheme}
|
||||
aria-label="테마 전환"
|
||||
>
|
||||
{/* ========== LIGHT ICON ========== */}
|
||||
<Sun
|
||||
className={cn(
|
||||
"h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0",
|
||||
iconClassName,
|
||||
)}
|
||||
/>
|
||||
{/* 다크 모드 아이콘 (회전 애니메이션) */}
|
||||
{/* ========== DARK ICON ========== */}
|
||||
<Moon
|
||||
className={cn(
|
||||
"absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100",
|
||||
@@ -56,20 +60,5 @@ export function ThemeToggle({ className, iconClassName }: ThemeToggleProps) {
|
||||
/>
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
{/* ========== 메뉴 컨텐츠 (우측 정렬) ========== */}
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
152
components/ui/animated-brand-tone.tsx
Normal file
152
components/ui/animated-brand-tone.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TONE_PHRASES = [
|
||||
{ q: "주식이 너무 어려워요...", a: "걱정하지 마. JOORIN-E가 다 해줄게." },
|
||||
{
|
||||
q: "내 돈, 정말 안전할까?",
|
||||
a: "안심해도 돼. 금융권 수준 보안키로 지키니까.",
|
||||
},
|
||||
{
|
||||
q: "손실 날까 봐 불안해요...",
|
||||
a: "걱정하지 마. 안전 장치가 24시간 작동해.",
|
||||
},
|
||||
{
|
||||
q: "복잡한 건 딱 질색인데..",
|
||||
a: "몰라도 돼. 클릭 몇 번이면 바로 시작이야.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @description 프리미엄한 텍스트 리빌 효과를 제공하는 브랜드 톤 컴포넌트
|
||||
*/
|
||||
export function AnimatedBrandTone() {
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setIndex((prev) => (prev + 1) % TONE_PHRASES.length);
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const answerText = TONE_PHRASES[index].a;
|
||||
const answerChars = answerText.split("");
|
||||
const answerLength = answerChars.length;
|
||||
const answerFontSize = resolveAnswerFontSize(answerLength);
|
||||
const answerTracking = resolveAnswerTracking(answerLength);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[300px] flex-col items-center justify-center py-10 text-center md:min-h-[400px]">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
className="flex w-full flex-col items-center"
|
||||
>
|
||||
{/* 질문 (Q) */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-sm font-medium text-brand-300/60 md:text-lg"
|
||||
>
|
||||
“{TONE_PHRASES[index].q}”
|
||||
</motion.p>
|
||||
|
||||
{/* 답변 (A) - 타이핑 효과 */}
|
||||
<div className="mt-8 flex w-full flex-col items-center gap-2 px-2 sm:px-4">
|
||||
<h2
|
||||
className="w-full font-bold text-white drop-shadow-[0_12px_30px_rgba(0,0,0,0.38)]"
|
||||
style={{ fontSize: answerFontSize }}
|
||||
>
|
||||
<div
|
||||
className="inline-flex max-w-full items-center whitespace-nowrap leading-[1.12]"
|
||||
style={{ letterSpacing: answerTracking }}
|
||||
>
|
||||
{answerChars.map((char, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{
|
||||
duration: 0,
|
||||
delay: 0.45 + i * 0.055,
|
||||
}}
|
||||
className={cn(
|
||||
"inline-block align-baseline",
|
||||
i < 5 ? "text-brand-300" : "text-white",
|
||||
)}
|
||||
>
|
||||
{char === " " ? "\u00A0" : char}
|
||||
</motion.span>
|
||||
))}
|
||||
{/* 깜빡이는 커서 */}
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: [0, 1, 0] }}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
}}
|
||||
className="ml-2 inline-block h-[0.78em] w-1.5 rounded-xs bg-brand-300 align-middle shadow-[0_0_14px_rgba(167,139,250,0.55)]"
|
||||
/>
|
||||
</div>
|
||||
</h2>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 인디케이터 - 유휴 상태 표시 및 선택 기능 */}
|
||||
<div className="mt-16 flex gap-3">
|
||||
{TONE_PHRASES.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setIndex(i)}
|
||||
className={cn(
|
||||
"h-1.5 transition-all duration-500 rounded-full",
|
||||
i === index
|
||||
? "w-10 bg-brand-500 shadow-[0_0_20px_rgba(20,184,166,0.3)]"
|
||||
: "w-2 bg-white/10 hover:bg-white/20",
|
||||
)}
|
||||
aria-label={`Go to slide ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAnswerFontSize(answerLength: number) {
|
||||
if (answerLength >= 30) {
|
||||
return "clamp(1rem,2.4vw,2.2rem)";
|
||||
}
|
||||
if (answerLength >= 25) {
|
||||
return "clamp(1.15rem,2.9vw,2.9rem)";
|
||||
}
|
||||
if (answerLength >= 20) {
|
||||
return "clamp(1.3rem,3.4vw,3.8rem)";
|
||||
}
|
||||
return "clamp(1.45rem,4vw,4.8rem)";
|
||||
}
|
||||
|
||||
function resolveAnswerTracking(answerLength: number) {
|
||||
if (answerLength >= 30) {
|
||||
return "-0.008em";
|
||||
}
|
||||
if (answerLength >= 25) {
|
||||
return "-0.012em";
|
||||
}
|
||||
if (answerLength >= 20) {
|
||||
return "-0.016em";
|
||||
}
|
||||
return "-0.018em";
|
||||
}
|
||||
32
doc-rule.md
32
doc-rule.md
@@ -1,32 +0,0 @@
|
||||
# Antigravity Rules
|
||||
|
||||
This document defines the coding and behavior rules for the Antigravity agent.
|
||||
|
||||
## General Rules
|
||||
|
||||
- **Language**: All output, explanations, and commit messages MUST be in **Korean (한국어)**.
|
||||
- **Tone**: Professional, helpful, and concise.
|
||||
|
||||
## Documentation Rules
|
||||
|
||||
### JSX Comments
|
||||
|
||||
- Mandatory use of section comments in JSX to delineate logical blocks.
|
||||
- Format: `{/* ========== SECTION NAME ========== */}`
|
||||
|
||||
### JSDoc Tags
|
||||
|
||||
- **@see**: Mandatory for function/component documentation. Must include calling file, function/event name, and purpose.
|
||||
- **@author**: Mandatory file-level tag. Use `@author jihoon87.lee`.
|
||||
|
||||
### Inline Comments
|
||||
|
||||
- High density of inline comments required for:
|
||||
- State definitions
|
||||
- Event handlers
|
||||
- Complex logic in JSX
|
||||
- Balance conciseness with clarity.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Follow Project-specific linting and formatting rules.
|
||||
@@ -26,7 +26,6 @@ import { InlineSpinner } from "@/components/ui/loading-spinner";
|
||||
*/
|
||||
export default function LoginForm() {
|
||||
// ========== 상태 관리 ==========
|
||||
// 서버와 클라이언트 초기 렌더링을 일치시키기 위해 초기값은 고정
|
||||
const [email, setEmail] = useState(() => {
|
||||
if (typeof window === "undefined") return "";
|
||||
return localStorage.getItem("auto-trade-saved-email") || "";
|
||||
@@ -37,11 +36,6 @@ export default function LoginForm() {
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// ========== 마운트 시 localStorage 데이터 복구 ==========
|
||||
// localStorage는 클라이언트 전용 외부 시스템이므로 useEffect에서 동기화하는 것이 올바른 패턴
|
||||
// React 공식 문서: "외부 시스템과 동기화"는 useEffect의 정확한 사용 사례
|
||||
// useState lazy initializer + window guard handles localStorage safely
|
||||
|
||||
// ========== 폼 제출 핸들러 ==========
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
@@ -83,7 +77,7 @@ export default function LoginForm() {
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="h-11 transition-all duration-200"
|
||||
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -102,7 +96,7 @@ export default function LoginForm() {
|
||||
minLength={8}
|
||||
pattern="^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])(?=.*[!@#$%^&*]).{8,}$"
|
||||
title="비밀번호는 최소 8자 이상, 대문자, 소문자, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다."
|
||||
className="h-11 transition-all duration-200"
|
||||
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -121,10 +115,9 @@ export default function LoginForm() {
|
||||
이메일 기억하기
|
||||
</Label>
|
||||
</div>
|
||||
{/* 비밀번호 찾기 링크 */}
|
||||
<Link
|
||||
href={AUTH_ROUTES.FORGOT_PASSWORD}
|
||||
className="text-sm font-medium text-gray-700 hover:text-black dark:text-gray-300 dark:hover:text-white"
|
||||
className="text-sm font-medium text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||
>
|
||||
비밀번호 찾기
|
||||
</Link>
|
||||
@@ -134,7 +127,7 @@ export default function LoginForm() {
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="h-11 w-full bg-linear-to-r from-gray-900 to-black font-semibold shadow-lg transition-all duration-200 hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white"
|
||||
className="h-11 w-full bg-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all duration-200 hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
|
||||
size="lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
@@ -148,11 +141,11 @@ export default function LoginForm() {
|
||||
</Button>
|
||||
|
||||
{/* ========== 회원가입 링크 ========== */}
|
||||
<p className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
계정이 없으신가요?{" "}
|
||||
<Link
|
||||
href={AUTH_ROUTES.SIGNUP}
|
||||
className="font-semibold text-gray-900 transition-colors hover:text-black dark:text-gray-100 dark:hover:text-white"
|
||||
className="font-semibold text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||
>
|
||||
회원가입 하기
|
||||
</Link>
|
||||
@@ -162,7 +155,7 @@ export default function LoginForm() {
|
||||
{/* ========== 소셜 로그인 구분선 ========== */}
|
||||
<div className="relative">
|
||||
<Separator className="my-6" />
|
||||
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white px-3 text-xs font-medium text-gray-500 dark:bg-gray-900 dark:text-gray-400">
|
||||
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white px-3 text-xs font-medium text-muted-foreground dark:bg-brand-950">
|
||||
또는 소셜 로그인
|
||||
</span>
|
||||
</div>
|
||||
@@ -174,7 +167,7 @@ export default function LoginForm() {
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="h-11 w-full border-gray-200 bg-white shadow-sm transition-all duration-200 hover:bg-gray-50 hover:shadow-md dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-750"
|
||||
className="h-11 w-full border-brand-200/50 bg-white shadow-sm transition-all duration-200 hover:bg-brand-50 hover:shadow-md dark:border-brand-800/50 dark:bg-brand-950/50 dark:hover:bg-brand-900/50"
|
||||
>
|
||||
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24">
|
||||
<path
|
||||
|
||||
@@ -79,9 +79,9 @@ export default function ResetPasswordForm() {
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
{...register("password")}
|
||||
className="h-11 transition-all duration-200"
|
||||
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
8자 이상, 대문자/소문자/숫자/특수문자를 각 1개 이상 포함해야 합니다.
|
||||
</p>
|
||||
{errors.password && (
|
||||
@@ -102,7 +102,7 @@ export default function ResetPasswordForm() {
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
{...register("confirmPassword")}
|
||||
className="h-11 transition-all duration-200"
|
||||
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
|
||||
/>
|
||||
{confirmPassword &&
|
||||
password !== confirmPassword &&
|
||||
@@ -114,7 +114,7 @@ export default function ResetPasswordForm() {
|
||||
{confirmPassword &&
|
||||
password === confirmPassword &&
|
||||
!errors.confirmPassword && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400">
|
||||
<p className="text-xs text-brand-600 dark:text-brand-400">
|
||||
비밀번호가 일치합니다.
|
||||
</p>
|
||||
)}
|
||||
@@ -128,7 +128,7 @@ export default function ResetPasswordForm() {
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="h-11 w-full bg-linear-to-r from-gray-900 to-black font-semibold text-white shadow-lg transition-all hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white"
|
||||
className="h-11 w-full bg-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
|
||||
@@ -24,11 +24,19 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
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
|
||||
|
||||
// 설정: 경고 표시 시간 (타임아웃 1분 전) - 현재 미사용 (즉시 로그아웃)
|
||||
// const WARNING_MS = 60 * 1000;
|
||||
|
||||
const SESSION_RELATED_STORAGE_KEYS = [
|
||||
"session-storage",
|
||||
"auth-storage",
|
||||
"autotrade-kis-runtime-store",
|
||||
...KIS_REMEMBER_LOCAL_STORAGE_KEYS,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 세션 관리자 컴포넌트
|
||||
* 사용자 활동을 감지하여 세션 연장 및 타임아웃 처리
|
||||
@@ -51,6 +59,19 @@ export function SessionManager() {
|
||||
|
||||
const { setLastActive } = useSessionStore();
|
||||
|
||||
/**
|
||||
* @description 세션 만료 로그아웃 시 세션 관련 로컬 스토리지를 정리합니다.
|
||||
* @see features/layout/components/user-menu.tsx 수동 로그아웃 경로에서도 동일한 키를 제거합니다.
|
||||
*/
|
||||
const clearSessionRelatedStorage = useCallback(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
for (const key of SESSION_RELATED_STORAGE_KEYS) {
|
||||
window.localStorage.removeItem(key);
|
||||
window.sessionStorage.removeItem(key);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 로그아웃 처리 핸들러
|
||||
* @see session-timer.tsx - 타임아웃 시 동일한 로직 필요 가능성 있음
|
||||
@@ -64,11 +85,12 @@ export function SessionManager() {
|
||||
|
||||
// [Step 3] 로컬 스토어 및 세션 정보 초기화
|
||||
useSessionStore.persist.clearStorage();
|
||||
clearSessionRelatedStorage();
|
||||
|
||||
// [Step 4] 로그인 페이지로 리다이렉트 및 메시지 표시
|
||||
router.push("/login?message=세션이 만료되었습니다. 다시 로그인해주세요.");
|
||||
router.refresh();
|
||||
}, [router]);
|
||||
}, [clearSessionRelatedStorage, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthPage) return;
|
||||
@@ -79,6 +101,10 @@ export function SessionManager() {
|
||||
if (showWarning) setShowWarning(false);
|
||||
};
|
||||
|
||||
// [Step 0] 인증 페이지에서 메인 페이지로 진입한 직후를 "활동"으로 간주해
|
||||
// 이전 세션 잔여 시간(예: 00:00)으로 즉시 로그아웃되는 현상을 방지합니다.
|
||||
updateLastActive();
|
||||
|
||||
// [Step 1] 사용자 활동 이벤트 감지 (마우스, 키보드, 스크롤, 터치)
|
||||
const events = ["mousedown", "keydown", "scroll", "touchstart"];
|
||||
const handleActivity = () => updateLastActive();
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function SignupForm() {
|
||||
autoComplete="email"
|
||||
disabled={isLoading}
|
||||
{...register("email")}
|
||||
className="h-11 transition-all duration-200"
|
||||
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400">
|
||||
@@ -105,9 +105,9 @@ export default function SignupForm() {
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
{...register("password")}
|
||||
className="h-11 transition-all duration-200"
|
||||
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
최소 8자 이상, 대문자, 소문자, 숫자, 특수문자 포함
|
||||
</p>
|
||||
{errors.password && (
|
||||
@@ -129,7 +129,7 @@ export default function SignupForm() {
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
{...register("confirmPassword")}
|
||||
className="h-11 transition-all duration-200"
|
||||
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
|
||||
/>
|
||||
{/* 비밀번호 불일치 시 실시간 피드백 */}
|
||||
{confirmPassword &&
|
||||
@@ -143,7 +143,7 @@ export default function SignupForm() {
|
||||
{confirmPassword &&
|
||||
password === confirmPassword &&
|
||||
!errors.confirmPassword && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400">
|
||||
<p className="text-xs text-brand-600 dark:text-brand-400">
|
||||
비밀번호가 일치합니다 ✓
|
||||
</p>
|
||||
)}
|
||||
@@ -159,7 +159,7 @@ export default function SignupForm() {
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="h-11 w-full bg-gradient-to-r from-gray-900 to-black font-semibold shadow-lg transition-all duration-200 hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white"
|
||||
className="h-11 w-full bg-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all duration-200 hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user