스킬 정리 및 리팩토링
This commit is contained in:
@@ -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
|
|
||||||
57
.agents/skills/dev-auto-pipeline/SKILL.md
Normal file
57
.agents/skills/dev-auto-pipeline/SKILL.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
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
.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
|
||||||
145
.agents/skills/dev-refactor-polish/SKILL.md
Normal file
145
.agents/skills/dev-refactor-polish/SKILL.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
---
|
||||||
|
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`, store)는 "화면에 어떤 영향을 주는지" 한 줄 주석을 단다.
|
||||||
|
4. 복잡한 로직/핸들러는 `1.`, `2.`, `3.` 단계 주석으로 흐름을 나눈다.
|
||||||
|
5. 긴 JSX는 화면 구역 주석으로 나눠서 읽기 쉽게 만든다.
|
||||||
|
- 예: `{/* ===== 1. 상단: 페이지 제목 및 액션 버튼 ===== */}`
|
||||||
|
6. `@param`, `@see`, `@remarks` 같은 딱딱한 TSDoc 태그는 강제하지 않는다.
|
||||||
|
7. 결과 기준은 "주니어가 5분 내 파악 가능한지"로 잡는다.
|
||||||
|
|
||||||
|
## 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
|
||||||
74
AGENTS.md
74
AGENTS.md
@@ -1,67 +1,17 @@
|
|||||||
# AGENTS.md (auto-trade)
|
# AGENTS.md (auto-trade)
|
||||||
|
|
||||||
## 기본 원칙
|
## 운영 원칙
|
||||||
|
|
||||||
- 모든 응답과 설명은 한국어로 작성.
|
- 중복 규칙은 `AGENTS.md`에 두지 않고 각 스킬(`.agents/skills/*/SKILL.md`)에서 관리한다.
|
||||||
- 쉬운 말로 설명하고, 어려운 용어는 괄호로 짧게 뜻을 덧붙임.
|
- 개발 작업은 스킬 기반으로 수행한다.
|
||||||
- 요청이 모호하면 먼저 질문 1~3개로 범위를 확인.
|
|
||||||
- 소스 수정시 사이드이팩트가 발생하지 않게 사용자에게 묻거나 최대한 영향이 가지 않게 수정한다. 진짜 필요없는건 삭제하고 영향이 갈 내용이면 이전 소스가 영향이 없는지 검증하고 수정한다.
|
|
||||||
|
|
||||||
## 프로젝트 요약
|
## 스킬 호출 규칙
|
||||||
|
|
||||||
- Next.js 16 App Router, React 19, TypeScript
|
- 개발 요청 키워드(`개발해줘`, `구현해줘`, `기능 추가`, `버그 수정`, `리팩토링`, `개선해줘`, `고쳐줘`, `최적화해줘`, `만들어줘`)가 들어오면 `dev-auto-pipeline` 스킬을 우선 사용한다.
|
||||||
- 상태 관리: zustand
|
- 파이프라인 단계 스킬은 아래 순서로 사용한다.
|
||||||
- 데이터: Supabase
|
1. `dev-plan-writer`
|
||||||
- 폼 및 검증: react-hook-form, zod
|
2. `dev-mcp-implementation`
|
||||||
- UI: Tailwind CSS v4, Radix UI (`components.json` 사용)
|
3. `dev-refactor-polish`
|
||||||
|
4. `dev-test-gate`
|
||||||
## 명령어
|
5. `dev-plan-completion-checker`
|
||||||
|
- 단순 설명/문서 요약/잡담 요청에는 파이프라인 스킬을 강제하지 않는다.
|
||||||
- 개발 서버(포트 3001): `npm run dev`
|
|
||||||
- 린트: `npm run lint`
|
|
||||||
- 빌드: `npm run build`
|
|
||||||
- 실행: `npm run start`
|
|
||||||
|
|
||||||
## 코드 및 문서 규칙
|
|
||||||
|
|
||||||
- JSX 섹션 주석 형식: `{/* ========== SECTION NAME ========== */}`
|
|
||||||
- 함수 및 컴포넌트 JSDoc에 `@see` 필수
|
|
||||||
- `@see`에는 호출 파일, 함수/이벤트 이름, 목적을 함께 작성
|
|
||||||
- 상태 정의, 이벤트 핸들러, 복잡한 JSX 로직에는 인라인 주석을 충분히 작성
|
|
||||||
- UI 흐름 설명 필수: `어느 UI -> A 함수 호출 -> B 함수 호출 -> 리턴값 반영` 형태로 작성
|
|
||||||
|
|
||||||
## 브랜드 색상 규칙
|
|
||||||
|
|
||||||
- 메인 컬러는 헤더 로고/프로필 기준의 보라 계열 `brand` 팔레트 사용
|
|
||||||
- 새 UI 작성 시 `indigo/purple/pink` 하드코딩 대신 `brand-*` 토큰 사용
|
|
||||||
- 예시: `bg-brand-500`, `text-brand-600`, `from-brand-500 to-brand-700`
|
|
||||||
- 기본 액션 색(버튼/포커스)은 `primary` 사용
|
|
||||||
- `primary`는 `app/globals.css`의 `brand` 팔레트와 같은 톤으로 유지
|
|
||||||
- 색상 변경이 필요하면 컴포넌트 개별 수정보다 먼저 `app/globals.css` 토큰 수정
|
|
||||||
|
|
||||||
## 개발 도구 활용
|
|
||||||
|
|
||||||
- **Skills**: 프로젝트에 적합한 스킬을 적극 활용하여 베스트 프랙티스 적용
|
|
||||||
- **MCP 서버**:
|
|
||||||
- `next-devtools`: Next.js 프로젝트 개발/디버깅, 공식 문서 인덱스 조회
|
|
||||||
- `playwright`: 브라우저 자동화 테스트 (페이지 상호작용/검증)
|
|
||||||
- `playwriter`: Chrome 확장 기반 브라우저 자동화/디버깅
|
|
||||||
- `context7`: 라이브러리/프레임워크 공식 문서 참조
|
|
||||||
- `supabase-mcp-server`: Supabase 프로젝트 관리 및 SQL/함수 작업
|
|
||||||
- `tavily-remote`: 최신 기술 트렌드/웹 검색
|
|
||||||
- `sequential-thinking`: 복잡한 문제를 단계적으로 정리
|
|
||||||
- `figma`: Figma 파일 레이아웃/스타일/에셋 조회
|
|
||||||
- `mcp:kis-code-assistant-mcp`: 한국투자증권 API 검색/소스 조회
|
|
||||||
|
|
||||||
## 한국 투자 증권 API 이용시
|
|
||||||
|
|
||||||
- `mcp:kis-code-assistant-mcp` 활용
|
|
||||||
- `C:\dev\auto-trade\.tmp\open-trading-api` 활용
|
|
||||||
- 업로드된 전체 API 엑셀을 우선 참고: `C:\dev\auto-trade\common-docs\api-reference\openapi_all.xlsx`
|
|
||||||
- API 스펙 확인 순서: `openapi_all.xlsx` -> `mcp:kis-code-assistant-mcp` -> `.tmp\open-trading-api` 샘플 코드
|
|
||||||
- 공식 문서와 엑셀/실코드가 다르면 엑셀과 실코드를 우선 기준으로 판단하고, 차이가 크면 사용자에게 최신 파일 재확인 요청
|
|
||||||
|
|
||||||
## 소개문구
|
|
||||||
|
|
||||||
- 불안감을 해소하고 확신을 주는 문구
|
|
||||||
- 친근하고 확신에 찬 문구를 사용하여 심리적 장벽을 낮추는 전략
|
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -3,6 +3,11 @@ import type { DashboardActivityResponse } from "@/features/dashboard/types/dashb
|
|||||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||||
import { getDomesticDashboardActivity } from "@/lib/kis/dashboard";
|
import { getDomesticDashboardActivity } from "@/lib/kis/dashboard";
|
||||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||||
|
import {
|
||||||
|
createKisApiErrorResponse,
|
||||||
|
KIS_API_ERROR_CODE,
|
||||||
|
toKisApiErrorMessage,
|
||||||
|
} from "@/app/api/kis/_response";
|
||||||
import {
|
import {
|
||||||
readKisAccountParts,
|
readKisAccountParts,
|
||||||
readKisCredentialsFromHeaders,
|
readKisCredentialsFromHeaders,
|
||||||
@@ -23,29 +28,31 @@ import {
|
|||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const hasSession = await hasKisApiSession();
|
const hasSession = await hasKisApiSession();
|
||||||
if (!hasSession) {
|
if (!hasSession) {
|
||||||
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
|
return createKisApiErrorResponse({
|
||||||
|
status: 401,
|
||||||
|
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||||
|
message: "로그인이 필요합니다.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||||
|
|
||||||
if (!hasKisConfig(credentials)) {
|
if (!hasKisConfig(credentials)) {
|
||||||
return NextResponse.json(
|
return createKisApiErrorResponse({
|
||||||
{
|
status: 400,
|
||||||
error: "KIS API 키 설정이 필요합니다.",
|
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||||
},
|
message: "KIS API 키 설정이 필요합니다.",
|
||||||
{ status: 400 },
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const account = readKisAccountParts(request.headers);
|
const account = readKisAccountParts(request.headers);
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return NextResponse.json(
|
return createKisApiErrorResponse({
|
||||||
{
|
status: 400,
|
||||||
error:
|
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
|
||||||
|
message:
|
||||||
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
|
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
|
||||||
},
|
});
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -66,10 +73,13 @@ export async function GET(request: Request) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
return createKisApiErrorResponse({
|
||||||
error instanceof Error
|
status: 500,
|
||||||
? error.message
|
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||||
: "주문내역/매매일지 조회 중 오류가 발생했습니다.";
|
message: toKisApiErrorMessage(
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
error,
|
||||||
|
"주문내역/매매일지 조회 중 오류가 발생했습니다.",
|
||||||
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import type { DashboardBalanceResponse } from "@/features/dashboard/types/dashbo
|
|||||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||||
import { getDomesticDashboardBalance } from "@/lib/kis/dashboard";
|
import { getDomesticDashboardBalance } from "@/lib/kis/dashboard";
|
||||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||||
|
import {
|
||||||
|
createKisApiErrorResponse,
|
||||||
|
KIS_API_ERROR_CODE,
|
||||||
|
toKisApiErrorMessage,
|
||||||
|
} from "@/app/api/kis/_response";
|
||||||
import {
|
import {
|
||||||
readKisAccountParts,
|
readKisAccountParts,
|
||||||
readKisCredentialsFromHeaders,
|
readKisCredentialsFromHeaders,
|
||||||
@@ -21,29 +26,31 @@ import {
|
|||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const hasSession = await hasKisApiSession();
|
const hasSession = await hasKisApiSession();
|
||||||
if (!hasSession) {
|
if (!hasSession) {
|
||||||
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
|
return createKisApiErrorResponse({
|
||||||
|
status: 401,
|
||||||
|
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||||
|
message: "로그인이 필요합니다.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||||
|
|
||||||
if (!hasKisConfig(credentials)) {
|
if (!hasKisConfig(credentials)) {
|
||||||
return NextResponse.json(
|
return createKisApiErrorResponse({
|
||||||
{
|
status: 400,
|
||||||
error: "KIS API 키 설정이 필요합니다.",
|
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||||
},
|
message: "KIS API 키 설정이 필요합니다.",
|
||||||
{ status: 400 },
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const account = readKisAccountParts(request.headers);
|
const account = readKisAccountParts(request.headers);
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return NextResponse.json(
|
return createKisApiErrorResponse({
|
||||||
{
|
status: 400,
|
||||||
error:
|
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
|
||||||
|
message:
|
||||||
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
|
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
|
||||||
},
|
});
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -62,10 +69,10 @@ export async function GET(request: Request) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
return createKisApiErrorResponse({
|
||||||
error instanceof Error
|
status: 500,
|
||||||
? error.message
|
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||||
: "잔고 조회 중 오류가 발생했습니다.";
|
message: toKisApiErrorMessage(error, "잔고 조회 중 오류가 발생했습니다."),
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ import type {
|
|||||||
DashboardChartTimeframe,
|
DashboardChartTimeframe,
|
||||||
DashboardStockChartResponse,
|
DashboardStockChartResponse,
|
||||||
} from "@/features/trade/types/trade.types";
|
} from "@/features/trade/types/trade.types";
|
||||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
import { hasKisConfig } from "@/lib/kis/config";
|
||||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
|
||||||
import { getDomesticChart } from "@/lib/kis/domestic";
|
import { getDomesticChart } from "@/lib/kis/domestic";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||||
|
import {
|
||||||
|
createKisApiErrorResponse,
|
||||||
|
KIS_API_ERROR_CODE,
|
||||||
|
toKisApiErrorMessage,
|
||||||
|
} from "@/app/api/kis/_response";
|
||||||
|
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
||||||
|
|
||||||
const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
|
const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
|
||||||
"1m",
|
"1m",
|
||||||
@@ -23,7 +28,11 @@ const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
|
|||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const hasSession = await hasKisApiSession();
|
const hasSession = await hasKisApiSession();
|
||||||
if (!hasSession) {
|
if (!hasSession) {
|
||||||
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
|
return createKisApiErrorResponse({
|
||||||
|
status: 401,
|
||||||
|
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||||
|
message: "로그인이 필요합니다.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
@@ -34,28 +43,29 @@ export async function GET(request: NextRequest) {
|
|||||||
const cursor = (searchParams.get("cursor") ?? "").trim() || undefined;
|
const cursor = (searchParams.get("cursor") ?? "").trim() || undefined;
|
||||||
|
|
||||||
if (!/^\d{6}$/.test(symbol)) {
|
if (!/^\d{6}$/.test(symbol)) {
|
||||||
return NextResponse.json(
|
return createKisApiErrorResponse({
|
||||||
{ error: "symbol은 6자리 숫자여야 합니다." },
|
status: 400,
|
||||||
{ status: 400 },
|
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||||
);
|
message: "symbol은 6자리 숫자여야 합니다.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!VALID_TIMEFRAMES.includes(timeframe)) {
|
if (!VALID_TIMEFRAMES.includes(timeframe)) {
|
||||||
return NextResponse.json(
|
return createKisApiErrorResponse({
|
||||||
{ error: "지원하지 않는 timeframe입니다." },
|
status: 400,
|
||||||
{ status: 400 },
|
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||||
);
|
message: "지원하지 않는 timeframe입니다.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||||
if (!hasKisConfig(credentials)) {
|
if (!hasKisConfig(credentials)) {
|
||||||
return NextResponse.json(
|
return createKisApiErrorResponse({
|
||||||
{
|
status: 400,
|
||||||
error:
|
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||||
|
message:
|
||||||
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 차트를 조회할 수 없습니다.",
|
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 차트를 조회할 수 없습니다.",
|
||||||
},
|
});
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -81,24 +91,10 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
return createKisApiErrorResponse({
|
||||||
error instanceof Error
|
status: 500,
|
||||||
? error.message
|
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||||
: "KIS 차트 조회 중 오류가 발생했습니다.";
|
message: toKisApiErrorMessage(error, "KIS 차트 조회 중 오류가 발생했습니다."),
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
|
|
||||||
const appKey = headers.get("x-kis-app-key")?.trim();
|
|
||||||
const appSecret = headers.get("x-kis-app-secret")?.trim();
|
|
||||||
const tradingEnv = normalizeTradingEnv(
|
|
||||||
headers.get("x-kis-trading-env") ?? undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
appKey,
|
|
||||||
appSecret,
|
|
||||||
tradingEnv,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import type { DashboardIndicesResponse } from "@/features/dashboard/types/dashbo
|
|||||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||||
import { getDomesticDashboardIndices } from "@/lib/kis/dashboard";
|
import { getDomesticDashboardIndices } from "@/lib/kis/dashboard";
|
||||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||||
|
import {
|
||||||
|
createKisApiErrorResponse,
|
||||||
|
KIS_API_ERROR_CODE,
|
||||||
|
toKisApiErrorMessage,
|
||||||
|
} from "@/app/api/kis/_response";
|
||||||
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,18 +23,21 @@ import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
|||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const hasSession = await hasKisApiSession();
|
const hasSession = await hasKisApiSession();
|
||||||
if (!hasSession) {
|
if (!hasSession) {
|
||||||
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
|
return createKisApiErrorResponse({
|
||||||
|
status: 401,
|
||||||
|
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||||
|
message: "로그인이 필요합니다.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||||
|
|
||||||
if (!hasKisConfig(credentials)) {
|
if (!hasKisConfig(credentials)) {
|
||||||
return NextResponse.json(
|
return createKisApiErrorResponse({
|
||||||
{
|
status: 400,
|
||||||
error: "KIS API 키 설정이 필요합니다.",
|
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||||
},
|
message: "KIS API 키 설정이 필요합니다.",
|
||||||
{ status: 400 },
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -47,10 +55,10 @@ export async function GET(request: Request) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
return createKisApiErrorResponse({
|
||||||
error instanceof Error
|
status: 500,
|
||||||
? error.message
|
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||||
: "지수 조회 중 오류가 발생했습니다.";
|
message: toKisApiErrorMessage(error, "지수 조회 중 오류가 발생했습니다."),
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +1,137 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
import { executeOrderCash } from "@/lib/kis/trade";
|
import { executeOrderCash } from "@/lib/kis/trade";
|
||||||
import {
|
import {
|
||||||
DashboardStockCashOrderRequest,
|
|
||||||
DashboardStockCashOrderResponse,
|
DashboardStockCashOrderResponse,
|
||||||
} from "@/features/trade/types/trade.types";
|
} from "@/features/trade/types/trade.types";
|
||||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||||
|
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||||
|
import { parseKisAccountParts } from "@/lib/kis/account";
|
||||||
import {
|
import {
|
||||||
KisCredentialInput,
|
createKisApiErrorResponse,
|
||||||
hasKisConfig,
|
KIS_API_ERROR_CODE,
|
||||||
normalizeTradingEnv,
|
toKisApiErrorMessage,
|
||||||
} from "@/lib/kis/config";
|
} from "@/app/api/kis/_response";
|
||||||
|
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @file app/api/kis/domestic/order-cash/route.ts
|
* @file app/api/kis/domestic/order-cash/route.ts
|
||||||
* @description 국내주식 현금 주문 API
|
* @description 국내주식 현금 주문 API
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const orderCashBodySchema = z
|
||||||
|
.object({
|
||||||
|
symbol: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^\d{6}$/, "종목코드는 6자리 숫자여야 합니다."),
|
||||||
|
side: z.enum(["buy", "sell"], {
|
||||||
|
message: "주문 구분(side)은 buy/sell만 허용됩니다.",
|
||||||
|
}),
|
||||||
|
orderType: z.enum(["limit", "market"], {
|
||||||
|
message: "주문 유형(orderType)은 limit/market만 허용됩니다.",
|
||||||
|
}),
|
||||||
|
quantity: z.coerce
|
||||||
|
.number()
|
||||||
|
.int("주문수량은 정수여야 합니다.")
|
||||||
|
.positive("주문수량은 1주 이상이어야 합니다."),
|
||||||
|
price: z.coerce.number(),
|
||||||
|
accountNo: z.string().trim().min(1, "계좌번호를 입력해 주세요."),
|
||||||
|
accountProductCode: z.string().trim().optional(),
|
||||||
|
})
|
||||||
|
.superRefine((body, ctx) => {
|
||||||
|
if (body.orderType === "limit" && body.price <= 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["price"],
|
||||||
|
message: "지정가 주문은 주문가격이 0보다 커야 합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.orderType === "market" && body.price < 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["price"],
|
||||||
|
message: "시장가 주문은 주문가격이 0 이상이어야 합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountParts = parseKisAccountParts(
|
||||||
|
body.accountNo,
|
||||||
|
body.accountProductCode,
|
||||||
|
);
|
||||||
|
if (!accountParts) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["accountNo"],
|
||||||
|
message:
|
||||||
|
"계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||||
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
|
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
|
||||||
|
|
||||||
const hasSession = await hasKisApiSession();
|
const hasSession = await hasKisApiSession();
|
||||||
if (!hasSession) {
|
if (!hasSession) {
|
||||||
return NextResponse.json(
|
return createKisApiErrorResponse({
|
||||||
{
|
status: 401,
|
||||||
ok: false,
|
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||||
tradingEnv,
|
|
||||||
message: "로그인이 필요합니다.",
|
message: "로그인이 필요합니다.",
|
||||||
},
|
tradingEnv,
|
||||||
{ status: 401 },
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasKisConfig(credentials)) {
|
if (!hasKisConfig(credentials)) {
|
||||||
return NextResponse.json(
|
return createKisApiErrorResponse({
|
||||||
{
|
status: 400,
|
||||||
ok: false,
|
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||||
tradingEnv,
|
|
||||||
message: "KIS API 키 설정이 필요합니다.",
|
message: "KIS API 키 설정이 필요합니다.",
|
||||||
},
|
tradingEnv,
|
||||||
{ status: 400 },
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = (await request.json()) as DashboardStockCashOrderRequest;
|
let rawBody: unknown = {};
|
||||||
|
try {
|
||||||
// TODO: Validate body fields (symbol, quantity, price, etc.)
|
rawBody = (await request.json()) as unknown;
|
||||||
if (
|
} catch {
|
||||||
!body.symbol ||
|
return createKisApiErrorResponse({
|
||||||
!body.accountNo ||
|
status: 400,
|
||||||
!body.accountProductCode ||
|
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||||
body.quantity <= 0
|
message: "요청 본문(JSON)을 읽을 수 없습니다.",
|
||||||
) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
ok: false,
|
|
||||||
tradingEnv,
|
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(
|
const output = await executeOrderCash(
|
||||||
@@ -71,8 +141,8 @@ export async function POST(request: NextRequest) {
|
|||||||
orderType: body.orderType,
|
orderType: body.orderType,
|
||||||
quantity: body.quantity,
|
quantity: body.quantity,
|
||||||
price: body.price,
|
price: body.price,
|
||||||
accountNo: body.accountNo,
|
accountNo: accountParts.accountNo,
|
||||||
accountProductCode: body.accountProductCode,
|
accountProductCode: accountParts.accountProductCode,
|
||||||
},
|
},
|
||||||
credentials,
|
credentials,
|
||||||
);
|
);
|
||||||
@@ -88,31 +158,11 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json(response);
|
return NextResponse.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
return createKisApiErrorResponse({
|
||||||
error instanceof Error
|
status: 500,
|
||||||
? error.message
|
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||||
: "주문 전송 중 오류가 발생했습니다.";
|
message: toKisApiErrorMessage(error, "주문 전송 중 오류가 발생했습니다."),
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
ok: false,
|
|
||||||
tradingEnv,
|
tradingEnv,
|
||||||
message,
|
});
|
||||||
},
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
|
|
||||||
const appKey = headers.get("x-kis-app-key")?.trim();
|
|
||||||
const appSecret = headers.get("x-kis-app-secret")?.trim();
|
|
||||||
const tradingEnv = normalizeTradingEnv(
|
|
||||||
headers.get("x-kis-trading-env") ?? undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
appKey,
|
|
||||||
appSecret,
|
|
||||||
tradingEnv,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,15 +5,17 @@ import {
|
|||||||
} from "@/lib/kis/domestic";
|
} from "@/lib/kis/domestic";
|
||||||
import { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types";
|
import { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types";
|
||||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||||
import {
|
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||||
KisCredentialInput,
|
|
||||||
hasKisConfig,
|
|
||||||
normalizeTradingEnv,
|
|
||||||
} from "@/lib/kis/config";
|
|
||||||
import {
|
import {
|
||||||
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
|
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
|
||||||
parseDomesticKisSession,
|
parseDomesticKisSession,
|
||||||
} from "@/lib/kis/domestic-market-session";
|
} from "@/lib/kis/domestic-market-session";
|
||||||
|
import {
|
||||||
|
createKisApiErrorResponse,
|
||||||
|
KIS_API_ERROR_CODE,
|
||||||
|
toKisApiErrorMessage,
|
||||||
|
} from "@/app/api/kis/_response";
|
||||||
|
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @file app/api/kis/domestic/orderbook/route.ts
|
* @file app/api/kis/domestic/orderbook/route.ts
|
||||||
@@ -23,28 +25,32 @@ import {
|
|||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const hasSession = await hasKisApiSession();
|
const hasSession = await hasKisApiSession();
|
||||||
if (!hasSession) {
|
if (!hasSession) {
|
||||||
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
|
return createKisApiErrorResponse({
|
||||||
|
status: 401,
|
||||||
|
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||||
|
message: "로그인이 필요합니다.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const symbol = (searchParams.get("symbol") ?? "").trim();
|
const symbol = (searchParams.get("symbol") ?? "").trim();
|
||||||
|
|
||||||
if (!/^\d{6}$/.test(symbol)) {
|
if (!/^\d{6}$/.test(symbol)) {
|
||||||
return NextResponse.json(
|
return createKisApiErrorResponse({
|
||||||
{ error: "symbol은 6자리 숫자여야 합니다." },
|
status: 400,
|
||||||
{ status: 400 },
|
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||||
);
|
message: "symbol은 6자리 숫자여야 합니다.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||||
|
|
||||||
if (!hasKisConfig(credentials)) {
|
if (!hasKisConfig(credentials)) {
|
||||||
return NextResponse.json(
|
return createKisApiErrorResponse({
|
||||||
{
|
status: 400,
|
||||||
error: "KIS API 키 설정이 필요합니다.",
|
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||||
},
|
message: "KIS API 키 설정이 필요합니다.",
|
||||||
{ status: 400 },
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -95,28 +101,14 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
return createKisApiErrorResponse({
|
||||||
error instanceof Error
|
status: 500,
|
||||||
? error.message
|
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||||
: "호가 조회 중 오류가 발생했습니다.";
|
message: toKisApiErrorMessage(error, "호가 조회 중 오류가 발생했습니다."),
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
|
|
||||||
const appKey = headers.get("x-kis-app-key")?.trim();
|
|
||||||
const appSecret = headers.get("x-kis-app-secret")?.trim();
|
|
||||||
const tradingEnv = normalizeTradingEnv(
|
|
||||||
headers.get("x-kis-trading-env") ?? undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
appKey,
|
|
||||||
appSecret,
|
|
||||||
tradingEnv,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSessionOverrideFromHeaders(headers: Headers) {
|
function readSessionOverrideFromHeaders(headers: Headers) {
|
||||||
if (process.env.NODE_ENV === "production") return null;
|
if (process.env.NODE_ENV === "production") return null;
|
||||||
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);
|
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks";
|
import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks";
|
||||||
import type { DashboardStockOverviewResponse } from "@/features/trade/types/trade.types";
|
import type { DashboardStockOverviewResponse } from "@/features/trade/types/trade.types";
|
||||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
|
||||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||||
import { getDomesticOverview } from "@/lib/kis/domestic";
|
import { getDomesticOverview } from "@/lib/kis/domestic";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
createKisApiErrorResponse,
|
||||||
|
KIS_API_ERROR_CODE,
|
||||||
|
toKisApiErrorMessage,
|
||||||
|
} from "@/app/api/kis/_response";
|
||||||
import {
|
import {
|
||||||
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
|
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
|
||||||
parseDomesticKisSession,
|
parseDomesticKisSession,
|
||||||
} from "@/lib/kis/domestic-market-session";
|
} from "@/lib/kis/domestic-market-session";
|
||||||
|
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @file app/api/kis/domestic/overview/route.ts
|
* @file app/api/kis/domestic/overview/route.ts
|
||||||
@@ -23,26 +28,33 @@ import {
|
|||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const hasSession = await hasKisApiSession();
|
const hasSession = await hasKisApiSession();
|
||||||
if (!hasSession) {
|
if (!hasSession) {
|
||||||
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
|
return createKisApiErrorResponse({
|
||||||
|
status: 401,
|
||||||
|
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||||
|
message: "로그인이 필요합니다.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const symbol = (searchParams.get("symbol") ?? "").trim();
|
const symbol = (searchParams.get("symbol") ?? "").trim();
|
||||||
|
|
||||||
if (!/^\d{6}$/.test(symbol)) {
|
if (!/^\d{6}$/.test(symbol)) {
|
||||||
return NextResponse.json({ error: "symbol은 6자리 숫자여야 합니다." }, { status: 400 });
|
return createKisApiErrorResponse({
|
||||||
|
status: 400,
|
||||||
|
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||||
|
message: "symbol은 6자리 숫자여야 합니다.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||||
|
|
||||||
if (!hasKisConfig(credentials)) {
|
if (!hasKisConfig(credentials)) {
|
||||||
return NextResponse.json(
|
return createKisApiErrorResponse({
|
||||||
{
|
status: 400,
|
||||||
error:
|
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||||
|
message:
|
||||||
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 시세를 조회할 수 없습니다.",
|
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 시세를 조회할 수 없습니다.",
|
||||||
},
|
});
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackMeta = KOREAN_STOCK_INDEX.find((item) => item.symbol === symbol);
|
const fallbackMeta = KOREAN_STOCK_INDEX.find((item) => item.symbol === symbol);
|
||||||
@@ -71,28 +83,14 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "KIS 조회 중 오류가 발생했습니다.";
|
return createKisApiErrorResponse({
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
status: 500,
|
||||||
|
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||||
|
message: toKisApiErrorMessage(error, "KIS 조회 중 오류가 발생했습니다."),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 요청 헤더에서 KIS 키를 읽어옵니다.
|
|
||||||
* @param headers 요청 헤더
|
|
||||||
* @returns credentials
|
|
||||||
*/
|
|
||||||
function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
|
|
||||||
const appKey = headers.get("x-kis-app-key")?.trim();
|
|
||||||
const appSecret = headers.get("x-kis-app-secret")?.trim();
|
|
||||||
const tradingEnv = normalizeTradingEnv(headers.get("x-kis-trading-env") ?? undefined);
|
|
||||||
|
|
||||||
return {
|
|
||||||
appKey,
|
|
||||||
appSecret,
|
|
||||||
tradingEnv,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSessionOverrideFromHeaders(headers: Headers) {
|
function readSessionOverrideFromHeaders(headers: Headers) {
|
||||||
if (process.env.NODE_ENV === "production") return null;
|
if (process.env.NODE_ENV === "production") return null;
|
||||||
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);
|
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import type {
|
|||||||
KoreanStockIndexItem,
|
KoreanStockIndexItem,
|
||||||
} from "@/features/trade/types/trade.types";
|
} from "@/features/trade/types/trade.types";
|
||||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||||
|
import {
|
||||||
|
createKisApiErrorResponse,
|
||||||
|
KIS_API_ERROR_CODE,
|
||||||
|
} from "@/app/api/kis/_response";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
const SEARCH_LIMIT = 10;
|
const SEARCH_LIMIT = 10;
|
||||||
@@ -29,7 +33,11 @@ const SEARCH_LIMIT = 10;
|
|||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const hasSession = await hasKisApiSession();
|
const hasSession = await hasKisApiSession();
|
||||||
if (!hasSession) {
|
if (!hasSession) {
|
||||||
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
|
return createKisApiErrorResponse({
|
||||||
|
status: 401,
|
||||||
|
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||||
|
message: "로그인이 필요합니다.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Step 1] query string에서 검색어(q)를 읽고 공백을 제거합니다.
|
// [Step 1] query string에서 검색어(q)를 읽고 공백을 제거합니다.
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import {
|
|||||||
} from "@/lib/kis/request";
|
} from "@/lib/kis/request";
|
||||||
import { revokeKisAccessToken } from "@/lib/kis/token";
|
import { revokeKisAccessToken } from "@/lib/kis/token";
|
||||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||||
|
import {
|
||||||
|
createKisApiErrorResponse,
|
||||||
|
KIS_API_ERROR_CODE,
|
||||||
|
toKisApiErrorMessage,
|
||||||
|
} from "@/app/api/kis/_response";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,26 +28,22 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const hasSession = await hasKisApiSession();
|
const hasSession = await hasKisApiSession();
|
||||||
if (!hasSession) {
|
if (!hasSession) {
|
||||||
return NextResponse.json(
|
return createKisApiErrorResponse({
|
||||||
{
|
status: 401,
|
||||||
ok: false,
|
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||||
tradingEnv,
|
|
||||||
message: "로그인이 필요합니다.",
|
message: "로그인이 필요합니다.",
|
||||||
} satisfies DashboardKisRevokeResponse,
|
tradingEnv,
|
||||||
{ status: 401 },
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const invalidMessage = validateKisCredentialInput(credentials);
|
const invalidMessage = validateKisCredentialInput(credentials);
|
||||||
if (invalidMessage) {
|
if (invalidMessage) {
|
||||||
return NextResponse.json(
|
return createKisApiErrorResponse({
|
||||||
{
|
status: 400,
|
||||||
ok: false,
|
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||||
tradingEnv,
|
|
||||||
message: invalidMessage,
|
message: invalidMessage,
|
||||||
} satisfies DashboardKisRevokeResponse,
|
tradingEnv,
|
||||||
{ status: 400 },
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -54,18 +55,11 @@ export async function POST(request: NextRequest) {
|
|||||||
message,
|
message,
|
||||||
} satisfies DashboardKisRevokeResponse);
|
} satisfies DashboardKisRevokeResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
return createKisApiErrorResponse({
|
||||||
error instanceof Error
|
status: 401,
|
||||||
? error.message
|
code: KIS_API_ERROR_CODE.UNAUTHORIZED,
|
||||||
: "API 토큰 폐기 중 오류가 발생했습니다.";
|
message: toKisApiErrorMessage(error, "API 토큰 폐기 중 오류가 발생했습니다."),
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
ok: false,
|
|
||||||
tradingEnv,
|
tradingEnv,
|
||||||
message,
|
});
|
||||||
} satisfies DashboardKisRevokeResponse,
|
|
||||||
{ status: 401 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
import type { DashboardKisProfileValidateResponse } from "@/features/trade/types/trade.types";
|
import type { DashboardKisProfileValidateResponse } from "@/features/trade/types/trade.types";
|
||||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||||
import { parseKisAccountParts } from "@/lib/kis/account";
|
import { parseKisAccountParts } from "@/lib/kis/account";
|
||||||
@@ -6,13 +7,18 @@ import { kisGet } from "@/lib/kis/client";
|
|||||||
import { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config";
|
import { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config";
|
||||||
import { validateKisCredentialInput } from "@/lib/kis/request";
|
import { validateKisCredentialInput } from "@/lib/kis/request";
|
||||||
import { getKisAccessToken } from "@/lib/kis/token";
|
import { getKisAccessToken } from "@/lib/kis/token";
|
||||||
|
import {
|
||||||
|
createKisApiErrorResponse,
|
||||||
|
KIS_API_ERROR_CODE,
|
||||||
|
toKisApiErrorMessage,
|
||||||
|
} from "@/app/api/kis/_response";
|
||||||
|
|
||||||
interface KisProfileValidateRequestBody {
|
const kisProfileValidateBodySchema = z.object({
|
||||||
appKey?: string;
|
appKey: z.string().trim().min(1, "앱 키를 입력해 주세요."),
|
||||||
appSecret?: string;
|
appSecret: z.string().trim().min(1, "앱 시크릿을 입력해 주세요."),
|
||||||
tradingEnv?: string;
|
tradingEnv: z.string().optional(),
|
||||||
accountNo?: string;
|
accountNo: z.string().trim().min(1, "계좌번호를 입력해 주세요."),
|
||||||
}
|
});
|
||||||
|
|
||||||
interface BalanceValidationPreset {
|
interface BalanceValidationPreset {
|
||||||
inqrDvsn: "01" | "02";
|
inqrDvsn: "01" | "02";
|
||||||
@@ -50,34 +56,44 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const hasSession = await hasKisApiSession();
|
const hasSession = await hasKisApiSession();
|
||||||
if (!hasSession) {
|
if (!hasSession) {
|
||||||
return NextResponse.json(
|
return createKisApiErrorResponse({
|
||||||
{
|
status: 401,
|
||||||
ok: false,
|
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||||
tradingEnv: fallbackTradingEnv,
|
|
||||||
message: "로그인이 필요합니다.",
|
message: "로그인이 필요합니다.",
|
||||||
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
|
tradingEnv: fallbackTradingEnv,
|
||||||
{ status: 401 },
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: KisProfileValidateRequestBody = {};
|
let rawBody: unknown = {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
body = (await request.json()) as KisProfileValidateRequestBody;
|
rawBody = (await request.json()) as unknown;
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json(
|
return createKisApiErrorResponse({
|
||||||
{
|
status: 400,
|
||||||
ok: false,
|
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||||
tradingEnv: fallbackTradingEnv,
|
|
||||||
message: "요청 본문(JSON)을 읽을 수 없습니다.",
|
message: "요청 본문(JSON)을 읽을 수 없습니다.",
|
||||||
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
|
tradingEnv: fallbackTradingEnv,
|
||||||
{ status: 400 },
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parsedBody = kisProfileValidateBodySchema.safeParse(rawBody);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return createKisApiErrorResponse({
|
||||||
|
status: 400,
|
||||||
|
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||||
|
message:
|
||||||
|
parsedBody.error.issues[0]?.message ??
|
||||||
|
"요청 본문 값이 올바르지 않습니다.",
|
||||||
|
tradingEnv: fallbackTradingEnv,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = parsedBody.data;
|
||||||
|
|
||||||
const credentials: KisCredentialInput = {
|
const credentials: KisCredentialInput = {
|
||||||
appKey: body.appKey?.trim(),
|
appKey: body.appKey.trim(),
|
||||||
appSecret: body.appSecret?.trim(),
|
appSecret: body.appSecret.trim(),
|
||||||
tradingEnv: normalizeTradingEnv(body.tradingEnv),
|
tradingEnv: normalizeTradingEnv(body.tradingEnv),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,39 +101,25 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const invalidCredentialMessage = validateKisCredentialInput(credentials);
|
const invalidCredentialMessage = validateKisCredentialInput(credentials);
|
||||||
if (invalidCredentialMessage) {
|
if (invalidCredentialMessage) {
|
||||||
return NextResponse.json(
|
return createKisApiErrorResponse({
|
||||||
{
|
status: 400,
|
||||||
ok: false,
|
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||||
tradingEnv,
|
|
||||||
message: invalidCredentialMessage,
|
message: invalidCredentialMessage,
|
||||||
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const accountNoInput = (body.accountNo ?? "").trim();
|
|
||||||
|
|
||||||
if (!accountNoInput) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
ok: false,
|
|
||||||
tradingEnv,
|
tradingEnv,
|
||||||
message: "계좌번호를 입력해 주세요.",
|
});
|
||||||
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accountNoInput = body.accountNo.trim();
|
||||||
|
|
||||||
const accountParts = parseKisAccountParts(accountNoInput);
|
const accountParts = parseKisAccountParts(accountNoInput);
|
||||||
if (!accountParts) {
|
if (!accountParts) {
|
||||||
return NextResponse.json(
|
return createKisApiErrorResponse({
|
||||||
{
|
status: 400,
|
||||||
ok: false,
|
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
|
||||||
|
message:
|
||||||
|
"계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
|
||||||
tradingEnv,
|
tradingEnv,
|
||||||
message: "계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
|
});
|
||||||
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -150,19 +152,12 @@ export async function POST(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
} satisfies DashboardKisProfileValidateResponse);
|
} satisfies DashboardKisProfileValidateResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
return createKisApiErrorResponse({
|
||||||
error instanceof Error
|
status: 400,
|
||||||
? error.message
|
code: KIS_API_ERROR_CODE.UNAUTHORIZED,
|
||||||
: "계좌 검증 중 오류가 발생했습니다.";
|
message: toKisApiErrorMessage(error, "계좌 검증 중 오류가 발생했습니다."),
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
ok: false,
|
|
||||||
tradingEnv,
|
tradingEnv,
|
||||||
message,
|
});
|
||||||
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import {
|
|||||||
} from "@/lib/kis/request";
|
} from "@/lib/kis/request";
|
||||||
import { getKisAccessToken } from "@/lib/kis/token";
|
import { getKisAccessToken } from "@/lib/kis/token";
|
||||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||||
|
import {
|
||||||
|
createKisApiErrorResponse,
|
||||||
|
KIS_API_ERROR_CODE,
|
||||||
|
toKisApiErrorMessage,
|
||||||
|
} from "@/app/api/kis/_response";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,26 +28,22 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const hasSession = await hasKisApiSession();
|
const hasSession = await hasKisApiSession();
|
||||||
if (!hasSession) {
|
if (!hasSession) {
|
||||||
return NextResponse.json(
|
return createKisApiErrorResponse({
|
||||||
{
|
status: 401,
|
||||||
ok: false,
|
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||||
tradingEnv,
|
|
||||||
message: "로그인이 필요합니다.",
|
message: "로그인이 필요합니다.",
|
||||||
} satisfies DashboardKisValidateResponse,
|
tradingEnv,
|
||||||
{ status: 401 },
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const invalidMessage = validateKisCredentialInput(credentials);
|
const invalidMessage = validateKisCredentialInput(credentials);
|
||||||
if (invalidMessage) {
|
if (invalidMessage) {
|
||||||
return NextResponse.json(
|
return createKisApiErrorResponse({
|
||||||
{
|
status: 400,
|
||||||
ok: false,
|
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||||
tradingEnv,
|
|
||||||
message: invalidMessage,
|
message: invalidMessage,
|
||||||
} satisfies DashboardKisValidateResponse,
|
tradingEnv,
|
||||||
{ status: 400 },
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -54,18 +55,11 @@ export async function POST(request: NextRequest) {
|
|||||||
message: "API 키 검증이 완료되었습니다. (토큰 발급 성공)",
|
message: "API 키 검증이 완료되었습니다. (토큰 발급 성공)",
|
||||||
} satisfies DashboardKisValidateResponse);
|
} satisfies DashboardKisValidateResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
return createKisApiErrorResponse({
|
||||||
error instanceof Error
|
status: 401,
|
||||||
? error.message
|
code: KIS_API_ERROR_CODE.UNAUTHORIZED,
|
||||||
: "API 키 검증 중 오류가 발생했습니다.";
|
message: toKisApiErrorMessage(error, "API 키 검증 중 오류가 발생했습니다."),
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
ok: false,
|
|
||||||
tradingEnv,
|
tradingEnv,
|
||||||
message,
|
});
|
||||||
} satisfies DashboardKisValidateResponse,
|
|
||||||
{ status: 401 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import {
|
|||||||
parseKisCredentialRequest,
|
parseKisCredentialRequest,
|
||||||
validateKisCredentialInput,
|
validateKisCredentialInput,
|
||||||
} from "@/lib/kis/request";
|
} from "@/lib/kis/request";
|
||||||
|
import {
|
||||||
|
createKisApiErrorResponse,
|
||||||
|
KIS_API_ERROR_CODE,
|
||||||
|
toKisApiErrorMessage,
|
||||||
|
} from "@/app/api/kis/_response";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,26 +28,22 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const hasSession = await hasKisApiSession();
|
const hasSession = await hasKisApiSession();
|
||||||
if (!hasSession) {
|
if (!hasSession) {
|
||||||
return NextResponse.json(
|
return createKisApiErrorResponse({
|
||||||
{
|
status: 401,
|
||||||
ok: false,
|
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||||
tradingEnv,
|
|
||||||
message: "로그인이 필요합니다.",
|
message: "로그인이 필요합니다.",
|
||||||
} satisfies DashboardKisWsApprovalResponse,
|
tradingEnv,
|
||||||
{ status: 401 },
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const invalidMessage = validateKisCredentialInput(credentials);
|
const invalidMessage = validateKisCredentialInput(credentials);
|
||||||
if (invalidMessage) {
|
if (invalidMessage) {
|
||||||
return NextResponse.json(
|
return createKisApiErrorResponse({
|
||||||
{
|
status: 400,
|
||||||
ok: false,
|
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||||
tradingEnv,
|
|
||||||
message: invalidMessage,
|
message: invalidMessage,
|
||||||
} satisfies DashboardKisWsApprovalResponse,
|
tradingEnv,
|
||||||
{ status: 400 },
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -57,18 +58,14 @@ export async function POST(request: NextRequest) {
|
|||||||
message: "웹소켓 승인키 발급이 완료되었습니다.",
|
message: "웹소켓 승인키 발급이 완료되었습니다.",
|
||||||
} satisfies DashboardKisWsApprovalResponse);
|
} satisfies DashboardKisWsApprovalResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
return createKisApiErrorResponse({
|
||||||
error instanceof Error
|
status: 401,
|
||||||
? error.message
|
code: KIS_API_ERROR_CODE.UNAUTHORIZED,
|
||||||
: "웹소켓 승인키 발급 중 오류가 발생했습니다.";
|
message: toKisApiErrorMessage(
|
||||||
|
error,
|
||||||
return NextResponse.json(
|
"웹소켓 승인키 발급 중 오류가 발생했습니다.",
|
||||||
{
|
),
|
||||||
ok: false,
|
|
||||||
tradingEnv,
|
tradingEnv,
|
||||||
message,
|
});
|
||||||
} satisfies DashboardKisWsApprovalResponse,
|
|
||||||
{ status: 401 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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`
|
||||||
@@ -66,6 +66,7 @@ export function SessionManager() {
|
|||||||
|
|
||||||
for (const key of SESSION_RELATED_STORAGE_KEYS) {
|
for (const key of SESSION_RELATED_STORAGE_KEYS) {
|
||||||
window.localStorage.removeItem(key);
|
window.localStorage.removeItem(key);
|
||||||
|
window.sessionStorage.removeItem(key);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||||
|
import {
|
||||||
|
buildKisRequestHeaders,
|
||||||
|
resolveKisApiErrorMessage,
|
||||||
|
type KisApiErrorPayload,
|
||||||
|
} from "@/features/settings/apis/kis-api-utils";
|
||||||
import type {
|
import type {
|
||||||
DashboardActivityResponse,
|
DashboardActivityResponse,
|
||||||
DashboardBalanceResponse,
|
DashboardBalanceResponse,
|
||||||
@@ -21,18 +26,16 @@ export async function fetchDashboardBalance(
|
|||||||
): Promise<DashboardBalanceResponse> {
|
): Promise<DashboardBalanceResponse> {
|
||||||
const response = await fetch("/api/kis/domestic/balance", {
|
const response = await fetch("/api/kis/domestic/balance", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: buildKisRequestHeaders(credentials),
|
headers: buildKisRequestHeaders(credentials, { includeAccountNo: true }),
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = (await response.json()) as
|
const payload = (await response.json()) as
|
||||||
| DashboardBalanceResponse
|
| DashboardBalanceResponse
|
||||||
| { error?: string };
|
| KisApiErrorPayload;
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(resolveKisApiErrorMessage(payload, "잔고 조회 중 오류가 발생했습니다."));
|
||||||
"error" in payload ? payload.error : "잔고 조회 중 오류가 발생했습니다.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload as DashboardBalanceResponse;
|
return payload as DashboardBalanceResponse;
|
||||||
@@ -55,12 +58,10 @@ export async function fetchDashboardIndices(
|
|||||||
|
|
||||||
const payload = (await response.json()) as
|
const payload = (await response.json()) as
|
||||||
| DashboardIndicesResponse
|
| DashboardIndicesResponse
|
||||||
| { error?: string };
|
| KisApiErrorPayload;
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(resolveKisApiErrorMessage(payload, "지수 조회 중 오류가 발생했습니다."));
|
||||||
"error" in payload ? payload.error : "지수 조회 중 오류가 발생했습니다.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload as DashboardIndicesResponse;
|
return payload as DashboardIndicesResponse;
|
||||||
@@ -77,39 +78,17 @@ export async function fetchDashboardActivity(
|
|||||||
): Promise<DashboardActivityResponse> {
|
): Promise<DashboardActivityResponse> {
|
||||||
const response = await fetch("/api/kis/domestic/activity", {
|
const response = await fetch("/api/kis/domestic/activity", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: buildKisRequestHeaders(credentials),
|
headers: buildKisRequestHeaders(credentials, { includeAccountNo: true }),
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = (await response.json()) as
|
const payload = (await response.json()) as
|
||||||
| DashboardActivityResponse
|
| DashboardActivityResponse
|
||||||
| { error?: string };
|
| KisApiErrorPayload;
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(resolveKisApiErrorMessage(payload, "활동 데이터 조회 중 오류가 발생했습니다."));
|
||||||
"error" in payload ? payload.error : "활동 데이터 조회 중 오류가 발생했습니다.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload as DashboardActivityResponse;
|
return payload as DashboardActivityResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 대시보드 API 공통 헤더를 구성합니다.
|
|
||||||
* @param credentials KIS 인증 정보
|
|
||||||
* @returns KIS 전달 헤더
|
|
||||||
* @see features/dashboard/apis/dashboard.api.ts fetchDashboardBalance/fetchDashboardIndices
|
|
||||||
*/
|
|
||||||
function buildKisRequestHeaders(credentials: KisRuntimeCredentials) {
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
"x-kis-app-key": credentials.appKey,
|
|
||||||
"x-kis-app-secret": credentials.appSecret,
|
|
||||||
"x-kis-trading-env": credentials.tradingEnv,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (credentials.accountNo?.trim()) {
|
|
||||||
headers["x-kis-account-no"] = credentials.accountNo.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Spline from "@splinetool/react-spline";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface SplineSceneProps {
|
|
||||||
sceneUrl: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SplineScene({ sceneUrl, className }: SplineSceneProps) {
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("relative h-full w-full", className)}>
|
|
||||||
{isLoading && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-zinc-100 dark:bg-zinc-900">
|
|
||||||
<div className="h-10 w-10 animate-spin rounded-full border-4 border-zinc-200 border-t-brand-500 dark:border-zinc-800" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Spline
|
|
||||||
scene={sceneUrl}
|
|
||||||
onLoad={() => setIsLoading(false)}
|
|
||||||
className="h-full w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import { buildKisErrorDetail } from "@/lib/kis/error-codes";
|
||||||
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
|
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
|
||||||
import { buildKisRealtimeMessage } from "@/features/kis-realtime/utils/websocketUtils";
|
import { buildKisRealtimeMessage } from "@/features/kis-realtime/utils/websocketUtils";
|
||||||
|
|
||||||
@@ -63,6 +64,21 @@ const RECONNECT_BASE_DELAY_MS = 1_000;
|
|||||||
const RECONNECT_MAX_DELAY_MS = 30_000;
|
const RECONNECT_MAX_DELAY_MS = 30_000;
|
||||||
const RECONNECT_JITTER_MS = 300;
|
const RECONNECT_JITTER_MS = 300;
|
||||||
|
|
||||||
|
function isKisWsDebugEnabled() {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
return window.localStorage.getItem("KIS_WS_DEBUG") === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsDebugLog(...args: unknown[]) {
|
||||||
|
if (!isKisWsDebugEnabled()) return;
|
||||||
|
console.log(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsDebugWarn(...args: unknown[]) {
|
||||||
|
if (!isKisWsDebugEnabled()) return;
|
||||||
|
console.warn(...args);
|
||||||
|
}
|
||||||
|
|
||||||
export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -105,7 +121,7 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
|||||||
// 소켓 생성
|
// 소켓 생성
|
||||||
// socket 변수에 할당하기 전에 로컬 변수로 제어하여 이벤트 핸들러 클로저 문제 방지
|
// socket 변수에 할당하기 전에 로컬 변수로 제어하여 이벤트 핸들러 클로저 문제 방지
|
||||||
const ws = new WebSocket(wsConnection.wsUrl);
|
const ws = new WebSocket(wsConnection.wsUrl);
|
||||||
console.log("[KisWebSocket] Connecting to:", wsConnection.wsUrl);
|
wsDebugLog("[KisWebSocket] Connecting to:", wsConnection.wsUrl);
|
||||||
socket = ws;
|
socket = ws;
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
@@ -116,7 +132,7 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
|||||||
|
|
||||||
set({ isConnected: true, error: null });
|
set({ isConnected: true, error: null });
|
||||||
reconnectAttempt = 0;
|
reconnectAttempt = 0;
|
||||||
console.log("[KisWebSocket] Connected");
|
wsDebugLog("[KisWebSocket] Connected");
|
||||||
|
|
||||||
// 재연결 시 기존 구독 복구
|
// 재연결 시 기존 구독 복구
|
||||||
const approvalKey = wsConnection.approvalKey;
|
const approvalKey = wsConnection.approvalKey;
|
||||||
@@ -147,7 +163,7 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
|||||||
if (canAutoReconnect) {
|
if (canAutoReconnect) {
|
||||||
reconnectAttempt += 1;
|
reconnectAttempt += 1;
|
||||||
const delayMs = getReconnectDelayMs(reconnectAttempt);
|
const delayMs = getReconnectDelayMs(reconnectAttempt);
|
||||||
console.warn(
|
wsDebugWarn(
|
||||||
`[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"}, wasClean=${event.wasClean}) -> reconnect in ${delayMs}ms (attempt ${reconnectAttempt}/${MAX_AUTO_RECONNECT_ATTEMPTS})`,
|
`[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"}, wasClean=${event.wasClean}) -> reconnect in ${delayMs}ms (attempt ${reconnectAttempt}/${MAX_AUTO_RECONNECT_ATTEMPTS})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -170,7 +186,7 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
reconnectAttempt = 0;
|
reconnectAttempt = 0;
|
||||||
console.log(
|
wsDebugLog(
|
||||||
`[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"})`,
|
`[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -224,7 +240,7 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastAppKeyConflictAt > 5_000) {
|
if (now - lastAppKeyConflictAt > 5_000) {
|
||||||
lastAppKeyConflictAt = now;
|
lastAppKeyConflictAt = now;
|
||||||
console.warn(
|
wsDebugWarn(
|
||||||
"[KisWebSocket] ALREADY IN USE appkey - 현재 소켓을 닫고 30초 후 재연결합니다.",
|
"[KisWebSocket] ALREADY IN USE appkey - 현재 소켓을 닫고 30초 후 재연결합니다.",
|
||||||
);
|
);
|
||||||
// 현재 소켓을 즉시 닫아 서버 측 세션 정리 유도
|
// 현재 소켓을 즉시 닫아 서버 측 세션 정리 유도
|
||||||
@@ -374,11 +390,11 @@ function sendSubscription(
|
|||||||
try {
|
try {
|
||||||
const msg = buildKisRealtimeMessage(appKey, symbol, trId, trType);
|
const msg = buildKisRealtimeMessage(appKey, symbol, trId, trType);
|
||||||
ws.send(JSON.stringify(msg));
|
ws.send(JSON.stringify(msg));
|
||||||
console.debug(
|
wsDebugLog(
|
||||||
`[KisWebSocket] ${trType === "1" ? "Sub" : "Unsub"} ${trId} ${symbol}`,
|
`[KisWebSocket] ${trType === "1" ? "Sub" : "Unsub"} ${trId} ${symbol}`,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[KisWebSocket] Send error", e);
|
wsDebugWarn("[KisWebSocket] Send error", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,7 +456,10 @@ function buildControlErrorMessage(message: KisWsControlMessage) {
|
|||||||
if (message.msgCd === "OPSP8996") {
|
if (message.msgCd === "OPSP8996") {
|
||||||
return "실시간 연결이 다른 세션과 충돌해 재연결을 시도합니다.";
|
return "실시간 연결이 다른 세션과 충돌해 재연결을 시도합니다.";
|
||||||
}
|
}
|
||||||
const detail = [message.msg1, message.msgCd].filter(Boolean).join(" / ");
|
const detail = buildKisErrorDetail({
|
||||||
|
message: message.msg1,
|
||||||
|
msgCode: message.msgCd,
|
||||||
|
});
|
||||||
return detail
|
return detail
|
||||||
? `실시간 제어 메시지 오류: ${detail}`
|
? `실시간 제어 메시지 오류: ${detail}`
|
||||||
: "실시간 제어 메시지 오류";
|
: "실시간 제어 메시지 오류";
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
Home,
|
Home,
|
||||||
Settings,
|
Settings,
|
||||||
User,
|
|
||||||
Wallet,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
@@ -31,20 +29,6 @@ const MENU_ITEMS: MenuItem[] = [
|
|||||||
badge: "LIVE",
|
badge: "LIVE",
|
||||||
showInBottomNav: true,
|
showInBottomNav: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "자산현황",
|
|
||||||
href: "/assets",
|
|
||||||
icon: Wallet,
|
|
||||||
variant: "ghost",
|
|
||||||
showInBottomNav: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "프로필",
|
|
||||||
href: "/profile",
|
|
||||||
icon: User,
|
|
||||||
variant: "ghost",
|
|
||||||
showInBottomNav: false,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "설정",
|
title: "설정",
|
||||||
href: "/settings",
|
href: "/settings",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { User } from "@supabase/supabase-js";
|
import { User } from "@supabase/supabase-js";
|
||||||
import { LogOut, Settings, User as UserIcon } from "lucide-react";
|
import { LogOut, Settings } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { signout } from "@/features/auth/actions";
|
import { signout } from "@/features/auth/actions";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
@@ -54,6 +54,7 @@ export function UserMenu({ user, blendWithBackground = false }: UserMenuProps) {
|
|||||||
|
|
||||||
for (const key of SESSION_RELATED_STORAGE_KEYS) {
|
for (const key of SESSION_RELATED_STORAGE_KEYS) {
|
||||||
window.localStorage.removeItem(key);
|
window.localStorage.removeItem(key);
|
||||||
|
window.sessionStorage.removeItem(key);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,11 +98,6 @@ export function UserMenu({ user, blendWithBackground = false }: UserMenuProps) {
|
|||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => router.push("/profile")}>
|
|
||||||
<UserIcon className="mr-2 h-4 w-4" />
|
|
||||||
<span>프로필</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
<span>설정</span>
|
<span>설정</span>
|
||||||
|
|||||||
82
features/settings/apis/kis-api-utils.ts
Normal file
82
features/settings/apis/kis-api-utils.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||||
|
import {
|
||||||
|
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
|
||||||
|
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
|
||||||
|
parseDomesticKisSession,
|
||||||
|
} from "@/lib/kis/domestic-market-session";
|
||||||
|
|
||||||
|
export interface KisApiErrorPayload {
|
||||||
|
ok?: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
errorCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildKisRequestHeadersOptions {
|
||||||
|
jsonContentType?: boolean;
|
||||||
|
includeAccountNo?: boolean;
|
||||||
|
includeSessionOverride?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description KIS API 응답에서 사용자 노출용 에러 메시지를 추출합니다.
|
||||||
|
* @see features/trade/apis/kis-stock.api.ts 종목/주문 API 실패 처리
|
||||||
|
* @see features/dashboard/apis/dashboard.api.ts 대시보드 API 실패 처리
|
||||||
|
*/
|
||||||
|
export function resolveKisApiErrorMessage(
|
||||||
|
payload: unknown,
|
||||||
|
fallbackMessage: string,
|
||||||
|
) {
|
||||||
|
if (!payload || typeof payload !== "object") {
|
||||||
|
return fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = payload as KisApiErrorPayload;
|
||||||
|
return response.message || response.error || fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description KIS API 호출용 공통 헤더를 생성합니다.
|
||||||
|
* @see features/dashboard/apis/dashboard.api.ts 잔고/지수/활동 조회 공통 헤더
|
||||||
|
* @see features/trade/apis/kis-stock.api.ts 종목/호가/차트/주문 공통 헤더
|
||||||
|
*/
|
||||||
|
export function buildKisRequestHeaders(
|
||||||
|
credentials: KisRuntimeCredentials,
|
||||||
|
options?: BuildKisRequestHeadersOptions,
|
||||||
|
) {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"x-kis-app-key": credentials.appKey,
|
||||||
|
"x-kis-app-secret": credentials.appSecret,
|
||||||
|
"x-kis-trading-env": credentials.tradingEnv,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options?.jsonContentType) {
|
||||||
|
headers["content-type"] = "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.includeAccountNo && credentials.accountNo.trim()) {
|
||||||
|
headers["x-kis-account-no"] = credentials.accountNo.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.includeSessionOverride) {
|
||||||
|
const sessionOverride = readSessionOverrideForDev();
|
||||||
|
if (sessionOverride) {
|
||||||
|
headers[DOMESTIC_KIS_SESSION_OVERRIDE_HEADER] = sessionOverride;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSessionOverrideForDev() {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(
|
||||||
|
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
|
||||||
|
);
|
||||||
|
return parseDomesticKisSession(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||||
|
import {
|
||||||
|
resolveKisApiErrorMessage,
|
||||||
|
type KisApiErrorPayload,
|
||||||
|
} from "@/features/settings/apis/kis-api-utils";
|
||||||
import type {
|
import type {
|
||||||
DashboardKisProfileValidateResponse,
|
DashboardKisProfileValidateResponse,
|
||||||
DashboardKisRevokeResponse,
|
DashboardKisRevokeResponse,
|
||||||
@@ -25,13 +29,13 @@ async function postKisAuthApi<T extends KisApiBaseResponse>(
|
|||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = (await response.json()) as T;
|
const payload = (await response.json()) as T | KisApiErrorPayload;
|
||||||
|
|
||||||
if (!response.ok || !payload.ok) {
|
if (!response.ok || !payload.ok) {
|
||||||
throw new Error(payload.message || fallbackErrorMessage);
|
throw new Error(resolveKisApiErrorMessage(payload, fallbackErrorMessage));
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload;
|
return payload as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -240,11 +240,14 @@ export const useKisRuntimeStore = create<
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "autotrade-kis-runtime-store",
|
name: "autotrade-kis-runtime-store",
|
||||||
storage: createJSONStorage(() => localStorage),
|
// 민감정보(appKey/appSecret/accountNo)는 브라우저 세션 범위로만 유지합니다.
|
||||||
|
storage: createJSONStorage(() => sessionStorage),
|
||||||
onRehydrateStorage: () => (state) => {
|
onRehydrateStorage: () => (state) => {
|
||||||
state?.setHasHydrated(true);
|
state?.setHasHydrated(true);
|
||||||
},
|
},
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
|
// 새로고침 시 인증이 풀리지 않도록, "세션 범위"에서만 인증/입력 상태를 유지합니다.
|
||||||
|
// 브라우저 종료 시 sessionStorage가 비워지므로 장기 영속(localStorage)은 하지 않습니다.
|
||||||
kisTradingEnvInput: state.kisTradingEnvInput,
|
kisTradingEnvInput: state.kisTradingEnvInput,
|
||||||
kisAppKeyInput: state.kisAppKeyInput,
|
kisAppKeyInput: state.kisAppKeyInput,
|
||||||
kisAppSecretInput: state.kisAppSecretInput,
|
kisAppSecretInput: state.kisAppSecretInput,
|
||||||
@@ -254,7 +257,6 @@ export const useKisRuntimeStore = create<
|
|||||||
isKisProfileVerified: state.isKisProfileVerified,
|
isKisProfileVerified: state.isKisProfileVerified,
|
||||||
verifiedAccountNo: state.verifiedAccountNo,
|
verifiedAccountNo: state.verifiedAccountNo,
|
||||||
tradingEnv: state.tradingEnv,
|
tradingEnv: state.tradingEnv,
|
||||||
// wsApprovalKey/wsUrl are kept in memory only (expiration-sensitive).
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||||
|
import {
|
||||||
|
buildKisRequestHeaders,
|
||||||
|
resolveKisApiErrorMessage,
|
||||||
|
type KisApiErrorPayload,
|
||||||
|
} from "@/features/settings/apis/kis-api-utils";
|
||||||
import type {
|
import type {
|
||||||
DashboardChartTimeframe,
|
DashboardChartTimeframe,
|
||||||
DashboardStockCashOrderRequest,
|
DashboardStockCashOrderRequest,
|
||||||
@@ -8,11 +13,6 @@ import type {
|
|||||||
DashboardStockOverviewResponse,
|
DashboardStockOverviewResponse,
|
||||||
DashboardStockSearchResponse,
|
DashboardStockSearchResponse,
|
||||||
} from "@/features/trade/types/trade.types";
|
} from "@/features/trade/types/trade.types";
|
||||||
import {
|
|
||||||
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
|
|
||||||
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
|
|
||||||
parseDomesticKisSession,
|
|
||||||
} from "@/lib/kis/domestic-market-session";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 종목 검색 API 호출
|
* 종목 검색 API 호출
|
||||||
@@ -32,12 +32,10 @@ export async function fetchStockSearch(
|
|||||||
|
|
||||||
const payload = (await response.json()) as
|
const payload = (await response.json()) as
|
||||||
| DashboardStockSearchResponse
|
| DashboardStockSearchResponse
|
||||||
| { error?: string };
|
| KisApiErrorPayload;
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(resolveKisApiErrorMessage(payload, "종목 검색 중 오류가 발생했습니다."));
|
||||||
"error" in payload ? payload.error : "종목 검색 중 오류가 발생했습니다.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload as DashboardStockSearchResponse;
|
return payload as DashboardStockSearchResponse;
|
||||||
@@ -56,19 +54,19 @@ export async function fetchStockOverview(
|
|||||||
`/api/kis/domestic/overview?symbol=${encodeURIComponent(symbol)}`,
|
`/api/kis/domestic/overview?symbol=${encodeURIComponent(symbol)}`,
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: buildKisRequestHeaders(credentials),
|
headers: buildKisRequestHeaders(credentials, {
|
||||||
|
includeSessionOverride: true,
|
||||||
|
}),
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const payload = (await response.json()) as
|
const payload = (await response.json()) as
|
||||||
| DashboardStockOverviewResponse
|
| DashboardStockOverviewResponse
|
||||||
| { error?: string };
|
| KisApiErrorPayload;
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(resolveKisApiErrorMessage(payload, "종목 조회 중 오류가 발생했습니다."));
|
||||||
"error" in payload ? payload.error : "종목 조회 중 오류가 발생했습니다.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload as DashboardStockOverviewResponse;
|
return payload as DashboardStockOverviewResponse;
|
||||||
@@ -88,7 +86,9 @@ export async function fetchStockOrderBook(
|
|||||||
`/api/kis/domestic/orderbook?symbol=${encodeURIComponent(symbol)}`,
|
`/api/kis/domestic/orderbook?symbol=${encodeURIComponent(symbol)}`,
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: buildKisRequestHeaders(credentials),
|
headers: buildKisRequestHeaders(credentials, {
|
||||||
|
includeSessionOverride: true,
|
||||||
|
}),
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
@@ -96,12 +96,10 @@ export async function fetchStockOrderBook(
|
|||||||
|
|
||||||
const payload = (await response.json()) as
|
const payload = (await response.json()) as
|
||||||
| DashboardStockOrderBookResponse
|
| DashboardStockOrderBookResponse
|
||||||
| { error?: string };
|
| KisApiErrorPayload;
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(resolveKisApiErrorMessage(payload, "호가 조회 중 오류가 발생했습니다."));
|
||||||
"error" in payload ? payload.error : "호가 조회 중 오류가 발생했습니다.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload as DashboardStockOrderBookResponse;
|
return payload as DashboardStockOrderBookResponse;
|
||||||
@@ -124,18 +122,18 @@ export async function fetchStockChart(
|
|||||||
|
|
||||||
const response = await fetch(`/api/kis/domestic/chart?${query.toString()}`, {
|
const response = await fetch(`/api/kis/domestic/chart?${query.toString()}`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: buildKisRequestHeaders(credentials),
|
headers: buildKisRequestHeaders(credentials, {
|
||||||
|
includeSessionOverride: true,
|
||||||
|
}),
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = (await response.json()) as
|
const payload = (await response.json()) as
|
||||||
| DashboardStockChartResponse
|
| DashboardStockChartResponse
|
||||||
| { error?: string };
|
| KisApiErrorPayload;
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(resolveKisApiErrorMessage(payload, "차트 조회 중 오류가 발생했습니다."));
|
||||||
"error" in payload ? payload.error : "차트 조회 중 오류가 발생했습니다.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload as DashboardStockChartResponse;
|
return payload as DashboardStockChartResponse;
|
||||||
@@ -152,51 +150,21 @@ export async function fetchOrderCash(
|
|||||||
): Promise<DashboardStockCashOrderResponse> {
|
): Promise<DashboardStockCashOrderResponse> {
|
||||||
const response = await fetch("/api/kis/domestic/order-cash", {
|
const response = await fetch("/api/kis/domestic/order-cash", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildKisRequestHeaders(credentials, { jsonContentType: true }),
|
headers: buildKisRequestHeaders(credentials, {
|
||||||
|
jsonContentType: true,
|
||||||
|
includeSessionOverride: true,
|
||||||
|
}),
|
||||||
body: JSON.stringify(request),
|
body: JSON.stringify(request),
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = (await response.json()) as DashboardStockCashOrderResponse;
|
const payload = (await response.json()) as
|
||||||
|
| DashboardStockCashOrderResponse
|
||||||
|
| KisApiErrorPayload;
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(payload.message || "주문 전송 중 오류가 발생했습니다.");
|
throw new Error(resolveKisApiErrorMessage(payload, "주문 전송 중 오류가 발생했습니다."));
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload;
|
return payload as DashboardStockCashOrderResponse;
|
||||||
}
|
|
||||||
|
|
||||||
function buildKisRequestHeaders(
|
|
||||||
credentials: KisRuntimeCredentials,
|
|
||||||
options?: { jsonContentType?: boolean },
|
|
||||||
) {
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
"x-kis-app-key": credentials.appKey,
|
|
||||||
"x-kis-app-secret": credentials.appSecret,
|
|
||||||
"x-kis-trading-env": credentials.tradingEnv,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (options?.jsonContentType) {
|
|
||||||
headers["content-type"] = "application/json";
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionOverride = readSessionOverrideForDev();
|
|
||||||
if (sessionOverride) {
|
|
||||||
headers[DOMESTIC_KIS_SESSION_OVERRIDE_HEADER] = sessionOverride;
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSessionOverrideForDev() {
|
|
||||||
if (typeof window === "undefined") return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const raw = window.localStorage.getItem(
|
|
||||||
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
|
|
||||||
);
|
|
||||||
return parseDomesticKisSession(raw);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -33,98 +33,18 @@ import {
|
|||||||
toRealtimeTickBar,
|
toRealtimeTickBar,
|
||||||
upsertRealtimeBar,
|
upsertRealtimeBar,
|
||||||
} from "./chart-utils";
|
} from "./chart-utils";
|
||||||
|
import {
|
||||||
const UP_COLOR = "#ef4444";
|
areBarsEqual,
|
||||||
const MINUTE_SYNC_INTERVAL_MS = 30000;
|
type ChartPalette,
|
||||||
const REALTIME_STALE_THRESHOLD_MS = 12000;
|
CHART_MIN_HEIGHT,
|
||||||
const CHART_MIN_HEIGHT = 220;
|
DEFAULT_CHART_PALETTE,
|
||||||
|
getChartPaletteFromCssVars,
|
||||||
interface ChartPalette {
|
MINUTE_SYNC_INTERVAL_MS,
|
||||||
backgroundColor: string;
|
MINUTE_TIMEFRAMES,
|
||||||
downColor: string;
|
PERIOD_TIMEFRAMES,
|
||||||
volumeDownColor: string;
|
REALTIME_STALE_THRESHOLD_MS,
|
||||||
textColor: string;
|
UP_COLOR,
|
||||||
borderColor: string;
|
} from "./stock-line-chart-meta";
|
||||||
gridColor: string;
|
|
||||||
crosshairColor: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_CHART_PALETTE: ChartPalette = {
|
|
||||||
backgroundColor: "#ffffff",
|
|
||||||
downColor: "#2563eb",
|
|
||||||
volumeDownColor: "rgba(37, 99, 235, 0.45)",
|
|
||||||
textColor: "#6d28d9",
|
|
||||||
borderColor: "#e9d5ff",
|
|
||||||
gridColor: "#f3e8ff",
|
|
||||||
crosshairColor: "#c084fc",
|
|
||||||
};
|
|
||||||
|
|
||||||
function readCssVar(name: string, fallback: string) {
|
|
||||||
if (typeof window === "undefined") return fallback;
|
|
||||||
const value = window
|
|
||||||
.getComputedStyle(document.documentElement)
|
|
||||||
.getPropertyValue(name)
|
|
||||||
.trim();
|
|
||||||
return value || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getChartPaletteFromCssVars(themeMode: "light" | "dark"): ChartPalette {
|
|
||||||
const isDark = themeMode === "dark";
|
|
||||||
const backgroundVar = isDark
|
|
||||||
? "--brand-chart-background-dark"
|
|
||||||
: "--brand-chart-background-light";
|
|
||||||
const textVar = isDark
|
|
||||||
? "--brand-chart-text-dark"
|
|
||||||
: "--brand-chart-text-light";
|
|
||||||
const borderVar = isDark
|
|
||||||
? "--brand-chart-border-dark"
|
|
||||||
: "--brand-chart-border-light";
|
|
||||||
const gridVar = isDark
|
|
||||||
? "--brand-chart-grid-dark"
|
|
||||||
: "--brand-chart-grid-light";
|
|
||||||
const crosshairVar = isDark
|
|
||||||
? "--brand-chart-crosshair-dark"
|
|
||||||
: "--brand-chart-crosshair-light";
|
|
||||||
|
|
||||||
return {
|
|
||||||
backgroundColor: readCssVar(
|
|
||||||
backgroundVar,
|
|
||||||
DEFAULT_CHART_PALETTE.backgroundColor,
|
|
||||||
),
|
|
||||||
downColor: readCssVar(
|
|
||||||
"--brand-chart-down",
|
|
||||||
DEFAULT_CHART_PALETTE.downColor,
|
|
||||||
),
|
|
||||||
volumeDownColor: readCssVar(
|
|
||||||
"--brand-chart-volume-down",
|
|
||||||
DEFAULT_CHART_PALETTE.volumeDownColor,
|
|
||||||
),
|
|
||||||
textColor: readCssVar(textVar, DEFAULT_CHART_PALETTE.textColor),
|
|
||||||
borderColor: readCssVar(borderVar, DEFAULT_CHART_PALETTE.borderColor),
|
|
||||||
gridColor: readCssVar(gridVar, DEFAULT_CHART_PALETTE.gridColor),
|
|
||||||
crosshairColor: readCssVar(
|
|
||||||
crosshairVar,
|
|
||||||
DEFAULT_CHART_PALETTE.crosshairColor,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const MINUTE_TIMEFRAMES: Array<{
|
|
||||||
value: DashboardChartTimeframe;
|
|
||||||
label: string;
|
|
||||||
}> = [
|
|
||||||
{ value: "1m", label: "1분" },
|
|
||||||
{ value: "30m", label: "30분" },
|
|
||||||
{ value: "1h", label: "1시간" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const PERIOD_TIMEFRAMES: Array<{
|
|
||||||
value: DashboardChartTimeframe;
|
|
||||||
label: string;
|
|
||||||
}> = [
|
|
||||||
{ value: "1d", label: "일" },
|
|
||||||
{ value: "1w", label: "주" },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface StockLineChartProps {
|
interface StockLineChartProps {
|
||||||
symbol?: string;
|
symbol?: string;
|
||||||
@@ -161,6 +81,7 @@ export function StockLineChart({
|
|||||||
const lastRealtimeAppliedAtRef = useRef(0);
|
const lastRealtimeAppliedAtRef = useRef(0);
|
||||||
const chartPaletteRef = useRef<ChartPalette>(DEFAULT_CHART_PALETTE);
|
const chartPaletteRef = useRef<ChartPalette>(DEFAULT_CHART_PALETTE);
|
||||||
const renderableBarsRef = useRef<ChartBar[]>([]);
|
const renderableBarsRef = useRef<ChartBar[]>([]);
|
||||||
|
const initialThemeModeRef = useRef<"light" | "dark">("light");
|
||||||
|
|
||||||
const activeThemeMode: "light" | "dark" =
|
const activeThemeMode: "light" | "dark" =
|
||||||
resolvedTheme === "dark"
|
resolvedTheme === "dark"
|
||||||
@@ -172,6 +93,10 @@ export function StockLineChart({
|
|||||||
? "dark"
|
? "dark"
|
||||||
: "light";
|
: "light";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initialThemeModeRef.current = activeThemeMode;
|
||||||
|
}, [activeThemeMode]);
|
||||||
|
|
||||||
// 복수 이벤트에서 중복 로드를 막기 위한 ref 상태
|
// 복수 이벤트에서 중복 로드를 막기 위한 ref 상태
|
||||||
const loadingMoreRef = useRef(false);
|
const loadingMoreRef = useRef(false);
|
||||||
const loadMoreHandlerRef = useRef<() => Promise<void>>(async () => {});
|
const loadMoreHandlerRef = useRef<() => Promise<void>>(async () => {});
|
||||||
@@ -244,8 +169,10 @@ export function StockLineChart({
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
console.error("Failed to render chart series data:", error);
|
console.error("Failed to render chart series data:", error);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -296,7 +223,7 @@ export function StockLineChart({
|
|||||||
if (!container || chartRef.current) return;
|
if (!container || chartRef.current) return;
|
||||||
|
|
||||||
// 브랜드 색상은 globals.css의 CSS 변수에서 읽어 차트(canvas)에도 동일하게 적용합니다.
|
// 브랜드 색상은 globals.css의 CSS 변수에서 읽어 차트(canvas)에도 동일하게 적용합니다.
|
||||||
const palette = getChartPaletteFromCssVars(activeThemeMode);
|
const palette = getChartPaletteFromCssVars(initialThemeModeRef.current);
|
||||||
chartPaletteRef.current = palette;
|
chartPaletteRef.current = palette;
|
||||||
|
|
||||||
const chart = createChart(container, {
|
const chart = createChart(container, {
|
||||||
@@ -411,7 +338,7 @@ export function StockLineChart({
|
|||||||
volumeSeriesRef.current = null;
|
volumeSeriesRef.current = null;
|
||||||
setIsChartReady(false);
|
setIsChartReady(false);
|
||||||
};
|
};
|
||||||
}, [activeThemeMode]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const chart = chartRef.current;
|
const chart = chartRef.current;
|
||||||
@@ -460,6 +387,7 @@ export function StockLineChart({
|
|||||||
|
|
||||||
initialLoadCompleteRef.current = false;
|
initialLoadCompleteRef.current = false;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
let initialLoadTimer: number | null = null;
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -508,7 +436,7 @@ export function StockLineChart({
|
|||||||
setBars(mergedBars);
|
setBars(mergedBars);
|
||||||
setNextCursor(resolvedNextCursor);
|
setNextCursor(resolvedNextCursor);
|
||||||
|
|
||||||
window.setTimeout(() => {
|
initialLoadTimer = window.setTimeout(() => {
|
||||||
if (!disposed) initialLoadCompleteRef.current = true;
|
if (!disposed) initialLoadCompleteRef.current = true;
|
||||||
}, 350);
|
}, 350);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -531,6 +459,9 @@ export function StockLineChart({
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
if (initialLoadTimer !== null) {
|
||||||
|
window.clearTimeout(initialLoadTimer);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [credentials, symbol, timeframe]);
|
}, [credentials, symbol, timeframe]);
|
||||||
|
|
||||||
@@ -550,7 +481,7 @@ export function StockLineChart({
|
|||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!latestTick) return;
|
if (!latestTick) return;
|
||||||
if (bars.length === 0) return;
|
if (renderableBarsRef.current.length === 0) return;
|
||||||
|
|
||||||
const dedupeKey = `${timeframe}:${latestTick.tickTime}:${latestTick.price}:${latestTick.tradeVolume}`;
|
const dedupeKey = `${timeframe}:${latestTick.tickTime}:${latestTick.price}:${latestTick.tradeVolume}`;
|
||||||
if (lastRealtimeKeyRef.current === dedupeKey) return;
|
if (lastRealtimeKeyRef.current === dedupeKey) return;
|
||||||
@@ -561,7 +492,7 @@ export function StockLineChart({
|
|||||||
lastRealtimeKeyRef.current = dedupeKey;
|
lastRealtimeKeyRef.current = dedupeKey;
|
||||||
lastRealtimeAppliedAtRef.current = Date.now();
|
lastRealtimeAppliedAtRef.current = Date.now();
|
||||||
setBars((prev) => upsertRealtimeBar(prev, realtimeBar));
|
setBars((prev) => upsertRealtimeBar(prev, realtimeBar));
|
||||||
}, [bars.length, latestTick, timeframe]);
|
}, [latestTick, timeframe]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 분봉(1m/30m/1h)에서는 WS 공백 상황을 대비해 최신 페이지를 주기적으로 동기화합니다.
|
* @description 분봉(1m/30m/1h)에서는 WS 공백 상황을 대비해 최신 페이지를 주기적으로 동기화합니다.
|
||||||
@@ -715,25 +646,3 @@ export function StockLineChart({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function areBarsEqual(left: ChartBar[], right: ChartBar[]) {
|
|
||||||
if (left.length !== right.length) return false;
|
|
||||||
|
|
||||||
for (let index = 0; index < left.length; index += 1) {
|
|
||||||
const lhs = left[index];
|
|
||||||
const rhs = right[index];
|
|
||||||
if (!lhs || !rhs) return false;
|
|
||||||
if (
|
|
||||||
lhs.time !== rhs.time ||
|
|
||||||
lhs.open !== rhs.open ||
|
|
||||||
lhs.high !== rhs.high ||
|
|
||||||
lhs.low !== rhs.low ||
|
|
||||||
lhs.close !== rhs.close ||
|
|
||||||
lhs.volume !== rhs.volume
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|||||||
126
features/trade/components/chart/stock-line-chart-meta.ts
Normal file
126
features/trade/components/chart/stock-line-chart-meta.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import type { DashboardChartTimeframe } from "@/features/trade/types/trade.types";
|
||||||
|
import type { ChartBar } from "./chart-utils";
|
||||||
|
|
||||||
|
export const UP_COLOR = "#ef4444";
|
||||||
|
export const MINUTE_SYNC_INTERVAL_MS = 30000;
|
||||||
|
export const REALTIME_STALE_THRESHOLD_MS = 12000;
|
||||||
|
export const CHART_MIN_HEIGHT = 220;
|
||||||
|
|
||||||
|
export interface ChartPalette {
|
||||||
|
backgroundColor: string;
|
||||||
|
downColor: string;
|
||||||
|
volumeDownColor: string;
|
||||||
|
textColor: string;
|
||||||
|
borderColor: string;
|
||||||
|
gridColor: string;
|
||||||
|
crosshairColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_CHART_PALETTE: ChartPalette = {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
downColor: "#2563eb",
|
||||||
|
volumeDownColor: "rgba(37, 99, 235, 0.45)",
|
||||||
|
textColor: "#6d28d9",
|
||||||
|
borderColor: "#e9d5ff",
|
||||||
|
gridColor: "#f3e8ff",
|
||||||
|
crosshairColor: "#c084fc",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MINUTE_TIMEFRAMES: Array<{
|
||||||
|
value: DashboardChartTimeframe;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: "1m", label: "1분" },
|
||||||
|
{ value: "30m", label: "30분" },
|
||||||
|
{ value: "1h", label: "1시간" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PERIOD_TIMEFRAMES: Array<{
|
||||||
|
value: DashboardChartTimeframe;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: "1d", label: "일" },
|
||||||
|
{ value: "1w", label: "주" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 브랜드 CSS 변수에서 차트 팔레트를 읽어옵니다.
|
||||||
|
* @see features/trade/components/chart/StockLineChart.tsx 차트 생성/테마 반영
|
||||||
|
*/
|
||||||
|
export function getChartPaletteFromCssVars(
|
||||||
|
themeMode: "light" | "dark",
|
||||||
|
): ChartPalette {
|
||||||
|
const isDark = themeMode === "dark";
|
||||||
|
const backgroundVar = isDark
|
||||||
|
? "--brand-chart-background-dark"
|
||||||
|
: "--brand-chart-background-light";
|
||||||
|
const textVar = isDark
|
||||||
|
? "--brand-chart-text-dark"
|
||||||
|
: "--brand-chart-text-light";
|
||||||
|
const borderVar = isDark
|
||||||
|
? "--brand-chart-border-dark"
|
||||||
|
: "--brand-chart-border-light";
|
||||||
|
const gridVar = isDark
|
||||||
|
? "--brand-chart-grid-dark"
|
||||||
|
: "--brand-chart-grid-light";
|
||||||
|
const crosshairVar = isDark
|
||||||
|
? "--brand-chart-crosshair-dark"
|
||||||
|
: "--brand-chart-crosshair-light";
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: readCssVar(
|
||||||
|
backgroundVar,
|
||||||
|
DEFAULT_CHART_PALETTE.backgroundColor,
|
||||||
|
),
|
||||||
|
downColor: readCssVar(
|
||||||
|
"--brand-chart-down",
|
||||||
|
DEFAULT_CHART_PALETTE.downColor,
|
||||||
|
),
|
||||||
|
volumeDownColor: readCssVar(
|
||||||
|
"--brand-chart-volume-down",
|
||||||
|
DEFAULT_CHART_PALETTE.volumeDownColor,
|
||||||
|
),
|
||||||
|
textColor: readCssVar(textVar, DEFAULT_CHART_PALETTE.textColor),
|
||||||
|
borderColor: readCssVar(borderVar, DEFAULT_CHART_PALETTE.borderColor),
|
||||||
|
gridColor: readCssVar(gridVar, DEFAULT_CHART_PALETTE.gridColor),
|
||||||
|
crosshairColor: readCssVar(
|
||||||
|
crosshairVar,
|
||||||
|
DEFAULT_CHART_PALETTE.crosshairColor,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 차트 데이터 배열이 동일한지 비교합니다.
|
||||||
|
* @see features/trade/components/chart/StockLineChart.tsx 분봉 동기화 시 불필요한 상태 업데이트 방지
|
||||||
|
*/
|
||||||
|
export function areBarsEqual(left: ChartBar[], right: ChartBar[]) {
|
||||||
|
if (left.length !== right.length) return false;
|
||||||
|
|
||||||
|
for (let index = 0; index < left.length; index += 1) {
|
||||||
|
const lhs = left[index];
|
||||||
|
const rhs = right[index];
|
||||||
|
if (!lhs || !rhs) return false;
|
||||||
|
if (
|
||||||
|
lhs.time !== rhs.time ||
|
||||||
|
lhs.open !== rhs.open ||
|
||||||
|
lhs.high !== rhs.high ||
|
||||||
|
lhs.low !== rhs.low ||
|
||||||
|
lhs.close !== rhs.close ||
|
||||||
|
lhs.volume !== rhs.volume
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCssVar(name: string, fallback: string) {
|
||||||
|
if (typeof window === "undefined") return fallback;
|
||||||
|
const value = window
|
||||||
|
.getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue(name)
|
||||||
|
.trim();
|
||||||
|
return value || fallback;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
DashboardOrderSide,
|
DashboardOrderSide,
|
||||||
DashboardStockItem,
|
DashboardStockItem,
|
||||||
} from "@/features/trade/types/trade.types";
|
} from "@/features/trade/types/trade.types";
|
||||||
|
import { parseKisAccountParts } from "@/lib/kis/account";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface OrderFormProps {
|
interface OrderFormProps {
|
||||||
@@ -60,6 +61,14 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accountParts = parseKisAccountParts(verifiedCredentials.accountNo);
|
||||||
|
if (!accountParts) {
|
||||||
|
alert(
|
||||||
|
"계좌번호 형식이 올바르지 않습니다. 설정에서 8-2 형식(예: 12345678-01)으로 다시 확인해 주세요.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await placeOrder(
|
const response = await placeOrder(
|
||||||
{
|
{
|
||||||
symbol: stock.symbol,
|
symbol: stock.symbol,
|
||||||
@@ -67,8 +76,8 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
|||||||
orderType: "limit",
|
orderType: "limit",
|
||||||
price: priceNum,
|
price: priceNum,
|
||||||
quantity: qtyNum,
|
quantity: qtyNum,
|
||||||
accountNo: verifiedCredentials.accountNo,
|
accountNo: `${accountParts.accountNo}-${accountParts.accountProductCode}`,
|
||||||
accountProductCode: "01",
|
accountProductCode: accountParts.accountProductCode,
|
||||||
},
|
},
|
||||||
verifiedCredentials,
|
verifiedCredentials,
|
||||||
);
|
);
|
||||||
@@ -84,8 +93,17 @@ export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
|||||||
parseInt(quantity.replace(/,/g, "") || "0", 10);
|
parseInt(quantity.replace(/,/g, "") || "0", 10);
|
||||||
|
|
||||||
const setPercent = (pct: string) => {
|
const setPercent = (pct: string) => {
|
||||||
// TODO: 계좌 잔고 연동 시 퍼센트 자동 계산으로 교체
|
const ratio = Number.parseInt(pct.replace("%", ""), 10) / 100;
|
||||||
console.log("Percent clicked:", pct);
|
if (!Number.isFinite(ratio) || ratio <= 0) return;
|
||||||
|
|
||||||
|
// UI 흐름: 비율 버튼 클릭 -> 보유수량 기준 계산(매도 탭) -> 주문수량 입력값 반영
|
||||||
|
if (activeTab === "sell" && matchedHolding?.quantity) {
|
||||||
|
const calculatedQuantity = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(matchedHolding.quantity * ratio),
|
||||||
|
);
|
||||||
|
setQuantity(String(calculatedQuantity));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isMarketDataAvailable = Boolean(stock);
|
const isMarketDataAvailable = Boolean(stock);
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import type {
|
import type {
|
||||||
DashboardRealtimeTradeTick,
|
DashboardRealtimeTradeTick,
|
||||||
DashboardStockOrderBookResponse,
|
DashboardStockOrderBookResponse,
|
||||||
} from "@/features/trade/types/trade.types";
|
} from "@/features/trade/types/trade.types";
|
||||||
import { cn } from "@/lib/utils";
|
import type { BookRow } from "./orderbook-utils";
|
||||||
import { AnimatedQuantity } from "./AnimatedQuantity";
|
import {
|
||||||
|
buildBookRows,
|
||||||
// ─── 타입 ───────────────────────────────────────────────
|
buildFallbackLevelsFromTick,
|
||||||
|
hasOrderBookLevelData,
|
||||||
|
resolveReferencePrice,
|
||||||
|
} from "./orderbook-utils";
|
||||||
|
import {
|
||||||
|
BookHeader,
|
||||||
|
BookSideRows,
|
||||||
|
CumulativeRows,
|
||||||
|
CurrentPriceBar,
|
||||||
|
OrderBookSkeleton,
|
||||||
|
SummaryPanel,
|
||||||
|
TradeTape,
|
||||||
|
} from "./orderbook-sections";
|
||||||
|
|
||||||
interface OrderBookProps {
|
interface OrderBookProps {
|
||||||
symbol?: string;
|
symbol?: string;
|
||||||
@@ -20,228 +31,10 @@ interface OrderBookProps {
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BookRow {
|
|
||||||
price: number;
|
|
||||||
size: number;
|
|
||||||
changeValue: number | null;
|
|
||||||
isHighlighted: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 실시간 호가 레벨 데이터가 실제 값을 포함하는지 검사합니다.
|
* @description 호가창 — 실시간 매도·매수 10호가와 체결 목록을 표시합니다.
|
||||||
* @see features/trade/components/orderbook/OrderBook.tsx levels 계산에서 WS 호가 유효성 판단에 사용합니다.
|
* @see features/trade/components/orderbook/orderbook-utils.ts 호가 계산/포맷 유틸
|
||||||
*/
|
* @see features/trade/components/orderbook/orderbook-sections.tsx 호가/체결/요약 UI 섹션
|
||||||
function hasOrderBookLevelData(
|
|
||||||
levels: DashboardStockOrderBookResponse["levels"],
|
|
||||||
) {
|
|
||||||
return levels.some(
|
|
||||||
(level) =>
|
|
||||||
level.askPrice > 0 ||
|
|
||||||
level.bidPrice > 0 ||
|
|
||||||
level.askSize > 0 ||
|
|
||||||
level.bidSize > 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description H0STOAA0 미수신 상황에서 체결(H0UNCNT0) 데이터로 최소 1호가를 구성합니다.
|
|
||||||
* @see features/trade/utils/kisRealtimeUtils.ts parseKisRealtimeTickBatch 체결 데이터에서 1호가/잔량/총잔량을 파싱합니다.
|
|
||||||
*/
|
|
||||||
function buildFallbackLevelsFromTick(
|
|
||||||
latestTick: DashboardRealtimeTradeTick | null,
|
|
||||||
) {
|
|
||||||
if (!latestTick) return [] as DashboardStockOrderBookResponse["levels"];
|
|
||||||
if (latestTick.askPrice1 <= 0 && latestTick.bidPrice1 <= 0) {
|
|
||||||
return [] as DashboardStockOrderBookResponse["levels"];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
askPrice: latestTick.askPrice1,
|
|
||||||
bidPrice: latestTick.bidPrice1,
|
|
||||||
askSize: Math.max(latestTick.askSize1, 0),
|
|
||||||
bidSize: Math.max(latestTick.bidSize1, 0),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 유틸리티 함수 ──────────────────────────────────────
|
|
||||||
|
|
||||||
/** 천단위 구분 포맷 */
|
|
||||||
function fmt(v: number) {
|
|
||||||
return Number.isFinite(v) ? v.toLocaleString("ko-KR") : "0";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 부호 포함 퍼센트 */
|
|
||||||
function fmtPct(v: number) {
|
|
||||||
return `${v > 0 ? "+" : ""}${v.toFixed(2)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 등락률 계산 */
|
|
||||||
function pctChange(price: number, base: number) {
|
|
||||||
return base > 0 ? ((price - base) / base) * 100 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description 기준가 대비 증감값/증감률을 함께 계산합니다.
|
|
||||||
* @see features/trade/components/orderbook/OrderBook.tsx buildBookRows
|
|
||||||
*/
|
|
||||||
function resolvePriceChange(price: number, basePrice: number) {
|
|
||||||
if (price <= 0 || basePrice <= 0) {
|
|
||||||
return { changeValue: null } as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
const changeValue = price - basePrice;
|
|
||||||
|
|
||||||
return { changeValue } as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description 증감 숫자를 부호 포함 문자열로 포맷합니다.
|
|
||||||
* @see features/trade/components/orderbook/OrderBook.tsx BookSideRows
|
|
||||||
*/
|
|
||||||
function fmtSignedChange(v: number) {
|
|
||||||
if (!Number.isFinite(v)) return "-";
|
|
||||||
if (v > 0) return `+${fmt(v)}`;
|
|
||||||
if (v < 0) return `-${fmt(Math.abs(v))}`;
|
|
||||||
return "0";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description 증감값에 따라 색상 톤 클래스를 반환합니다.
|
|
||||||
* @see features/trade/components/orderbook/OrderBook.tsx BookSideRows
|
|
||||||
*/
|
|
||||||
function getChangeToneClass(
|
|
||||||
changeValue: number | null,
|
|
||||||
neutralClass = "text-muted-foreground",
|
|
||||||
) {
|
|
||||||
if (changeValue === null) {
|
|
||||||
return neutralClass;
|
|
||||||
}
|
|
||||||
if (changeValue > 0) {
|
|
||||||
return "text-red-500";
|
|
||||||
}
|
|
||||||
if (changeValue < 0) {
|
|
||||||
return "text-blue-600 dark:text-blue-400";
|
|
||||||
}
|
|
||||||
return neutralClass;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 체결 시각 포맷 */
|
|
||||||
function fmtTime(hms: string) {
|
|
||||||
if (!hms || hms.length !== 6) return "--:--:--";
|
|
||||||
return `${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description 체결 틱 데이터에서 체결 주도 방향(매수/매도/중립)을 계산합니다.
|
|
||||||
* @see features/trade/components/orderbook/OrderBook.tsx TradeTape 체결량 글자색 결정에 사용합니다.
|
|
||||||
*/
|
|
||||||
function resolveTickExecutionSide(
|
|
||||||
tick: DashboardRealtimeTradeTick,
|
|
||||||
olderTick?: DashboardRealtimeTradeTick,
|
|
||||||
) {
|
|
||||||
// 실시간 체결구분 코드(CNTG_CLS_CODE) 우선 해석
|
|
||||||
const executionClassCode = (tick.executionClassCode ?? "").trim();
|
|
||||||
if (executionClassCode === "1" || executionClassCode === "2") {
|
|
||||||
return "buy" as const;
|
|
||||||
}
|
|
||||||
if (executionClassCode === "4" || executionClassCode === "5") {
|
|
||||||
return "sell" as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 누적 건수 기반 데이터는 절대값이 아니라 "증분"으로 판단해야 편향을 줄일 수 있습니다.
|
|
||||||
if (olderTick) {
|
|
||||||
const netBuyDelta =
|
|
||||||
tick.netBuyExecutionCount - olderTick.netBuyExecutionCount;
|
|
||||||
if (netBuyDelta > 0) return "buy" as const;
|
|
||||||
if (netBuyDelta < 0) return "sell" as const;
|
|
||||||
|
|
||||||
const buyCountDelta = tick.buyExecutionCount - olderTick.buyExecutionCount;
|
|
||||||
const sellCountDelta =
|
|
||||||
tick.sellExecutionCount - olderTick.sellExecutionCount;
|
|
||||||
if (buyCountDelta > sellCountDelta) return "buy" as const;
|
|
||||||
if (buyCountDelta < sellCountDelta) return "sell" as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tick.askPrice1 > 0 && tick.bidPrice1 > 0) {
|
|
||||||
if (tick.price >= tick.askPrice1 && tick.price > tick.bidPrice1) {
|
|
||||||
return "buy" as const;
|
|
||||||
}
|
|
||||||
if (tick.price <= tick.bidPrice1 && tick.price < tick.askPrice1) {
|
|
||||||
return "sell" as const;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tick.tradeStrength > 100) return "buy" as const;
|
|
||||||
if (tick.tradeStrength < 100) return "sell" as const;
|
|
||||||
|
|
||||||
return "neutral" as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description 호가 레벨을 화면 렌더링용 행 모델로 변환합니다.
|
|
||||||
* UI 흐름: OrderBook(levels/basePrice/latestPrice) -> buildBookRows -> BookSideRows -> 가격/증감 반영
|
|
||||||
* @see features/trade/components/orderbook/OrderBook.tsx OrderBook askRows/bidRows 계산
|
|
||||||
*/
|
|
||||||
function buildBookRows({
|
|
||||||
levels,
|
|
||||||
side,
|
|
||||||
basePrice,
|
|
||||||
latestPrice,
|
|
||||||
}: {
|
|
||||||
levels: DashboardStockOrderBookResponse["levels"];
|
|
||||||
side: "ask" | "bid";
|
|
||||||
basePrice: number;
|
|
||||||
latestPrice: number;
|
|
||||||
}) {
|
|
||||||
const normalizedLevels = side === "ask" ? [...levels].reverse() : levels;
|
|
||||||
|
|
||||||
return normalizedLevels.map((level) => {
|
|
||||||
const price = side === "ask" ? level.askPrice : level.bidPrice;
|
|
||||||
const size = side === "ask" ? level.askSize : level.bidSize;
|
|
||||||
const { changeValue } = resolvePriceChange(price, basePrice);
|
|
||||||
|
|
||||||
return {
|
|
||||||
price,
|
|
||||||
size: Math.max(size, 0),
|
|
||||||
changeValue,
|
|
||||||
isHighlighted: latestPrice > 0 && price === latestPrice,
|
|
||||||
} satisfies BookRow;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description 호가/체결 증감 표시용 기준가(전일종가)를 결정합니다.
|
|
||||||
* @summary UI 흐름: OrderBook props -> resolveReferencePrice -> 호가행/체결가 증감 계산 반영
|
|
||||||
* @see features/trade/components/orderbook/OrderBook.tsx basePrice 계산
|
|
||||||
*/
|
|
||||||
function resolveReferencePrice({
|
|
||||||
referencePrice,
|
|
||||||
latestTick,
|
|
||||||
}: {
|
|
||||||
referencePrice?: number;
|
|
||||||
latestTick: DashboardRealtimeTradeTick | null;
|
|
||||||
}) {
|
|
||||||
if ((referencePrice ?? 0) > 0) {
|
|
||||||
return referencePrice!;
|
|
||||||
}
|
|
||||||
|
|
||||||
// referencePrice 미전달 케이스에서도 틱 데이터(price-change)로 전일종가를 역산합니다.
|
|
||||||
if (latestTick?.price && Number.isFinite(latestTick.change)) {
|
|
||||||
const derivedPrevClose = latestTick.price - latestTick.change;
|
|
||||||
if (derivedPrevClose > 0) {
|
|
||||||
return derivedPrevClose;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 메인 컴포넌트 ──────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 호가창 — 실시간 매도·매수 10호가와 체결 목록을 표시합니다.
|
|
||||||
*/
|
*/
|
||||||
export function OrderBook({
|
export function OrderBook({
|
||||||
symbol,
|
symbol,
|
||||||
@@ -256,21 +49,23 @@ export function OrderBook({
|
|||||||
() => buildFallbackLevelsFromTick(latestTick),
|
() => buildFallbackLevelsFromTick(latestTick),
|
||||||
[latestTick],
|
[latestTick],
|
||||||
);
|
);
|
||||||
|
const hasRealtimeLevelData = useMemo(
|
||||||
|
() => hasOrderBookLevelData(realtimeLevels),
|
||||||
|
[realtimeLevels],
|
||||||
|
);
|
||||||
const levels = useMemo(() => {
|
const levels = useMemo(() => {
|
||||||
if (hasOrderBookLevelData(realtimeLevels)) return realtimeLevels;
|
if (hasRealtimeLevelData) return realtimeLevels;
|
||||||
return fallbackLevelsFromTick;
|
return fallbackLevelsFromTick;
|
||||||
}, [fallbackLevelsFromTick, realtimeLevels]);
|
}, [fallbackLevelsFromTick, hasRealtimeLevelData, realtimeLevels]);
|
||||||
const isTickFallbackActive =
|
|
||||||
!hasOrderBookLevelData(realtimeLevels) && fallbackLevelsFromTick.length > 0;
|
const isTickFallbackActive =
|
||||||
|
!hasRealtimeLevelData && fallbackLevelsFromTick.length > 0;
|
||||||
|
|
||||||
// 체결가: tick에서 우선, 없으면 0
|
|
||||||
const latestPrice =
|
const latestPrice =
|
||||||
latestTick?.price && latestTick.price > 0 ? latestTick.price : 0;
|
latestTick?.price && latestTick.price > 0 ? latestTick.price : 0;
|
||||||
|
|
||||||
// 등락률 기준가
|
|
||||||
const basePrice = resolveReferencePrice({ referencePrice, latestTick });
|
const basePrice = resolveReferencePrice({ referencePrice, latestTick });
|
||||||
|
|
||||||
// 매도호가 (역순: 10호가 → 1호가)
|
|
||||||
const askRows: BookRow[] = useMemo(
|
const askRows: BookRow[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildBookRows({
|
buildBookRows({
|
||||||
@@ -282,7 +77,6 @@ export function OrderBook({
|
|||||||
[levels, basePrice, latestPrice],
|
[levels, basePrice, latestPrice],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 매수호가 (1호가 → 10호가)
|
|
||||||
const bidRows: BookRow[] = useMemo(
|
const bidRows: BookRow[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildBookRows({
|
buildBookRows({
|
||||||
@@ -294,31 +88,42 @@ export function OrderBook({
|
|||||||
[levels, basePrice, latestPrice],
|
[levels, basePrice, latestPrice],
|
||||||
);
|
);
|
||||||
|
|
||||||
const askMax = Math.max(1, ...askRows.map((r) => r.size));
|
const askMax = useMemo(() => Math.max(1, ...askRows.map((r) => r.size)), [askRows]);
|
||||||
const bidMax = Math.max(1, ...bidRows.map((r) => r.size));
|
const bidMax = useMemo(() => Math.max(1, ...bidRows.map((r) => r.size)), [bidRows]);
|
||||||
const mobileAskRows = useMemo(() => askRows.slice(0, 6), [askRows]);
|
const mobileAskRows = useMemo(() => askRows.slice(0, 6), [askRows]);
|
||||||
const mobileBidRows = useMemo(() => bidRows.slice(0, 6), [bidRows]);
|
const mobileBidRows = useMemo(() => bidRows.slice(0, 6), [bidRows]);
|
||||||
|
|
||||||
// 스프레드·수급 불균형
|
const { bestAsk, spread, totalAsk, totalBid, imbalance } = useMemo(() => {
|
||||||
const bestAsk = levels.find((l) => l.askPrice > 0)?.askPrice ?? 0;
|
const resolvedBestAsk = levels.find((level) => level.askPrice > 0)?.askPrice ?? 0;
|
||||||
const bestBid = levels.find((l) => l.bidPrice > 0)?.bidPrice ?? 0;
|
const resolvedBestBid = levels.find((level) => level.bidPrice > 0)?.bidPrice ?? 0;
|
||||||
const spread = bestAsk > 0 && bestBid > 0 ? bestAsk - bestBid : 0;
|
const resolvedSpread =
|
||||||
const totalAsk =
|
resolvedBestAsk > 0 && resolvedBestBid > 0
|
||||||
|
? resolvedBestAsk - resolvedBestBid
|
||||||
|
: 0;
|
||||||
|
const resolvedTotalAsk =
|
||||||
orderBook?.totalAskSize && orderBook.totalAskSize > 0
|
orderBook?.totalAskSize && orderBook.totalAskSize > 0
|
||||||
? orderBook.totalAskSize
|
? orderBook.totalAskSize
|
||||||
: (latestTick?.totalAskSize ?? 0);
|
: (latestTick?.totalAskSize ?? 0);
|
||||||
const totalBid =
|
const resolvedTotalBid =
|
||||||
orderBook?.totalBidSize && orderBook.totalBidSize > 0
|
orderBook?.totalBidSize && orderBook.totalBidSize > 0
|
||||||
? orderBook.totalBidSize
|
? orderBook.totalBidSize
|
||||||
: (latestTick?.totalBidSize ?? 0);
|
: (latestTick?.totalBidSize ?? 0);
|
||||||
const imbalance =
|
const resolvedImbalance =
|
||||||
totalAsk + totalBid > 0
|
resolvedTotalAsk + resolvedTotalBid > 0
|
||||||
? ((totalBid - totalAsk) / (totalAsk + totalBid)) * 100
|
? ((resolvedTotalBid - resolvedTotalAsk) /
|
||||||
|
(resolvedTotalAsk + resolvedTotalBid)) *
|
||||||
|
100
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// 체결가 행 중앙 스크롤
|
return {
|
||||||
|
bestAsk: resolvedBestAsk,
|
||||||
|
spread: resolvedSpread,
|
||||||
|
totalAsk: resolvedTotalAsk,
|
||||||
|
totalBid: resolvedTotalBid,
|
||||||
|
imbalance: resolvedImbalance,
|
||||||
|
};
|
||||||
|
}, [latestTick?.totalAskSize, latestTick?.totalBidSize, levels, orderBook]);
|
||||||
|
|
||||||
// ─── 빈/로딩 상태 ───
|
|
||||||
if (!symbol) {
|
if (!symbol) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
@@ -340,7 +145,7 @@ export function OrderBook({
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col bg-white dark:bg-[linear-gradient(180deg,rgba(13,13,24,0.95),rgba(8,8,18,0.98))]">
|
<div className="flex h-full min-h-0 flex-col bg-white dark:bg-[linear-gradient(180deg,rgba(13,13,24,0.95),rgba(8,8,18,0.98))]">
|
||||||
<Tabs defaultValue="normal" className="h-full min-h-0">
|
<Tabs defaultValue="normal" className="h-full min-h-0">
|
||||||
{/* 탭 헤더 */}
|
{/* ========== ORDERBOOK TAB HEADER ========== */}
|
||||||
<div className="border-b border-border/60 bg-muted/15 px-2 pt-2 dark:border-brand-800/50 dark:bg-brand-950/60">
|
<div className="border-b border-border/60 bg-muted/15 px-2 pt-2 dark:border-brand-800/50 dark:bg-brand-950/60">
|
||||||
<TabsList variant="line" className="w-full justify-start">
|
<TabsList variant="line" className="w-full justify-start">
|
||||||
<TabsTrigger value="normal" className="px-3">
|
<TabsTrigger value="normal" className="px-3">
|
||||||
@@ -355,10 +160,9 @@ export function OrderBook({
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 일반호가 탭 ── */}
|
{/* ========== ORDERBOOK NORMAL TAB ========== */}
|
||||||
<TabsContent value="normal" className="min-h-0 flex-1">
|
<TabsContent value="normal" className="min-h-0 flex-1">
|
||||||
<div className="flex h-full min-h-0 flex-col border-t dark:border-brand-800/45 xl:grid xl:grid-cols-[minmax(0,1fr)_220px_220px] 2xl:grid-cols-[minmax(0,1fr)_250px_240px] xl:overflow-hidden">
|
<div className="flex h-full min-h-0 flex-col border-t dark:border-brand-800/45 xl:grid xl:grid-cols-[minmax(0,1fr)_220px_220px] 2xl:grid-cols-[minmax(0,1fr)_250px_240px] xl:overflow-hidden">
|
||||||
{/* 호가 테이블 */}
|
|
||||||
<div className="flex min-h-0 flex-col xl:border-r dark:border-brand-800/45">
|
<div className="flex min-h-0 flex-col xl:border-r dark:border-brand-800/45">
|
||||||
{isTickFallbackActive && (
|
{isTickFallbackActive && (
|
||||||
<div className="border-b border-amber-200 bg-amber-50 px-2 py-1 text-[11px] text-amber-700 dark:border-amber-700/40 dark:bg-amber-900/20 dark:text-amber-200">
|
<div className="border-b border-amber-200 bg-amber-50 px-2 py-1 text-[11px] text-amber-700 dark:border-amber-700/40 dark:bg-amber-900/20 dark:text-amber-200">
|
||||||
@@ -368,7 +172,6 @@ export function OrderBook({
|
|||||||
)}
|
)}
|
||||||
<BookHeader />
|
<BookHeader />
|
||||||
<div className="xl:hidden">
|
<div className="xl:hidden">
|
||||||
{/* 모바일: 양방향 호가가 항상 보이도록 6호가씩 고정 노출 */}
|
|
||||||
<BookSideRows rows={mobileAskRows} side="ask" maxSize={askMax} />
|
<BookSideRows rows={mobileAskRows} side="ask" maxSize={askMax} />
|
||||||
<CurrentPriceBar
|
<CurrentPriceBar
|
||||||
latestPrice={latestPrice}
|
latestPrice={latestPrice}
|
||||||
@@ -380,7 +183,6 @@ export function OrderBook({
|
|||||||
<BookSideRows rows={mobileBidRows} side="bid" maxSize={bidMax} />
|
<BookSideRows rows={mobileBidRows} side="bid" maxSize={bidMax} />
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="hidden min-h-0 flex-1 xl:block">
|
<ScrollArea className="hidden min-h-0 flex-1 xl:block">
|
||||||
{/* 데스크톱: 전체 호가 스크롤 */}
|
|
||||||
<BookSideRows rows={askRows} side="ask" maxSize={askMax} />
|
<BookSideRows rows={askRows} side="ask" maxSize={askMax} />
|
||||||
<CurrentPriceBar
|
<CurrentPriceBar
|
||||||
latestPrice={latestPrice}
|
latestPrice={latestPrice}
|
||||||
@@ -393,14 +195,12 @@ export function OrderBook({
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 체결량 영역 */}
|
|
||||||
<div className="min-h-[180px] border-t border-border/60 dark:border-brand-800/45 xl:min-h-0 xl:border-t-0 xl:border-r">
|
<div className="min-h-[180px] border-t border-border/60 dark:border-brand-800/45 xl:min-h-0 xl:border-t-0 xl:border-r">
|
||||||
<div className="h-full min-h-0">
|
<div className="h-full min-h-0">
|
||||||
<TradeTape ticks={recentTicks} maxRows={10} />
|
<TradeTape ticks={recentTicks} maxRows={10} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 실시간 정보 영역 */}
|
|
||||||
<div className="min-h-[180px] border-t border-border/60 dark:border-brand-800/45 xl:min-h-0 xl:border-t-0">
|
<div className="min-h-[180px] border-t border-border/60 dark:border-brand-800/45 xl:min-h-0 xl:border-t-0">
|
||||||
<div className="h-full min-h-0">
|
<div className="h-full min-h-0">
|
||||||
<SummaryPanel
|
<SummaryPanel
|
||||||
@@ -416,7 +216,7 @@ export function OrderBook({
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ── 누적호가 탭 ── */}
|
{/* ========== ORDERBOOK CUMULATIVE TAB ========== */}
|
||||||
<TabsContent value="cumulative" className="min-h-0 flex-1">
|
<TabsContent value="cumulative" className="min-h-0 flex-1">
|
||||||
<ScrollArea className="h-full border-t dark:border-brand-800/45">
|
<ScrollArea className="h-full border-t dark:border-brand-800/45">
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
@@ -430,7 +230,7 @@ export function OrderBook({
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ── 호가주문 탭 ── */}
|
{/* ========== ORDERBOOK ORDER TAB ========== */}
|
||||||
<TabsContent value="order" className="min-h-0 flex-1">
|
<TabsContent value="order" className="min-h-0 flex-1">
|
||||||
<div className="flex h-full items-center justify-center border-t px-4 text-sm text-muted-foreground dark:border-brand-800/45 dark:text-brand-100/75">
|
<div className="flex h-full items-center justify-center border-t px-4 text-sm text-muted-foreground dark:border-brand-800/45 dark:text-brand-100/75">
|
||||||
호가주문 탭은 주문 입력 패널과 연동해 확장할 수 있습니다.
|
호가주문 탭은 주문 입력 패널과 연동해 확장할 수 있습니다.
|
||||||
@@ -440,454 +240,3 @@ export function OrderBook({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 하위 컴포넌트 ──────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description 호가창 중앙의 현재가/등락률/총잔량 바를 렌더링합니다.
|
|
||||||
* @summary UI 흐름: OrderBook -> CurrentPriceBar -> 매도/매수 구간 사이 현재 체결 상태를 강조 표시
|
|
||||||
* @see features/trade/components/orderbook/OrderBook.tsx 일반호가 탭의 모바일/데스크톱 공통 현재가 행
|
|
||||||
*/
|
|
||||||
function CurrentPriceBar({
|
|
||||||
latestPrice,
|
|
||||||
basePrice,
|
|
||||||
bestAsk,
|
|
||||||
totalAsk,
|
|
||||||
totalBid,
|
|
||||||
}: {
|
|
||||||
latestPrice: number;
|
|
||||||
basePrice: number;
|
|
||||||
bestAsk: number;
|
|
||||||
totalAsk: number;
|
|
||||||
totalBid: number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="grid h-10 grid-cols-3 items-center border-y-2 border-amber-400 bg-linear-to-r from-blue-50/60 via-amber-50/90 to-red-50/60 shadow-[0_0_10px_rgba(251,191,36,0.25)] dark:from-blue-950/30 dark:via-amber-900/30 dark:to-red-950/30 xl:h-10">
|
|
||||||
<div className="px-2 text-right text-[10px] font-semibold text-blue-600 dark:text-blue-400">
|
|
||||||
{totalAsk > 0 ? fmt(totalAsk) : ""}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center justify-center">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-base leading-none font-bold tabular-nums",
|
|
||||||
latestPrice > 0 && basePrice > 0
|
|
||||||
? latestPrice >= basePrice
|
|
||||||
? "text-red-600"
|
|
||||||
: "text-blue-600 dark:text-blue-400"
|
|
||||||
: "text-foreground dark:text-brand-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{latestPrice > 0 ? fmt(latestPrice) : bestAsk > 0 ? fmt(bestAsk) : "-"}
|
|
||||||
</span>
|
|
||||||
{latestPrice > 0 && basePrice > 0 && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-[11px] font-semibold leading-none",
|
|
||||||
latestPrice >= basePrice
|
|
||||||
? "text-red-500"
|
|
||||||
: "text-blue-600 dark:text-blue-400",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{fmtPct(pctChange(latestPrice, basePrice))}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="px-2 text-left text-[10px] font-semibold text-red-600 dark:text-red-400">
|
|
||||||
{totalBid > 0 ? fmt(totalBid) : ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 호가 표 헤더 */
|
|
||||||
function BookHeader() {
|
|
||||||
return (
|
|
||||||
<div className="grid h-8 grid-cols-3 border-b bg-linear-to-r from-blue-50/40 via-muted/20 to-red-50/40 text-[11px] font-semibold text-muted-foreground dark:border-brand-800/50 dark:from-blue-950/30 dark:via-brand-900/40 dark:to-red-950/30 dark:text-brand-100/80">
|
|
||||||
<div className="flex items-center justify-end px-2 text-blue-600/80 dark:text-blue-400/80">
|
|
||||||
매도잔량
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center border-x border-border/50 dark:border-brand-800/40">
|
|
||||||
호가
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-start px-2 text-red-600/80 dark:text-red-400/80">
|
|
||||||
매수잔량
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 매도 또는 매수 호가 행 목록 */
|
|
||||||
function BookSideRows({
|
|
||||||
rows,
|
|
||||||
side,
|
|
||||||
maxSize,
|
|
||||||
}: {
|
|
||||||
rows: BookRow[];
|
|
||||||
side: "ask" | "bid";
|
|
||||||
maxSize: number;
|
|
||||||
}) {
|
|
||||||
const isAsk = side === "ask";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
isAsk
|
|
||||||
? "bg-linear-to-r from-blue-50/40 via-blue-50/10 to-transparent dark:from-blue-950/35 dark:via-blue-950/10 dark:to-transparent"
|
|
||||||
: "bg-linear-to-r from-transparent via-red-50/10 to-red-50/45 dark:from-transparent dark:via-red-950/10 dark:to-red-950/35",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{rows.map((row, i) => {
|
|
||||||
const ratio =
|
|
||||||
maxSize > 0 ? Math.min((row.size / maxSize) * 100, 100) : 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`${side}-${row.price}-${i}`}
|
|
||||||
className={cn(
|
|
||||||
"grid h-[29px] grid-cols-3 border-b border-border/30 text-[11px] transition-colors hover:bg-muted/15 xl:h-[29px] xl:text-xs dark:border-brand-800/25 dark:hover:bg-brand-900/20",
|
|
||||||
row.isHighlighted &&
|
|
||||||
"ring-1 ring-inset ring-amber-400 bg-amber-100/60 dark:bg-amber-800/35",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* 매도잔량 (좌측) */}
|
|
||||||
<div className="relative flex items-center justify-end overflow-hidden px-2">
|
|
||||||
{isAsk && (
|
|
||||||
<>
|
|
||||||
<DepthBar ratio={ratio} side="ask" />
|
|
||||||
{row.size > 0 ? (
|
|
||||||
<AnimatedQuantity
|
|
||||||
value={row.size}
|
|
||||||
format={fmt}
|
|
||||||
useColor
|
|
||||||
side="ask"
|
|
||||||
className="relative z-10"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="relative z-10 text-transparent">0</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 호가 (중앙) */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-center border-x font-medium tabular-nums gap-1",
|
|
||||||
row.isHighlighted &&
|
|
||||||
"border-x-amber-400 bg-amber-50/70 dark:bg-amber-800/20",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-[12px] xl:text-[13px]",
|
|
||||||
isAsk ? "text-blue-600 dark:text-blue-400" : "text-red-600",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{row.price > 0 ? fmt(row.price) : "-"}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"w-[50px] shrink-0 text-right text-[10px] tabular-nums xl:w-[58px]",
|
|
||||||
getChangeToneClass(row.changeValue),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{row.changeValue === null
|
|
||||||
? "-"
|
|
||||||
: fmtSignedChange(row.changeValue)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 매수잔량 (우측) */}
|
|
||||||
<div className="relative flex items-center justify-start overflow-hidden px-1">
|
|
||||||
{!isAsk && (
|
|
||||||
<>
|
|
||||||
<DepthBar ratio={ratio} side="bid" />
|
|
||||||
{row.size > 0 ? (
|
|
||||||
<AnimatedQuantity
|
|
||||||
value={row.size}
|
|
||||||
format={fmt}
|
|
||||||
useColor
|
|
||||||
side="bid"
|
|
||||||
className="relative z-10"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="relative z-10 text-transparent">0</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 우측 요약 패널 */
|
|
||||||
function SummaryPanel({
|
|
||||||
orderBook,
|
|
||||||
latestTick,
|
|
||||||
spread,
|
|
||||||
imbalance,
|
|
||||||
totalAsk,
|
|
||||||
totalBid,
|
|
||||||
}: {
|
|
||||||
orderBook: DashboardStockOrderBookResponse | null;
|
|
||||||
latestTick: DashboardRealtimeTradeTick | null;
|
|
||||||
spread: number;
|
|
||||||
imbalance: number;
|
|
||||||
totalAsk: number;
|
|
||||||
totalBid: number;
|
|
||||||
}) {
|
|
||||||
const displayTradeVolume =
|
|
||||||
latestTick?.isExpected && (orderBook?.anticipatedVolume ?? 0) > 0
|
|
||||||
? (orderBook?.anticipatedVolume ?? 0)
|
|
||||||
: (latestTick?.tradeVolume ?? orderBook?.anticipatedVolume ?? 0);
|
|
||||||
const summaryItems: SummaryMetric[] = [
|
|
||||||
{
|
|
||||||
label: "실시간",
|
|
||||||
value: orderBook || latestTick ? "연결됨" : "끊김",
|
|
||||||
tone: orderBook || latestTick ? "ask" : undefined,
|
|
||||||
},
|
|
||||||
{ label: "거래량", value: fmt(displayTradeVolume) },
|
|
||||||
{
|
|
||||||
label: "누적거래량",
|
|
||||||
value: fmt(
|
|
||||||
latestTick?.accumulatedVolume ?? orderBook?.accumulatedVolume ?? 0,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "체결강도",
|
|
||||||
value: latestTick
|
|
||||||
? `${latestTick.tradeStrength.toFixed(2)}%`
|
|
||||||
: orderBook?.anticipatedChangeRate !== undefined
|
|
||||||
? `${orderBook.anticipatedChangeRate.toFixed(2)}%`
|
|
||||||
: "-",
|
|
||||||
},
|
|
||||||
{ label: "예상체결가", value: fmt(orderBook?.anticipatedPrice ?? 0) },
|
|
||||||
{
|
|
||||||
label: "매도1호가",
|
|
||||||
value: latestTick ? fmt(latestTick.askPrice1) : "-",
|
|
||||||
tone: "ask",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "매수1호가",
|
|
||||||
value: latestTick ? fmt(latestTick.bidPrice1) : "-",
|
|
||||||
tone: "bid",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "순매수체결",
|
|
||||||
value: latestTick ? fmt(latestTick.netBuyExecutionCount) : "-",
|
|
||||||
},
|
|
||||||
{ label: "총 매도잔량", value: fmt(totalAsk), tone: "ask" },
|
|
||||||
{ label: "총 매수잔량", value: fmt(totalBid), tone: "bid" },
|
|
||||||
{ label: "스프레드", value: fmt(spread) },
|
|
||||||
{
|
|
||||||
label: "수급 불균형",
|
|
||||||
value: `${imbalance >= 0 ? "+" : ""}${imbalance.toFixed(2)}%`,
|
|
||||||
tone: imbalance >= 0 ? "bid" : "ask",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full min-w-0 bg-muted/10 p-2 text-xs dark:bg-brand-900/30">
|
|
||||||
<div className="grid grid-cols-2 gap-1 xl:h-full xl:grid-cols-1 xl:grid-rows-12">
|
|
||||||
{summaryItems.map((item) => (
|
|
||||||
<SummaryMetricCell
|
|
||||||
key={item.label}
|
|
||||||
label={item.label}
|
|
||||||
value={item.value}
|
|
||||||
tone={item.tone}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SummaryMetric {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
tone?: "ask" | "bid";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description 실시간 요약 패널의 2열 메트릭 셀을 렌더링합니다.
|
|
||||||
* @summary UI 흐름: SummaryPanel -> SummaryMetricCell -> 체결량 하단 정보 패널을 압축해 스크롤 없이 표시
|
|
||||||
* @see features/trade/components/orderbook/OrderBook.tsx SummaryPanel summaryItems
|
|
||||||
*/
|
|
||||||
function SummaryMetricCell({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
tone,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
tone?: "ask" | "bid";
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full min-w-0 items-center justify-between gap-2 rounded border bg-background px-2 py-1 dark:border-brand-800/45 dark:bg-black/20">
|
|
||||||
<span className="truncate text-[11px] text-muted-foreground dark:text-brand-100/70">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 text-xs font-semibold tabular-nums",
|
|
||||||
tone === "ask" && "text-blue-600 dark:text-blue-400",
|
|
||||||
tone === "bid" && "text-red-600",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 잔량 깊이 바 */
|
|
||||||
function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
|
|
||||||
if (ratio <= 0) return null;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-y-0.5 z-0 rounded-sm transition-[width] duration-150",
|
|
||||||
side === "ask"
|
|
||||||
? "right-0.5 bg-blue-300/55 dark:bg-blue-700/50"
|
|
||||||
: "left-0.5 bg-red-300/60 dark:bg-red-600/45",
|
|
||||||
)}
|
|
||||||
style={{ width: `${ratio}%` }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 체결 목록 (Trade Tape) */
|
|
||||||
function TradeTape({
|
|
||||||
ticks,
|
|
||||||
maxRows,
|
|
||||||
}: {
|
|
||||||
ticks: DashboardRealtimeTradeTick[];
|
|
||||||
maxRows?: number;
|
|
||||||
}) {
|
|
||||||
const visibleTicks = typeof maxRows === "number" ? ticks.slice(0, maxRows) : ticks;
|
|
||||||
const shouldUseScrollableList = typeof maxRows !== "number";
|
|
||||||
|
|
||||||
const tapeRows = (
|
|
||||||
<div>
|
|
||||||
{visibleTicks.length === 0 && (
|
|
||||||
<div className="flex min-h-[96px] items-center justify-center text-xs text-muted-foreground dark:text-brand-100/70 xl:min-h-[140px]">
|
|
||||||
체결 데이터가 아직 없습니다.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{visibleTicks.map((t, i) => {
|
|
||||||
const olderTick = visibleTicks[i + 1];
|
|
||||||
const executionSide = resolveTickExecutionSide(t, olderTick);
|
|
||||||
// UI 흐름: 체결목록 UI -> resolveTickExecutionSide(현재/이전 틱) -> 색상 class 결정 -> 체결량 렌더 반영
|
|
||||||
const volumeToneClass =
|
|
||||||
executionSide === "buy"
|
|
||||||
? "text-red-600"
|
|
||||||
: executionSide === "sell"
|
|
||||||
? "text-blue-600 dark:text-blue-400"
|
|
||||||
: "text-muted-foreground dark:text-brand-100/70";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`${t.tickTime}-${t.price}-${i}`}
|
|
||||||
className="grid h-7 grid-cols-3 border-b border-border/40 px-2 text-[13px] dark:border-brand-800/35"
|
|
||||||
>
|
|
||||||
<div className="flex items-center tabular-nums">
|
|
||||||
{fmtTime(t.tickTime)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-end tabular-nums",
|
|
||||||
getChangeToneClass(
|
|
||||||
t.change,
|
|
||||||
"text-foreground dark:text-brand-50",
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{fmt(t.price)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-end tabular-nums",
|
|
||||||
volumeToneClass,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{fmt(t.tradeVolume)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-0 flex-col bg-background dark:bg-brand-900/20">
|
|
||||||
<div className="grid h-8 grid-cols-3 border-b bg-muted/20 px-2 text-xs font-medium text-muted-foreground dark:border-brand-800/45 dark:bg-brand-900/35 dark:text-brand-100/78">
|
|
||||||
<div className="flex items-center">체결시각</div>
|
|
||||||
<div className="flex items-center justify-end">체결가</div>
|
|
||||||
<div className="flex items-center justify-end">체결량</div>
|
|
||||||
</div>
|
|
||||||
{shouldUseScrollableList ? (
|
|
||||||
<ScrollArea className="min-h-0 flex-1">{tapeRows}</ScrollArea>
|
|
||||||
) : (
|
|
||||||
tapeRows
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 누적호가 행 */
|
|
||||||
function CumulativeRows({ asks, bids }: { asks: BookRow[]; bids: BookRow[] }) {
|
|
||||||
const rows = useMemo(() => {
|
|
||||||
const len = Math.max(asks.length, bids.length);
|
|
||||||
const result: { askAcc: number; bidAcc: number; price: number }[] = [];
|
|
||||||
for (let i = 0; i < len; i++) {
|
|
||||||
const prevAsk = result[i - 1]?.askAcc ?? 0;
|
|
||||||
const prevBid = result[i - 1]?.bidAcc ?? 0;
|
|
||||||
result.push({
|
|
||||||
askAcc: prevAsk + (asks[i]?.size ?? 0),
|
|
||||||
bidAcc: prevBid + (bids[i]?.size ?? 0),
|
|
||||||
price: asks[i]?.price || bids[i]?.price || 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}, [asks, bids]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{rows.map((r, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="grid h-7 grid-cols-3 items-center rounded border bg-background px-2 text-xs dark:border-brand-800/45 dark:bg-black/20"
|
|
||||||
>
|
|
||||||
<span className="tabular-nums text-blue-600 dark:text-blue-400">{fmt(r.askAcc)}</span>
|
|
||||||
<span className="text-center font-medium tabular-nums">
|
|
||||||
{fmt(r.price)}
|
|
||||||
</span>
|
|
||||||
<span className="text-right tabular-nums text-red-600 dark:text-red-400">
|
|
||||||
{fmt(r.bidAcc)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 로딩 스켈레톤 */
|
|
||||||
function OrderBookSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col p-3">
|
|
||||||
<div className="mb-3 grid grid-cols-3 gap-2">
|
|
||||||
<Skeleton className="h-8 w-full" />
|
|
||||||
<Skeleton className="h-8 w-full" />
|
|
||||||
<Skeleton className="h-8 w-full" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{Array.from({ length: 16 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-7 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
473
features/trade/components/orderbook/orderbook-sections.tsx
Normal file
473
features/trade/components/orderbook/orderbook-sections.tsx
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import type {
|
||||||
|
DashboardRealtimeTradeTick,
|
||||||
|
DashboardStockOrderBookResponse,
|
||||||
|
} from "@/features/trade/types/trade.types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { AnimatedQuantity } from "./AnimatedQuantity";
|
||||||
|
import type { BookRow } from "./orderbook-utils";
|
||||||
|
import {
|
||||||
|
fmt,
|
||||||
|
fmtPct,
|
||||||
|
fmtSignedChange,
|
||||||
|
fmtTime,
|
||||||
|
getChangeToneClass,
|
||||||
|
pctChange,
|
||||||
|
resolveTickExecutionSide,
|
||||||
|
} from "./orderbook-utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 호가창 중앙의 현재가/등락률/총잔량 바를 렌더링합니다.
|
||||||
|
* @summary UI 흐름: OrderBook -> CurrentPriceBar -> 매도/매수 구간 사이 현재 체결 상태를 강조 표시
|
||||||
|
* @see features/trade/components/orderbook/OrderBook.tsx 일반호가 탭의 모바일/데스크톱 공통 현재가 행
|
||||||
|
*/
|
||||||
|
export function CurrentPriceBar({
|
||||||
|
latestPrice,
|
||||||
|
basePrice,
|
||||||
|
bestAsk,
|
||||||
|
totalAsk,
|
||||||
|
totalBid,
|
||||||
|
}: {
|
||||||
|
latestPrice: number;
|
||||||
|
basePrice: number;
|
||||||
|
bestAsk: number;
|
||||||
|
totalAsk: number;
|
||||||
|
totalBid: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid h-10 grid-cols-3 items-center border-y-2 border-amber-400 bg-linear-to-r from-blue-50/60 via-amber-50/90 to-red-50/60 shadow-[0_0_10px_rgba(251,191,36,0.25)] dark:from-blue-950/30 dark:via-amber-900/30 dark:to-red-950/30 xl:h-10">
|
||||||
|
<div className="px-2 text-right text-[10px] font-semibold text-blue-600 dark:text-blue-400">
|
||||||
|
{totalAsk > 0 ? fmt(totalAsk) : ""}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-base leading-none font-bold tabular-nums",
|
||||||
|
latestPrice > 0 && basePrice > 0
|
||||||
|
? latestPrice >= basePrice
|
||||||
|
? "text-red-600"
|
||||||
|
: "text-blue-600 dark:text-blue-400"
|
||||||
|
: "text-foreground dark:text-brand-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{latestPrice > 0 ? fmt(latestPrice) : bestAsk > 0 ? fmt(bestAsk) : "-"}
|
||||||
|
</span>
|
||||||
|
{latestPrice > 0 && basePrice > 0 && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[11px] font-semibold leading-none",
|
||||||
|
latestPrice >= basePrice
|
||||||
|
? "text-red-500"
|
||||||
|
: "text-blue-600 dark:text-blue-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{fmtPct(pctChange(latestPrice, basePrice))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-2 text-left text-[10px] font-semibold text-red-600 dark:text-red-400">
|
||||||
|
{totalBid > 0 ? fmt(totalBid) : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 호가 표 헤더 */
|
||||||
|
export function BookHeader() {
|
||||||
|
return (
|
||||||
|
<div className="grid h-8 grid-cols-3 border-b bg-linear-to-r from-blue-50/40 via-muted/20 to-red-50/40 text-[11px] font-semibold text-muted-foreground dark:border-brand-800/50 dark:from-blue-950/30 dark:via-brand-900/40 dark:to-red-950/30 dark:text-brand-100/80">
|
||||||
|
<div className="flex items-center justify-end px-2 text-blue-600/80 dark:text-blue-400/80">
|
||||||
|
매도잔량
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center border-x border-border/50 dark:border-brand-800/40">
|
||||||
|
호가
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-start px-2 text-red-600/80 dark:text-red-400/80">
|
||||||
|
매수잔량
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 매도 또는 매수 호가 행 목록 */
|
||||||
|
export function BookSideRows({
|
||||||
|
rows,
|
||||||
|
side,
|
||||||
|
maxSize,
|
||||||
|
}: {
|
||||||
|
rows: BookRow[];
|
||||||
|
side: "ask" | "bid";
|
||||||
|
maxSize: number;
|
||||||
|
}) {
|
||||||
|
const isAsk = side === "ask";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
isAsk
|
||||||
|
? "bg-linear-to-r from-blue-50/40 via-blue-50/10 to-transparent dark:from-blue-950/35 dark:via-blue-950/10 dark:to-transparent"
|
||||||
|
: "bg-linear-to-r from-transparent via-red-50/10 to-red-50/45 dark:from-transparent dark:via-red-950/10 dark:to-red-950/35",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{rows.map((row, i) => {
|
||||||
|
const ratio =
|
||||||
|
maxSize > 0 ? Math.min((row.size / maxSize) * 100, 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${side}-${row.price}-${i}`}
|
||||||
|
className={cn(
|
||||||
|
"grid h-[29px] grid-cols-3 border-b border-border/30 text-[11px] transition-colors hover:bg-muted/15 xl:h-[29px] xl:text-xs dark:border-brand-800/25 dark:hover:bg-brand-900/20",
|
||||||
|
row.isHighlighted &&
|
||||||
|
"ring-1 ring-inset ring-amber-400 bg-amber-100/60 dark:bg-amber-800/35",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-end overflow-hidden px-2">
|
||||||
|
{isAsk && (
|
||||||
|
<>
|
||||||
|
<DepthBar ratio={ratio} side="ask" />
|
||||||
|
{row.size > 0 ? (
|
||||||
|
<AnimatedQuantity
|
||||||
|
value={row.size}
|
||||||
|
format={fmt}
|
||||||
|
useColor
|
||||||
|
side="ask"
|
||||||
|
className="relative z-10"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="relative z-10 text-transparent">0</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center border-x font-medium tabular-nums gap-1",
|
||||||
|
row.isHighlighted &&
|
||||||
|
"border-x-amber-400 bg-amber-50/70 dark:bg-amber-800/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[12px] xl:text-[13px]",
|
||||||
|
isAsk ? "text-blue-600 dark:text-blue-400" : "text-red-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.price > 0 ? fmt(row.price) : "-"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"w-[50px] shrink-0 text-right text-[10px] tabular-nums xl:w-[58px]",
|
||||||
|
getChangeToneClass(row.changeValue),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.changeValue === null
|
||||||
|
? "-"
|
||||||
|
: fmtSignedChange(row.changeValue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex items-center justify-start overflow-hidden px-1">
|
||||||
|
{!isAsk && (
|
||||||
|
<>
|
||||||
|
<DepthBar ratio={ratio} side="bid" />
|
||||||
|
{row.size > 0 ? (
|
||||||
|
<AnimatedQuantity
|
||||||
|
value={row.size}
|
||||||
|
format={fmt}
|
||||||
|
useColor
|
||||||
|
side="bid"
|
||||||
|
className="relative z-10"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="relative z-10 text-transparent">0</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 우측 요약 패널 */
|
||||||
|
export function SummaryPanel({
|
||||||
|
orderBook,
|
||||||
|
latestTick,
|
||||||
|
spread,
|
||||||
|
imbalance,
|
||||||
|
totalAsk,
|
||||||
|
totalBid,
|
||||||
|
}: {
|
||||||
|
orderBook: DashboardStockOrderBookResponse | null;
|
||||||
|
latestTick: DashboardRealtimeTradeTick | null;
|
||||||
|
spread: number;
|
||||||
|
imbalance: number;
|
||||||
|
totalAsk: number;
|
||||||
|
totalBid: number;
|
||||||
|
}) {
|
||||||
|
const displayTradeVolume =
|
||||||
|
latestTick?.isExpected && (orderBook?.anticipatedVolume ?? 0) > 0
|
||||||
|
? (orderBook?.anticipatedVolume ?? 0)
|
||||||
|
: (latestTick?.tradeVolume ?? orderBook?.anticipatedVolume ?? 0);
|
||||||
|
const summaryItems: SummaryMetric[] = [
|
||||||
|
{
|
||||||
|
label: "실시간",
|
||||||
|
value: orderBook || latestTick ? "연결됨" : "끊김",
|
||||||
|
tone: orderBook || latestTick ? "ask" : undefined,
|
||||||
|
},
|
||||||
|
{ label: "거래량", value: fmt(displayTradeVolume) },
|
||||||
|
{
|
||||||
|
label: "누적거래량",
|
||||||
|
value: fmt(
|
||||||
|
latestTick?.accumulatedVolume ?? orderBook?.accumulatedVolume ?? 0,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "체결강도",
|
||||||
|
value: latestTick
|
||||||
|
? `${latestTick.tradeStrength.toFixed(2)}%`
|
||||||
|
: orderBook?.anticipatedChangeRate !== undefined
|
||||||
|
? `${orderBook.anticipatedChangeRate.toFixed(2)}%`
|
||||||
|
: "-",
|
||||||
|
},
|
||||||
|
{ label: "예상체결가", value: fmt(orderBook?.anticipatedPrice ?? 0) },
|
||||||
|
{
|
||||||
|
label: "매도1호가",
|
||||||
|
value: latestTick ? fmt(latestTick.askPrice1) : "-",
|
||||||
|
tone: "ask",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "매수1호가",
|
||||||
|
value: latestTick ? fmt(latestTick.bidPrice1) : "-",
|
||||||
|
tone: "bid",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "순매수체결",
|
||||||
|
value: latestTick ? fmt(latestTick.netBuyExecutionCount) : "-",
|
||||||
|
},
|
||||||
|
{ label: "총 매도잔량", value: fmt(totalAsk), tone: "ask" },
|
||||||
|
{ label: "총 매수잔량", value: fmt(totalBid), tone: "bid" },
|
||||||
|
{ label: "스프레드", value: fmt(spread) },
|
||||||
|
{
|
||||||
|
label: "수급 불균형",
|
||||||
|
value: `${imbalance >= 0 ? "+" : ""}${imbalance.toFixed(2)}%`,
|
||||||
|
tone: imbalance >= 0 ? "bid" : "ask",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full min-w-0 bg-muted/10 p-2 text-xs dark:bg-brand-900/30">
|
||||||
|
<div className="grid grid-cols-2 gap-1 xl:h-full xl:grid-cols-1 xl:grid-rows-12">
|
||||||
|
{summaryItems.map((item) => (
|
||||||
|
<SummaryMetricCell
|
||||||
|
key={item.label}
|
||||||
|
label={item.label}
|
||||||
|
value={item.value}
|
||||||
|
tone={item.tone}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 체결 목록 (Trade Tape) */
|
||||||
|
export function TradeTape({
|
||||||
|
ticks,
|
||||||
|
maxRows,
|
||||||
|
}: {
|
||||||
|
ticks: DashboardRealtimeTradeTick[];
|
||||||
|
maxRows?: number;
|
||||||
|
}) {
|
||||||
|
const visibleTicks =
|
||||||
|
typeof maxRows === "number" ? ticks.slice(0, maxRows) : ticks;
|
||||||
|
const shouldUseScrollableList = typeof maxRows !== "number";
|
||||||
|
|
||||||
|
const tapeRows = (
|
||||||
|
<div>
|
||||||
|
{visibleTicks.length === 0 && (
|
||||||
|
<div className="flex min-h-[96px] items-center justify-center text-xs text-muted-foreground dark:text-brand-100/70 xl:min-h-[140px]">
|
||||||
|
체결 데이터가 아직 없습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{visibleTicks.map((t, i) => {
|
||||||
|
const olderTick = visibleTicks[i + 1];
|
||||||
|
const executionSide = resolveTickExecutionSide(t, olderTick);
|
||||||
|
const volumeToneClass =
|
||||||
|
executionSide === "buy"
|
||||||
|
? "text-red-600"
|
||||||
|
: executionSide === "sell"
|
||||||
|
? "text-blue-600 dark:text-blue-400"
|
||||||
|
: "text-muted-foreground dark:text-brand-100/70";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${t.tickTime}-${t.price}-${i}`}
|
||||||
|
className="grid h-7 grid-cols-3 border-b border-border/40 px-2 text-[13px] dark:border-brand-800/35"
|
||||||
|
>
|
||||||
|
<div className="flex items-center tabular-nums">
|
||||||
|
{fmtTime(t.tickTime)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-end tabular-nums",
|
||||||
|
getChangeToneClass(
|
||||||
|
t.change,
|
||||||
|
"text-foreground dark:text-brand-50",
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{fmt(t.price)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-end tabular-nums",
|
||||||
|
volumeToneClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{fmt(t.tradeVolume)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-col bg-background dark:bg-brand-900/20">
|
||||||
|
<div className="grid h-8 grid-cols-3 border-b bg-muted/20 px-2 text-xs font-medium text-muted-foreground dark:border-brand-800/45 dark:bg-brand-900/35 dark:text-brand-100/78">
|
||||||
|
<div className="flex items-center">체결시각</div>
|
||||||
|
<div className="flex items-center justify-end">체결가</div>
|
||||||
|
<div className="flex items-center justify-end">체결량</div>
|
||||||
|
</div>
|
||||||
|
{shouldUseScrollableList ? (
|
||||||
|
<ScrollArea className="min-h-0 flex-1">{tapeRows}</ScrollArea>
|
||||||
|
) : (
|
||||||
|
tapeRows
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 누적호가 행 */
|
||||||
|
export function CumulativeRows({
|
||||||
|
asks,
|
||||||
|
bids,
|
||||||
|
}: {
|
||||||
|
asks: BookRow[];
|
||||||
|
bids: BookRow[];
|
||||||
|
}) {
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const len = Math.max(asks.length, bids.length);
|
||||||
|
const result: { askAcc: number; bidAcc: number; price: number }[] = [];
|
||||||
|
for (let i = 0; i < len; i += 1) {
|
||||||
|
const prevAsk = result[i - 1]?.askAcc ?? 0;
|
||||||
|
const prevBid = result[i - 1]?.bidAcc ?? 0;
|
||||||
|
result.push({
|
||||||
|
askAcc: prevAsk + (asks[i]?.size ?? 0),
|
||||||
|
bidAcc: prevBid + (bids[i]?.size ?? 0),
|
||||||
|
price: asks[i]?.price || bids[i]?.price || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [asks, bids]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="grid h-7 grid-cols-3 items-center rounded border bg-background px-2 text-xs dark:border-brand-800/45 dark:bg-black/20"
|
||||||
|
>
|
||||||
|
<span className="tabular-nums text-blue-600 dark:text-blue-400">
|
||||||
|
{fmt(r.askAcc)}
|
||||||
|
</span>
|
||||||
|
<span className="text-center font-medium tabular-nums">
|
||||||
|
{fmt(r.price)}
|
||||||
|
</span>
|
||||||
|
<span className="text-right tabular-nums text-red-600 dark:text-red-400">
|
||||||
|
{fmt(r.bidAcc)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 로딩 스켈레톤 */
|
||||||
|
export function OrderBookSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col p-3">
|
||||||
|
<div className="mb-3 grid grid-cols-3 gap-2">
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 16 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-7 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SummaryMetric {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone?: "ask" | "bid";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 실시간 요약 패널의 2열 메트릭 셀을 렌더링합니다.
|
||||||
|
* @summary UI 흐름: SummaryPanel -> SummaryMetricCell -> 체결량 하단 정보 패널을 압축해 스크롤 없이 표시
|
||||||
|
* @see features/trade/components/orderbook/orderbook-sections.tsx SummaryPanel summaryItems
|
||||||
|
*/
|
||||||
|
function SummaryMetricCell({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone?: "ask" | "bid";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-w-0 items-center justify-between gap-2 rounded border bg-background px-2 py-1 dark:border-brand-800/45 dark:bg-black/20">
|
||||||
|
<span className="truncate text-[11px] text-muted-foreground dark:text-brand-100/70">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 text-xs font-semibold tabular-nums",
|
||||||
|
tone === "ask" && "text-blue-600 dark:text-blue-400",
|
||||||
|
tone === "bid" && "text-red-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 잔량 깊이 바 */
|
||||||
|
function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
|
||||||
|
if (ratio <= 0) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-y-0.5 z-0 rounded-sm transition-[width] duration-150",
|
||||||
|
side === "ask"
|
||||||
|
? "right-0.5 bg-blue-300/55 dark:bg-blue-700/50"
|
||||||
|
: "left-0.5 bg-red-300/60 dark:bg-red-600/45",
|
||||||
|
)}
|
||||||
|
style={{ width: `${ratio}%` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
210
features/trade/components/orderbook/orderbook-utils.ts
Normal file
210
features/trade/components/orderbook/orderbook-utils.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import type {
|
||||||
|
DashboardRealtimeTradeTick,
|
||||||
|
DashboardStockOrderBookResponse,
|
||||||
|
} from "@/features/trade/types/trade.types";
|
||||||
|
|
||||||
|
type OrderBookLevels = DashboardStockOrderBookResponse["levels"];
|
||||||
|
|
||||||
|
export interface BookRow {
|
||||||
|
price: number;
|
||||||
|
size: number;
|
||||||
|
changeValue: number | null;
|
||||||
|
isHighlighted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 실시간 호가 레벨 데이터가 실제 값을 포함하는지 검사합니다.
|
||||||
|
* @see features/trade/components/orderbook/OrderBook.tsx levels 계산에서 WS 호가 유효성 판단에 사용합니다.
|
||||||
|
*/
|
||||||
|
export function hasOrderBookLevelData(levels: OrderBookLevels) {
|
||||||
|
return levels.some(
|
||||||
|
(level) =>
|
||||||
|
level.askPrice > 0 ||
|
||||||
|
level.bidPrice > 0 ||
|
||||||
|
level.askSize > 0 ||
|
||||||
|
level.bidSize > 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description H0STOAA0 미수신 상황에서 체결(H0UNCNT0) 데이터로 최소 1호가를 구성합니다.
|
||||||
|
* @see features/trade/utils/kisRealtimeUtils.ts parseKisRealtimeTickBatch 체결 데이터에서 1호가/잔량/총잔량을 파싱합니다.
|
||||||
|
*/
|
||||||
|
export function buildFallbackLevelsFromTick(
|
||||||
|
latestTick: DashboardRealtimeTradeTick | null,
|
||||||
|
) {
|
||||||
|
if (!latestTick) return [] as OrderBookLevels;
|
||||||
|
if (latestTick.askPrice1 <= 0 && latestTick.bidPrice1 <= 0) {
|
||||||
|
return [] as OrderBookLevels;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
askPrice: latestTick.askPrice1,
|
||||||
|
bidPrice: latestTick.bidPrice1,
|
||||||
|
askSize: Math.max(latestTick.askSize1, 0),
|
||||||
|
bidSize: Math.max(latestTick.bidSize1, 0),
|
||||||
|
},
|
||||||
|
] satisfies OrderBookLevels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 천단위 구분 포맷 */
|
||||||
|
export function fmt(v: number) {
|
||||||
|
return Number.isFinite(v) ? v.toLocaleString("ko-KR") : "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 부호 포함 퍼센트 */
|
||||||
|
export function fmtPct(v: number) {
|
||||||
|
return `${v > 0 ? "+" : ""}${v.toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 등락률 계산 */
|
||||||
|
export function pctChange(price: number, base: number) {
|
||||||
|
return base > 0 ? ((price - base) / base) * 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 증감 숫자를 부호 포함 문자열로 포맷합니다.
|
||||||
|
* @see features/trade/components/orderbook/orderbook-sections.tsx BookSideRows
|
||||||
|
*/
|
||||||
|
export function fmtSignedChange(v: number) {
|
||||||
|
if (!Number.isFinite(v)) return "-";
|
||||||
|
if (v > 0) return `+${fmt(v)}`;
|
||||||
|
if (v < 0) return `-${fmt(Math.abs(v))}`;
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 증감값에 따라 색상 톤 클래스를 반환합니다.
|
||||||
|
* @see features/trade/components/orderbook/orderbook-sections.tsx BookSideRows
|
||||||
|
*/
|
||||||
|
export function getChangeToneClass(
|
||||||
|
changeValue: number | null,
|
||||||
|
neutralClass = "text-muted-foreground",
|
||||||
|
) {
|
||||||
|
if (changeValue === null) {
|
||||||
|
return neutralClass;
|
||||||
|
}
|
||||||
|
if (changeValue > 0) {
|
||||||
|
return "text-red-500";
|
||||||
|
}
|
||||||
|
if (changeValue < 0) {
|
||||||
|
return "text-blue-600 dark:text-blue-400";
|
||||||
|
}
|
||||||
|
return neutralClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 체결 시각 포맷 */
|
||||||
|
export function fmtTime(hms: string) {
|
||||||
|
if (!hms || hms.length !== 6) return "--:--:--";
|
||||||
|
return `${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 체결 틱 데이터에서 체결 주도 방향(매수/매도/중립)을 계산합니다.
|
||||||
|
* @see features/trade/components/orderbook/orderbook-sections.tsx TradeTape 체결량 글자색 결정에 사용합니다.
|
||||||
|
*/
|
||||||
|
export function resolveTickExecutionSide(
|
||||||
|
tick: DashboardRealtimeTradeTick,
|
||||||
|
olderTick?: DashboardRealtimeTradeTick,
|
||||||
|
) {
|
||||||
|
const executionClassCode = (tick.executionClassCode ?? "").trim();
|
||||||
|
if (executionClassCode === "1" || executionClassCode === "2") {
|
||||||
|
return "buy" as const;
|
||||||
|
}
|
||||||
|
if (executionClassCode === "4" || executionClassCode === "5") {
|
||||||
|
return "sell" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (olderTick) {
|
||||||
|
const netBuyDelta =
|
||||||
|
tick.netBuyExecutionCount - olderTick.netBuyExecutionCount;
|
||||||
|
if (netBuyDelta > 0) return "buy" as const;
|
||||||
|
if (netBuyDelta < 0) return "sell" as const;
|
||||||
|
|
||||||
|
const buyCountDelta = tick.buyExecutionCount - olderTick.buyExecutionCount;
|
||||||
|
const sellCountDelta =
|
||||||
|
tick.sellExecutionCount - olderTick.sellExecutionCount;
|
||||||
|
if (buyCountDelta > sellCountDelta) return "buy" as const;
|
||||||
|
if (buyCountDelta < sellCountDelta) return "sell" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tick.askPrice1 > 0 && tick.bidPrice1 > 0) {
|
||||||
|
if (tick.price >= tick.askPrice1 && tick.price > tick.bidPrice1) {
|
||||||
|
return "buy" as const;
|
||||||
|
}
|
||||||
|
if (tick.price <= tick.bidPrice1 && tick.price < tick.askPrice1) {
|
||||||
|
return "sell" as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tick.tradeStrength > 100) return "buy" as const;
|
||||||
|
if (tick.tradeStrength < 100) return "sell" as const;
|
||||||
|
|
||||||
|
return "neutral" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 호가 레벨을 화면 렌더링용 행 모델로 변환합니다.
|
||||||
|
* @summary UI 흐름: OrderBook(levels/basePrice/latestPrice) -> buildBookRows -> BookSideRows -> 가격/증감 반영
|
||||||
|
* @see features/trade/components/orderbook/OrderBook.tsx OrderBook askRows/bidRows 계산
|
||||||
|
*/
|
||||||
|
export function buildBookRows({
|
||||||
|
levels,
|
||||||
|
side,
|
||||||
|
basePrice,
|
||||||
|
latestPrice,
|
||||||
|
}: {
|
||||||
|
levels: OrderBookLevels;
|
||||||
|
side: "ask" | "bid";
|
||||||
|
basePrice: number;
|
||||||
|
latestPrice: number;
|
||||||
|
}) {
|
||||||
|
const normalizedLevels = side === "ask" ? [...levels].reverse() : levels;
|
||||||
|
|
||||||
|
return normalizedLevels.map((level) => {
|
||||||
|
const price = side === "ask" ? level.askPrice : level.bidPrice;
|
||||||
|
const size = side === "ask" ? level.askSize : level.bidSize;
|
||||||
|
const changeValue = resolvePriceChange(price, basePrice);
|
||||||
|
|
||||||
|
return {
|
||||||
|
price,
|
||||||
|
size: Math.max(size, 0),
|
||||||
|
changeValue,
|
||||||
|
isHighlighted: latestPrice > 0 && price === latestPrice,
|
||||||
|
} satisfies BookRow;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 호가/체결 증감 표시용 기준가(전일종가)를 결정합니다.
|
||||||
|
* @summary UI 흐름: OrderBook props -> resolveReferencePrice -> 호가행/체결가 증감 계산 반영
|
||||||
|
* @see features/trade/components/orderbook/OrderBook.tsx basePrice 계산
|
||||||
|
*/
|
||||||
|
export function resolveReferencePrice({
|
||||||
|
referencePrice,
|
||||||
|
latestTick,
|
||||||
|
}: {
|
||||||
|
referencePrice?: number;
|
||||||
|
latestTick: DashboardRealtimeTradeTick | null;
|
||||||
|
}) {
|
||||||
|
if ((referencePrice ?? 0) > 0) {
|
||||||
|
return referencePrice!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestTick?.price && Number.isFinite(latestTick.change)) {
|
||||||
|
const derivedPrevClose = latestTick.price - latestTick.change;
|
||||||
|
if (derivedPrevClose > 0) {
|
||||||
|
return derivedPrevClose;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePriceChange(price: number, basePrice: number) {
|
||||||
|
if (price <= 0 || basePrice <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return price - basePrice;
|
||||||
|
}
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file features/trade/data/mock-stocks.ts
|
|
||||||
* @description 대시보드 1단계 UI 검증용 목업 종목 데이터
|
|
||||||
* @remarks
|
|
||||||
* - 한국투자증권 API 연동 전까지 화면 동작 검증에 사용합니다.
|
|
||||||
* - 2단계 이후 실제 화면은 app/api/kis/* 응답을 사용합니다.
|
|
||||||
* - 현재는 레거시/비교용 샘플 데이터로만 남겨둔 상태입니다.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { DashboardStockItem } from "@/features/trade/types/trade.types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 대시보드 목업 종목 목록
|
|
||||||
* @see app/(main)/dashboard/page.tsx DashboardPage가 DashboardMain을 통해 이 데이터를 조회합니다.
|
|
||||||
* @see features/trade/components/dashboard-main.tsx 검색/차트/지표 카드의 기본 데이터 소스입니다.
|
|
||||||
*/
|
|
||||||
export const MOCK_STOCKS: DashboardStockItem[] = [
|
|
||||||
{
|
|
||||||
symbol: "005930",
|
|
||||||
name: "삼성전자",
|
|
||||||
market: "KOSPI",
|
|
||||||
currentPrice: 78500,
|
|
||||||
change: 1200,
|
|
||||||
changeRate: 1.55,
|
|
||||||
open: 77300,
|
|
||||||
high: 78900,
|
|
||||||
low: 77000,
|
|
||||||
prevClose: 77300,
|
|
||||||
volume: 15234012,
|
|
||||||
candles: [
|
|
||||||
{ time: "09:00", price: 74400 },
|
|
||||||
{ time: "09:10", price: 74650 },
|
|
||||||
{ time: "09:20", price: 75100 },
|
|
||||||
{ time: "09:30", price: 74950 },
|
|
||||||
{ time: "09:40", price: 75300 },
|
|
||||||
{ time: "09:50", price: 75600 },
|
|
||||||
{ time: "10:00", price: 75400 },
|
|
||||||
{ time: "10:10", price: 75850 },
|
|
||||||
{ time: "10:20", price: 76100 },
|
|
||||||
{ time: "10:30", price: 75950 },
|
|
||||||
{ time: "10:40", price: 76350 },
|
|
||||||
{ time: "10:50", price: 76700 },
|
|
||||||
{ time: "11:00", price: 76900 },
|
|
||||||
{ time: "11:10", price: 77250 },
|
|
||||||
{ time: "11:20", price: 77100 },
|
|
||||||
{ time: "11:30", price: 77400 },
|
|
||||||
{ time: "11:40", price: 77700 },
|
|
||||||
{ time: "11:50", price: 78150 },
|
|
||||||
{ time: "12:00", price: 77900 },
|
|
||||||
{ time: "12:10", price: 78300 },
|
|
||||||
{ time: "12:20", price: 78500 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
symbol: "000660",
|
|
||||||
name: "SK하이닉스",
|
|
||||||
market: "KOSPI",
|
|
||||||
currentPrice: 214500,
|
|
||||||
change: -1500,
|
|
||||||
changeRate: -0.69,
|
|
||||||
open: 216000,
|
|
||||||
high: 218000,
|
|
||||||
low: 213000,
|
|
||||||
prevClose: 216000,
|
|
||||||
volume: 3210450,
|
|
||||||
candles: [
|
|
||||||
{ time: "09:00", price: 221000 },
|
|
||||||
{ time: "09:10", price: 220400 },
|
|
||||||
{ time: "09:20", price: 219900 },
|
|
||||||
{ time: "09:30", price: 220200 },
|
|
||||||
{ time: "09:40", price: 219300 },
|
|
||||||
{ time: "09:50", price: 218500 },
|
|
||||||
{ time: "10:00", price: 217900 },
|
|
||||||
{ time: "10:10", price: 218300 },
|
|
||||||
{ time: "10:20", price: 217600 },
|
|
||||||
{ time: "10:30", price: 216900 },
|
|
||||||
{ time: "10:40", price: 216500 },
|
|
||||||
{ time: "10:50", price: 216800 },
|
|
||||||
{ time: "11:00", price: 215900 },
|
|
||||||
{ time: "11:10", price: 215300 },
|
|
||||||
{ time: "11:20", price: 214800 },
|
|
||||||
{ time: "11:30", price: 215100 },
|
|
||||||
{ time: "11:40", price: 214200 },
|
|
||||||
{ time: "11:50", price: 214700 },
|
|
||||||
{ time: "12:00", price: 214300 },
|
|
||||||
{ time: "12:10", price: 214600 },
|
|
||||||
{ time: "12:20", price: 214500 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
symbol: "035420",
|
|
||||||
name: "NAVER",
|
|
||||||
market: "KOSPI",
|
|
||||||
currentPrice: 197800,
|
|
||||||
change: 2200,
|
|
||||||
changeRate: 1.12,
|
|
||||||
open: 195500,
|
|
||||||
high: 198600,
|
|
||||||
low: 194900,
|
|
||||||
prevClose: 195600,
|
|
||||||
volume: 1904123,
|
|
||||||
candles: [
|
|
||||||
{ time: "09:00", price: 191800 },
|
|
||||||
{ time: "09:10", price: 192400 },
|
|
||||||
{ time: "09:20", price: 193000 },
|
|
||||||
{ time: "09:30", price: 192700 },
|
|
||||||
{ time: "09:40", price: 193600 },
|
|
||||||
{ time: "09:50", price: 194200 },
|
|
||||||
{ time: "10:00", price: 194000 },
|
|
||||||
{ time: "10:10", price: 194900 },
|
|
||||||
{ time: "10:20", price: 195100 },
|
|
||||||
{ time: "10:30", price: 194700 },
|
|
||||||
{ time: "10:40", price: 195800 },
|
|
||||||
{ time: "10:50", price: 196400 },
|
|
||||||
{ time: "11:00", price: 196100 },
|
|
||||||
{ time: "11:10", price: 196900 },
|
|
||||||
{ time: "11:20", price: 197200 },
|
|
||||||
{ time: "11:30", price: 197000 },
|
|
||||||
{ time: "11:40", price: 197600 },
|
|
||||||
{ time: "11:50", price: 198000 },
|
|
||||||
{ time: "12:00", price: 197400 },
|
|
||||||
{ time: "12:10", price: 198300 },
|
|
||||||
{ time: "12:20", price: 197800 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,10 +1,48 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
|
import { z } from "zod";
|
||||||
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||||
import type {
|
import type {
|
||||||
DashboardStockCashOrderRequest,
|
DashboardStockCashOrderRequest,
|
||||||
DashboardStockCashOrderResponse,
|
DashboardStockCashOrderResponse,
|
||||||
} from "@/features/trade/types/trade.types";
|
} from "@/features/trade/types/trade.types";
|
||||||
import { fetchOrderCash } from "@/features/trade/apis/kis-stock.api";
|
import { fetchOrderCash } from "@/features/trade/apis/kis-stock.api";
|
||||||
|
import { parseKisAccountParts } from "@/lib/kis/account";
|
||||||
|
|
||||||
|
const placeOrderRequestSchema = z
|
||||||
|
.object({
|
||||||
|
symbol: z.string().trim().regex(/^\d{6}$/),
|
||||||
|
side: z.enum(["buy", "sell"]),
|
||||||
|
orderType: z.enum(["limit", "market"]),
|
||||||
|
quantity: z.number().int().positive(),
|
||||||
|
price: z.number(),
|
||||||
|
accountNo: z.string().trim().min(1),
|
||||||
|
accountProductCode: z.string().trim().optional(),
|
||||||
|
})
|
||||||
|
.superRefine((request, ctx) => {
|
||||||
|
if (request.orderType === "limit" && request.price <= 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["price"],
|
||||||
|
message: "지정가 주문은 가격이 0보다 커야 합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.orderType === "market" && request.price < 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["price"],
|
||||||
|
message: "시장가 주문은 가격이 0 이상이어야 합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parseKisAccountParts(request.accountNo, request.accountProductCode)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["accountNo"],
|
||||||
|
message: "계좌번호 형식이 올바르지 않습니다. (8-2)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export function useOrder() {
|
export function useOrder() {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -28,6 +66,15 @@ export function useOrder() {
|
|||||||
setResult(null);
|
setResult(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const validationResult = placeOrderRequestSchema.safeParse(request);
|
||||||
|
if (!validationResult.success) {
|
||||||
|
setError(
|
||||||
|
validationResult.error.issues[0]?.message ??
|
||||||
|
"주문 요청 값이 올바르지 않습니다.",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const data = await fetchOrderCash(request, credentials);
|
const data = await fetchOrderCash(request, credentials);
|
||||||
setResult(data);
|
setResult(data);
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -177,8 +177,17 @@ export interface DashboardStockCashOrderRequest {
|
|||||||
orderType: DashboardOrderType;
|
orderType: DashboardOrderType;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
price: number;
|
price: number;
|
||||||
|
/**
|
||||||
|
* KIS 계좌번호(권장: 8-2, 예: 12345678-01)
|
||||||
|
* @see lib/kis/account.ts parseKisAccountParts 서버 주문 라우트에서 8-2 파싱에 사용합니다.
|
||||||
|
*/
|
||||||
accountNo: string;
|
accountNo: string;
|
||||||
accountProductCode: string;
|
/**
|
||||||
|
* 계좌상품코드(2자리, 선택)
|
||||||
|
* @description accountNo가 8-2 형식이면 서버에서 자동 파싱합니다.
|
||||||
|
* @see app/api/kis/domestic/order-cash/route.ts 주문 요청 검증/계좌 파싱
|
||||||
|
*/
|
||||||
|
accountProductCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { createClient } from "@/utils/supabase/client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [사용자 정보 조회 쿼리]
|
|
||||||
*
|
|
||||||
* 현재 로그인한 사용자의 정보를 조회합니다.
|
|
||||||
* - 자동 캐싱 및 재검증
|
|
||||||
* - 로딩/에러 상태 자동 관리
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* import { useUserQuery } from '@/hooks/queries/use-user-query';
|
|
||||||
*
|
|
||||||
* function Profile() {
|
|
||||||
* const { data: user, isLoading, error } = useUserQuery();
|
|
||||||
*
|
|
||||||
* if (isLoading) return <div>Loading...</div>;
|
|
||||||
* if (error) return <div>Error: {error.message}</div>;
|
|
||||||
* if (!user) return <div>Not logged in</div>;
|
|
||||||
*
|
|
||||||
* return <div>Welcome, {user.email}</div>;
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function useUserQuery() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["user"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const supabase = createClient();
|
|
||||||
const {
|
|
||||||
data: { user },
|
|
||||||
error,
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
return user;
|
|
||||||
},
|
|
||||||
staleTime: 5 * 60 * 1000, // 5분 - 사용자 정보는 자주 변경되지 않음
|
|
||||||
retry: 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
import { buildKisErrorDetail } from "@/lib/kis/error-codes";
|
||||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||||
import { getKisConfig, getKisWebSocketUrl } from "@/lib/kis/config";
|
import { getKisConfig, getKisWebSocketUrl } from "@/lib/kis/config";
|
||||||
|
|
||||||
@@ -60,9 +61,11 @@ async function issueKisApprovalKey(
|
|||||||
const payload = tryParseApprovalResponse(rawText);
|
const payload = tryParseApprovalResponse(rawText);
|
||||||
|
|
||||||
if (!response.ok || !payload.approval_key) {
|
if (!response.ok || !payload.approval_key) {
|
||||||
const detail = [payload.msg1, payload.error_description, payload.error, payload.msg_cd]
|
const detail = buildKisErrorDetail({
|
||||||
.filter(Boolean)
|
message: payload.msg1,
|
||||||
.join(" / ");
|
msgCode: payload.msg_cd,
|
||||||
|
extraMessages: [payload.error_description, payload.error],
|
||||||
|
});
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
detail
|
detail
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||||
import { getKisConfig } from "@/lib/kis/config";
|
import { getKisConfig } from "@/lib/kis/config";
|
||||||
|
import { buildKisErrorDetail } from "@/lib/kis/error-codes";
|
||||||
import { getKisAccessToken } from "@/lib/kis/token";
|
import { getKisAccessToken } from "@/lib/kis/token";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,7 +58,11 @@ export async function kisGet<TOutput>(
|
|||||||
const payload = tryParseKisEnvelope<TOutput>(rawText);
|
const payload = tryParseKisEnvelope<TOutput>(rawText);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const detail = payload.msg1 || rawText.slice(0, 200);
|
const detail = buildKisErrorDetail({
|
||||||
|
message: payload.msg1,
|
||||||
|
msgCode: payload.msg_cd,
|
||||||
|
extraMessages: payload.msg1 ? [] : [rawText.slice(0, 200)],
|
||||||
|
});
|
||||||
throw new Error(
|
throw new Error(
|
||||||
detail
|
detail
|
||||||
? `KIS API 요청 실패 (${response.status}): ${detail}`
|
? `KIS API 요청 실패 (${response.status}): ${detail}`
|
||||||
@@ -66,7 +71,10 @@ export async function kisGet<TOutput>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (payload.rt_cd && payload.rt_cd !== "0") {
|
if (payload.rt_cd && payload.rt_cd !== "0") {
|
||||||
const detail = [payload.msg1, payload.msg_cd].filter(Boolean).join(" / ");
|
const detail = buildKisErrorDetail({
|
||||||
|
message: payload.msg1,
|
||||||
|
msgCode: payload.msg_cd,
|
||||||
|
});
|
||||||
throw new Error(detail || "KIS API 비즈니스 오류가 발생했습니다.");
|
throw new Error(detail || "KIS API 비즈니스 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +120,11 @@ export async function kisPost<TOutput>(
|
|||||||
const payload = tryParseKisEnvelope<TOutput>(rawText);
|
const payload = tryParseKisEnvelope<TOutput>(rawText);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const detail = payload.msg1 || rawText.slice(0, 200);
|
const detail = buildKisErrorDetail({
|
||||||
|
message: payload.msg1,
|
||||||
|
msgCode: payload.msg_cd,
|
||||||
|
extraMessages: payload.msg1 ? [] : [rawText.slice(0, 200)],
|
||||||
|
});
|
||||||
throw new Error(
|
throw new Error(
|
||||||
detail
|
detail
|
||||||
? `KIS API 요청 실패 (${response.status}): ${detail}`
|
? `KIS API 요청 실패 (${response.status}): ${detail}`
|
||||||
@@ -121,7 +133,10 @@ export async function kisPost<TOutput>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (payload.rt_cd && payload.rt_cd !== "0") {
|
if (payload.rt_cd && payload.rt_cd !== "0") {
|
||||||
const detail = [payload.msg1, payload.msg_cd].filter(Boolean).join(" / ");
|
const detail = buildKisErrorDetail({
|
||||||
|
message: payload.msg1,
|
||||||
|
msgCode: payload.msg_cd,
|
||||||
|
});
|
||||||
throw new Error(detail || "KIS API 비즈니스 오류가 발생했습니다.");
|
throw new Error(detail || "KIS API 비즈니스 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
269
lib/kis/dashboard-helpers.ts
Normal file
269
lib/kis/dashboard-helpers.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
/**
|
||||||
|
* @file lib/kis/dashboard-helpers.ts
|
||||||
|
* @description 대시보드 계산/포맷 공통 헬퍼 모음
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 기준일 기준 N일 범위의 YYYYMMDD 기간을 반환합니다.
|
||||||
|
* @see lib/kis/dashboard.ts getDomesticOrderHistory/getDomesticTradeJournal 조회 기간 계산
|
||||||
|
*/
|
||||||
|
export function getLookbackRangeYmd(lookbackDays: number) {
|
||||||
|
const end = new Date();
|
||||||
|
const start = new Date(end);
|
||||||
|
start.setDate(end.getDate() - lookbackDays);
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate: formatYmd(start),
|
||||||
|
endDate: formatYmd(end),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Date를 YYYYMMDD 문자열로 변환합니다.
|
||||||
|
* @see lib/kis/dashboard-helpers.ts getLookbackRangeYmd
|
||||||
|
*/
|
||||||
|
export function formatYmd(date: Date) {
|
||||||
|
const year = String(date.getFullYear());
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${year}${month}${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 문자열에서 숫자만 추출합니다.
|
||||||
|
* @see lib/kis/dashboard.ts 주문일자/매매일자/시각 정규화
|
||||||
|
*/
|
||||||
|
export function toDigits(value?: string) {
|
||||||
|
return (value ?? "").replace(/\D/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 주문 시각을 HHMMSS로 정규화합니다.
|
||||||
|
* @see lib/kis/dashboard.ts getDomesticOrderHistory 정렬/표시 시각 생성
|
||||||
|
*/
|
||||||
|
export function normalizeTimeDigits(value?: string) {
|
||||||
|
const digits = toDigits(value);
|
||||||
|
if (!digits) return "000000";
|
||||||
|
return digits.padEnd(6, "0").slice(0, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description YYYYMMDD를 YYYY-MM-DD로 변환합니다.
|
||||||
|
* @see lib/kis/dashboard.ts 주문내역/매매일지 날짜 컬럼 표시
|
||||||
|
*/
|
||||||
|
export function formatDateLabel(value: string) {
|
||||||
|
if (value.length !== 8) return "-";
|
||||||
|
return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description HHMMSS를 HH:MM:SS로 변환합니다.
|
||||||
|
* @see lib/kis/dashboard.ts 주문내역 시각 컬럼 표시
|
||||||
|
*/
|
||||||
|
export function formatTimeLabel(value: string) {
|
||||||
|
if (value.length !== 6) return "-";
|
||||||
|
return `${value.slice(0, 2)}:${value.slice(2, 4)}:${value.slice(4, 6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description KIS 매수/매도 코드를 공통 side 값으로 변환합니다.
|
||||||
|
* @see lib/kis/dashboard.ts getDomesticOrderHistory/getDomesticTradeJournal
|
||||||
|
*/
|
||||||
|
export function parseTradeSide(
|
||||||
|
code?: string,
|
||||||
|
name?: string,
|
||||||
|
): "buy" | "sell" | "unknown" {
|
||||||
|
const normalizedCode = (code ?? "").trim();
|
||||||
|
const normalizedName = (name ?? "").trim();
|
||||||
|
|
||||||
|
if (normalizedCode === "01") return "sell";
|
||||||
|
if (normalizedCode === "02") return "buy";
|
||||||
|
if (normalizedName.includes("매도")) return "sell";
|
||||||
|
if (normalizedName.includes("매수")) return "buy";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 매매일지 요약 기본값을 반환합니다.
|
||||||
|
* @see lib/kis/dashboard.ts getDomesticDashboardActivity 매매일지 실패 폴백
|
||||||
|
*/
|
||||||
|
export function createEmptyJournalSummary() {
|
||||||
|
return {
|
||||||
|
totalRealizedProfit: 0,
|
||||||
|
totalRealizedRate: 0,
|
||||||
|
totalBuyAmount: 0,
|
||||||
|
totalSellAmount: 0,
|
||||||
|
totalFee: 0,
|
||||||
|
totalTax: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 문자열 숫자를 number로 변환합니다.
|
||||||
|
* @see lib/kis/dashboard.ts 잔고/지수 필드 공통 파싱
|
||||||
|
*/
|
||||||
|
export function toNumber(value?: string) {
|
||||||
|
if (!value) return 0;
|
||||||
|
const normalized = value.replaceAll(",", "").trim();
|
||||||
|
if (!normalized) return 0;
|
||||||
|
const parsed = Number(normalized);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 문자열 숫자를 number로 변환하되 0도 유효값으로 유지합니다.
|
||||||
|
* @see lib/kis/dashboard.ts 요약값 폴백 순서 계산
|
||||||
|
*/
|
||||||
|
export function toOptionalNumber(value?: string) {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const normalized = value.replaceAll(",", "").trim();
|
||||||
|
if (!normalized) return undefined;
|
||||||
|
const parsed = Number(normalized);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description output 계열 데이터를 배열 형태로 변환합니다.
|
||||||
|
* @see lib/kis/dashboard.ts 잔고 output1/output2 파싱
|
||||||
|
*/
|
||||||
|
export function parseRows<T>(value: unknown): T[] {
|
||||||
|
if (Array.isArray(value)) return value as T[];
|
||||||
|
if (value && typeof value === "object") return [value as T];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description output 계열 데이터의 첫 행을 반환합니다.
|
||||||
|
* @see lib/kis/dashboard.ts 잔고 요약(output2) 파싱
|
||||||
|
*/
|
||||||
|
export function parseFirstRow<T>(value: unknown) {
|
||||||
|
const rows = parseRows<T>(value);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 지수 output을 단일 레코드로 정규화합니다.
|
||||||
|
* @see lib/kis/dashboard.ts getDomesticDashboardIndices
|
||||||
|
*/
|
||||||
|
export function parseIndexRow<T extends object>(
|
||||||
|
output: unknown,
|
||||||
|
): T {
|
||||||
|
if (Array.isArray(output) && output[0] && typeof output[0] === "object") {
|
||||||
|
return output[0] as T;
|
||||||
|
}
|
||||||
|
if (output && typeof output === "object") {
|
||||||
|
return output as T;
|
||||||
|
}
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description KIS 부호 코드(1/2 상승, 4/5 하락)를 실제 부호로 반영합니다.
|
||||||
|
* @see lib/kis/dashboard.ts getDomesticDashboardIndices
|
||||||
|
*/
|
||||||
|
export function normalizeSignedValue(value: number, signCode?: string) {
|
||||||
|
const abs = Math.abs(value);
|
||||||
|
if (signCode === "4" || signCode === "5") return -abs;
|
||||||
|
if (signCode === "1" || signCode === "2") return abs;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description undefined가 아닌 첫 값을 반환합니다.
|
||||||
|
* @see lib/kis/dashboard.ts 요약 수익률/손익 폴백 계산
|
||||||
|
*/
|
||||||
|
export function firstDefinedNumber(...values: Array<number | undefined>) {
|
||||||
|
return values.find((value) => value !== undefined) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 숫자 배열 합계를 계산합니다.
|
||||||
|
* @see lib/kis/dashboard.ts 보유종목 합계 계산
|
||||||
|
*/
|
||||||
|
export function sumNumbers(values: number[]) {
|
||||||
|
return values.reduce((total, value) => total + value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 총자산 대비 손익률을 계산합니다.
|
||||||
|
* @see lib/kis/dashboard.ts 요약 수익률 폴백 계산
|
||||||
|
*/
|
||||||
|
export function calcProfitRate(profit: number, totalAmount: number) {
|
||||||
|
if (totalAmount <= 0) return 0;
|
||||||
|
const baseAmount = totalAmount - profit;
|
||||||
|
if (baseAmount <= 0) return 0;
|
||||||
|
return (profit / baseAmount) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 매입금액 대비 손익률을 계산합니다.
|
||||||
|
* @see lib/kis/dashboard.ts getDomesticDashboardBalance 현재 손익률 산출
|
||||||
|
*/
|
||||||
|
export function calcProfitRateByPurchase(profit: number, purchaseAmount: number) {
|
||||||
|
if (purchaseAmount <= 0) return 0;
|
||||||
|
return (profit / purchaseAmount) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 총자산과 평가금액 기준으로 일관된 현금성 자산(예수금)을 계산합니다.
|
||||||
|
* @remarks UI 흐름: 대시보드 API 응답 생성 -> 총자산/평가금/예수금 카드 반영
|
||||||
|
* @see lib/kis/dashboard.ts getDomesticDashboardBalance
|
||||||
|
*/
|
||||||
|
export function resolveCashBalance(params: {
|
||||||
|
apiReportedTotalAmount: number;
|
||||||
|
apiReportedNetAssetAmount: number;
|
||||||
|
evaluationAmount: number;
|
||||||
|
cashCandidates: Array<number | undefined>;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
apiReportedTotalAmount,
|
||||||
|
apiReportedNetAssetAmount,
|
||||||
|
evaluationAmount,
|
||||||
|
cashCandidates,
|
||||||
|
} = params;
|
||||||
|
const referenceTotalAmount = pickPreferredAmount(
|
||||||
|
apiReportedNetAssetAmount,
|
||||||
|
apiReportedTotalAmount,
|
||||||
|
);
|
||||||
|
const candidateCash = pickPreferredAmount(...cashCandidates);
|
||||||
|
const derivedCash =
|
||||||
|
referenceTotalAmount > 0
|
||||||
|
? Math.max(referenceTotalAmount - evaluationAmount, 0)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (derivedCash === undefined) return candidateCash;
|
||||||
|
|
||||||
|
const recomposedWithCandidate = candidateCash + evaluationAmount;
|
||||||
|
const mismatchWithApi = Math.abs(
|
||||||
|
recomposedWithCandidate - referenceTotalAmount,
|
||||||
|
);
|
||||||
|
if (mismatchWithApi >= 1) {
|
||||||
|
return derivedCash;
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidateCash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 금액 후보 중 양수 값을 우선 선택합니다.
|
||||||
|
* @see lib/kis/dashboard.ts getDomesticDashboardBalance 금액 필드 폴백 계산
|
||||||
|
*/
|
||||||
|
export function pickPreferredAmount(...values: Array<number | undefined>) {
|
||||||
|
const positive = values.find(
|
||||||
|
(value): value is number => value !== undefined && value > 0,
|
||||||
|
);
|
||||||
|
if (positive !== undefined) return positive;
|
||||||
|
return firstDefinedNumber(...values);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 숫자 후보 중 0이 아닌 값을 우선 선택합니다.
|
||||||
|
* @see lib/kis/dashboard.ts getDomesticDashboardBalance 손익/수익률 폴백 계산
|
||||||
|
*/
|
||||||
|
export function pickNonZeroNumber(...values: Array<number | undefined>) {
|
||||||
|
const nonZero = values.find(
|
||||||
|
(value): value is number => value !== undefined && value !== 0,
|
||||||
|
);
|
||||||
|
if (nonZero !== undefined) return nonZero;
|
||||||
|
return firstDefinedNumber(...values);
|
||||||
|
}
|
||||||
@@ -8,6 +8,28 @@ import { kisGet } from "@/lib/kis/client";
|
|||||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||||
import { normalizeTradingEnv } from "@/lib/kis/config";
|
import { normalizeTradingEnv } from "@/lib/kis/config";
|
||||||
import type { KisAccountParts } from "@/lib/kis/account";
|
import type { KisAccountParts } from "@/lib/kis/account";
|
||||||
|
import {
|
||||||
|
calcProfitRate,
|
||||||
|
calcProfitRateByPurchase,
|
||||||
|
createEmptyJournalSummary,
|
||||||
|
firstDefinedNumber,
|
||||||
|
formatDateLabel,
|
||||||
|
formatTimeLabel,
|
||||||
|
getLookbackRangeYmd,
|
||||||
|
normalizeSignedValue,
|
||||||
|
normalizeTimeDigits,
|
||||||
|
parseFirstRow,
|
||||||
|
parseIndexRow,
|
||||||
|
parseRows,
|
||||||
|
parseTradeSide,
|
||||||
|
pickNonZeroNumber,
|
||||||
|
pickPreferredAmount,
|
||||||
|
resolveCashBalance,
|
||||||
|
sumNumbers,
|
||||||
|
toDigits,
|
||||||
|
toNumber,
|
||||||
|
toOptionalNumber,
|
||||||
|
} from "@/lib/kis/dashboard-helpers";
|
||||||
|
|
||||||
interface KisBalanceOutput1Row {
|
interface KisBalanceOutput1Row {
|
||||||
pdno?: string;
|
pdno?: string;
|
||||||
@@ -478,7 +500,7 @@ export async function getDomesticDashboardIndices(
|
|||||||
credentials,
|
credentials,
|
||||||
);
|
);
|
||||||
|
|
||||||
const row = parseIndexRow(response.output);
|
const row = parseIndexRow<KisIndexOutputRow>(response.output);
|
||||||
const rawChange = toNumber(row.bstp_nmix_prdy_vrss);
|
const rawChange = toNumber(row.bstp_nmix_prdy_vrss);
|
||||||
const rawChangeRate = toNumber(row.bstp_nmix_prdy_ctrt);
|
const rawChangeRate = toNumber(row.bstp_nmix_prdy_ctrt);
|
||||||
|
|
||||||
@@ -780,309 +802,3 @@ async function getDomesticTradeJournal(
|
|||||||
summary,
|
summary,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 기준일 기준 N일 범위의 YYYYMMDD 기간을 반환합니다.
|
|
||||||
* @param lookbackDays 과거 조회 일수
|
|
||||||
* @returns 시작/종료 일자
|
|
||||||
* @see lib/kis/dashboard.ts getDomesticOrderHistory/getDomesticTradeJournal 조회 기간 계산
|
|
||||||
*/
|
|
||||||
function getLookbackRangeYmd(lookbackDays: number) {
|
|
||||||
const end = new Date();
|
|
||||||
const start = new Date(end);
|
|
||||||
start.setDate(end.getDate() - lookbackDays);
|
|
||||||
|
|
||||||
return {
|
|
||||||
startDate: formatYmd(start),
|
|
||||||
endDate: formatYmd(end),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Date를 YYYYMMDD 문자열로 변환합니다.
|
|
||||||
* @param date 기준 일자
|
|
||||||
* @returns YYYYMMDD
|
|
||||||
* @see lib/kis/dashboard.ts getLookbackRangeYmd
|
|
||||||
*/
|
|
||||||
function formatYmd(date: Date) {
|
|
||||||
const year = String(date.getFullYear());
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(date.getDate()).padStart(2, "0");
|
|
||||||
return `${year}${month}${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 문자열에서 숫자만 추출합니다.
|
|
||||||
* @param value 원본 문자열
|
|
||||||
* @returns 숫자 문자열
|
|
||||||
* @see lib/kis/dashboard.ts 주문일자/매매일자/시각 정규화
|
|
||||||
*/
|
|
||||||
function toDigits(value?: string) {
|
|
||||||
return (value ?? "").replace(/\D/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 주문 시각을 HHMMSS로 정규화합니다.
|
|
||||||
* @param value 시각 문자열
|
|
||||||
* @returns 6자리 시각 문자열
|
|
||||||
* @see lib/kis/dashboard.ts getDomesticOrderHistory 정렬/표시 시각 생성
|
|
||||||
*/
|
|
||||||
function normalizeTimeDigits(value?: string) {
|
|
||||||
const digits = toDigits(value);
|
|
||||||
if (!digits) return "000000";
|
|
||||||
return digits.padEnd(6, "0").slice(0, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* YYYYMMDD를 YYYY-MM-DD로 변환합니다.
|
|
||||||
* @param value 날짜 문자열
|
|
||||||
* @returns YYYY-MM-DD 또는 "-"
|
|
||||||
* @see lib/kis/dashboard.ts 주문내역/매매일지 날짜 컬럼 표시
|
|
||||||
*/
|
|
||||||
function formatDateLabel(value: string) {
|
|
||||||
if (value.length !== 8) return "-";
|
|
||||||
return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HHMMSS를 HH:MM:SS로 변환합니다.
|
|
||||||
* @param value 시각 문자열
|
|
||||||
* @returns HH:MM:SS 또는 "-"
|
|
||||||
* @see lib/kis/dashboard.ts 주문내역 시각 컬럼 표시
|
|
||||||
*/
|
|
||||||
function formatTimeLabel(value: string) {
|
|
||||||
if (value.length !== 6) return "-";
|
|
||||||
return `${value.slice(0, 2)}:${value.slice(2, 4)}:${value.slice(4, 6)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* KIS 매수/매도 코드를 공통 side 값으로 변환합니다.
|
|
||||||
* @param code 매수매도구분코드
|
|
||||||
* @param name 매수매도구분명 또는 매매구분명
|
|
||||||
* @returns buy/sell/unknown
|
|
||||||
* @see lib/kis/dashboard.ts getDomesticOrderHistory/getDomesticTradeJournal
|
|
||||||
*/
|
|
||||||
function parseTradeSide(code?: string, name?: string): "buy" | "sell" | "unknown" {
|
|
||||||
const normalizedCode = (code ?? "").trim();
|
|
||||||
const normalizedName = (name ?? "").trim();
|
|
||||||
|
|
||||||
if (normalizedCode === "01") return "sell";
|
|
||||||
if (normalizedCode === "02") return "buy";
|
|
||||||
if (normalizedName.includes("매도")) return "sell";
|
|
||||||
if (normalizedName.includes("매수")) return "buy";
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 매매일지 요약 기본값을 반환합니다.
|
|
||||||
* @returns 0으로 채운 요약 객체
|
|
||||||
* @see lib/kis/dashboard.ts getDomesticDashboardActivity 매매일지 실패 폴백
|
|
||||||
*/
|
|
||||||
function createEmptyJournalSummary(): DomesticTradeJournalSummary {
|
|
||||||
return {
|
|
||||||
totalRealizedProfit: 0,
|
|
||||||
totalRealizedRate: 0,
|
|
||||||
totalBuyAmount: 0,
|
|
||||||
totalSellAmount: 0,
|
|
||||||
totalFee: 0,
|
|
||||||
totalTax: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 문자열 숫자를 number로 변환합니다.
|
|
||||||
* @param value KIS 숫자 문자열
|
|
||||||
* @returns 파싱된 숫자(실패 시 0)
|
|
||||||
* @see lib/kis/dashboard.ts 잔고/지수 필드 공통 파싱
|
|
||||||
*/
|
|
||||||
function toNumber(value?: string) {
|
|
||||||
if (!value) return 0;
|
|
||||||
const normalized = value.replaceAll(",", "").trim();
|
|
||||||
if (!normalized) return 0;
|
|
||||||
const parsed = Number(normalized);
|
|
||||||
return Number.isFinite(parsed) ? parsed : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 문자열 숫자를 number로 변환하되 0도 유효값으로 유지합니다.
|
|
||||||
* @param value KIS 숫자 문자열
|
|
||||||
* @returns 파싱된 숫자 또는 undefined
|
|
||||||
* @see lib/kis/dashboard.ts 요약값 폴백 순서 계산
|
|
||||||
*/
|
|
||||||
function toOptionalNumber(value?: string) {
|
|
||||||
if (!value) return undefined;
|
|
||||||
const normalized = value.replaceAll(",", "").trim();
|
|
||||||
if (!normalized) return undefined;
|
|
||||||
const parsed = Number(normalized);
|
|
||||||
return Number.isFinite(parsed) ? parsed : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* output 계열 데이터를 배열 형태로 변환합니다.
|
|
||||||
* @param value KIS output 값
|
|
||||||
* @returns 레코드 배열
|
|
||||||
* @see lib/kis/dashboard.ts 잔고 output1/output2 파싱
|
|
||||||
*/
|
|
||||||
function parseRows<T>(value: unknown): T[] {
|
|
||||||
if (Array.isArray(value)) return value as T[];
|
|
||||||
if (value && typeof value === "object") return [value as T];
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* output 계열 데이터의 첫 행을 반환합니다.
|
|
||||||
* @param value KIS output 값
|
|
||||||
* @returns 첫 번째 레코드
|
|
||||||
* @see lib/kis/dashboard.ts 잔고 요약(output2) 파싱
|
|
||||||
*/
|
|
||||||
function parseFirstRow<T>(value: unknown) {
|
|
||||||
const rows = parseRows<T>(value);
|
|
||||||
return rows[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 지수 output을 단일 레코드로 정규화합니다.
|
|
||||||
* @param output KIS output
|
|
||||||
* @returns 지수 레코드
|
|
||||||
* @see lib/kis/dashboard.ts getDomesticDashboardIndices
|
|
||||||
*/
|
|
||||||
function parseIndexRow(output: unknown): KisIndexOutputRow {
|
|
||||||
if (Array.isArray(output) && output[0] && typeof output[0] === "object") {
|
|
||||||
return output[0] as KisIndexOutputRow;
|
|
||||||
}
|
|
||||||
if (output && typeof output === "object") {
|
|
||||||
return output as KisIndexOutputRow;
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* KIS 부호 코드(1/2 상승, 4/5 하락)를 실제 부호로 반영합니다.
|
|
||||||
* @param value 변동값
|
|
||||||
* @param signCode 부호 코드
|
|
||||||
* @returns 부호 적용 숫자
|
|
||||||
* @see lib/kis/dashboard.ts getDomesticDashboardIndices
|
|
||||||
*/
|
|
||||||
function normalizeSignedValue(value: number, signCode?: string) {
|
|
||||||
const abs = Math.abs(value);
|
|
||||||
if (signCode === "4" || signCode === "5") return -abs;
|
|
||||||
if (signCode === "1" || signCode === "2") return abs;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* undefined가 아닌 첫 값을 반환합니다.
|
|
||||||
* @param values 후보 숫자 목록
|
|
||||||
* @returns 첫 번째 유효값, 없으면 0
|
|
||||||
* @see lib/kis/dashboard.ts 요약 수익률/손익 폴백 계산
|
|
||||||
*/
|
|
||||||
function firstDefinedNumber(...values: Array<number | undefined>) {
|
|
||||||
return values.find((value) => value !== undefined) ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 숫자 배열 합계를 계산합니다.
|
|
||||||
* @param values 숫자 배열
|
|
||||||
* @returns 합계
|
|
||||||
* @see lib/kis/dashboard.ts 보유종목 합계 계산
|
|
||||||
*/
|
|
||||||
function sumNumbers(values: number[]) {
|
|
||||||
return values.reduce((total, value) => total + value, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 총자산 대비 손익률을 계산합니다.
|
|
||||||
* @param profit 손익 금액
|
|
||||||
* @param totalAmount 총자산 금액
|
|
||||||
* @returns 손익률(%)
|
|
||||||
* @see lib/kis/dashboard.ts 요약 수익률 폴백 계산
|
|
||||||
*/
|
|
||||||
function calcProfitRate(profit: number, totalAmount: number) {
|
|
||||||
if (totalAmount <= 0) return 0;
|
|
||||||
const baseAmount = totalAmount - profit;
|
|
||||||
if (baseAmount <= 0) return 0;
|
|
||||||
return (profit / baseAmount) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 매입금액 대비 손익률을 계산합니다.
|
|
||||||
* @param profit 손익 금액
|
|
||||||
* @param purchaseAmount 매입금액
|
|
||||||
* @returns 손익률(%)
|
|
||||||
* @see lib/kis/dashboard.ts getDomesticDashboardBalance 현재 손익률 산출
|
|
||||||
*/
|
|
||||||
function calcProfitRateByPurchase(profit: number, purchaseAmount: number) {
|
|
||||||
if (purchaseAmount <= 0) return 0;
|
|
||||||
return (profit / purchaseAmount) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 총자산과 평가금액 기준으로 일관된 현금성 자산(예수금)을 계산합니다.
|
|
||||||
* @param params 계산 파라미터
|
|
||||||
* @returns 현금성 자산 금액
|
|
||||||
* @remarks UI 흐름: 대시보드 API 응답 생성 -> 총자산/평가금/예수금 카드 반영
|
|
||||||
* @see lib/kis/dashboard.ts getDomesticDashboardBalance
|
|
||||||
*/
|
|
||||||
function resolveCashBalance(params: {
|
|
||||||
apiReportedTotalAmount: number;
|
|
||||||
apiReportedNetAssetAmount: number;
|
|
||||||
evaluationAmount: number;
|
|
||||||
cashCandidates: Array<number | undefined>;
|
|
||||||
}) {
|
|
||||||
const {
|
|
||||||
apiReportedTotalAmount,
|
|
||||||
apiReportedNetAssetAmount,
|
|
||||||
evaluationAmount,
|
|
||||||
cashCandidates,
|
|
||||||
} = params;
|
|
||||||
const referenceTotalAmount = pickPreferredAmount(
|
|
||||||
apiReportedNetAssetAmount,
|
|
||||||
apiReportedTotalAmount,
|
|
||||||
);
|
|
||||||
const candidateCash = pickPreferredAmount(...cashCandidates);
|
|
||||||
const derivedCash =
|
|
||||||
referenceTotalAmount > 0
|
|
||||||
? Math.max(referenceTotalAmount - evaluationAmount, 0)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (derivedCash === undefined) return candidateCash;
|
|
||||||
|
|
||||||
// 후보 예수금 + 평가금이 기준 총자산(순자산 우선)과 크게 다르면 역산값을 사용합니다.
|
|
||||||
const recomposedWithCandidate = candidateCash + evaluationAmount;
|
|
||||||
const mismatchWithApi = Math.abs(
|
|
||||||
recomposedWithCandidate - referenceTotalAmount,
|
|
||||||
);
|
|
||||||
if (mismatchWithApi >= 1) {
|
|
||||||
return derivedCash;
|
|
||||||
}
|
|
||||||
|
|
||||||
return candidateCash;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 금액 후보 중 양수 값을 우선 선택합니다.
|
|
||||||
* @param values 금액 후보
|
|
||||||
* @returns 양수 우선 금액
|
|
||||||
* @see lib/kis/dashboard.ts getDomesticDashboardBalance 금액 필드 폴백 계산
|
|
||||||
*/
|
|
||||||
function pickPreferredAmount(...values: Array<number | undefined>) {
|
|
||||||
const positive = values.find(
|
|
||||||
(value): value is number => value !== undefined && value > 0,
|
|
||||||
);
|
|
||||||
if (positive !== undefined) return positive;
|
|
||||||
return firstDefinedNumber(...values);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 숫자 후보 중 0이 아닌 값을 우선 선택합니다.
|
|
||||||
* @param values 숫자 후보
|
|
||||||
* @returns 0이 아닌 값 우선 결과
|
|
||||||
* @see lib/kis/dashboard.ts getDomesticDashboardBalance 손익/수익률 폴백 계산
|
|
||||||
*/
|
|
||||||
function pickNonZeroNumber(...values: Array<number | undefined>) {
|
|
||||||
const nonZero = values.find(
|
|
||||||
(value): value is number => value !== undefined && value !== 0,
|
|
||||||
);
|
|
||||||
if (nonZero !== undefined) return nonZero;
|
|
||||||
return firstDefinedNumber(...values);
|
|
||||||
}
|
|
||||||
|
|||||||
349
lib/kis/domestic-helpers.ts
Normal file
349
lib/kis/domestic-helpers.ts
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
import type {
|
||||||
|
DashboardChartTimeframe,
|
||||||
|
StockCandlePoint,
|
||||||
|
} from "@/features/trade/types/trade.types";
|
||||||
|
|
||||||
|
type DomesticChartRow = Record<string, unknown>;
|
||||||
|
|
||||||
|
type OhlcvTuple = {
|
||||||
|
open: number;
|
||||||
|
high: number;
|
||||||
|
low: number;
|
||||||
|
close: number;
|
||||||
|
volume: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 문자열 숫자를 안전하게 number로 변환합니다.
|
||||||
|
* @see lib/kis/domestic.ts 국내 시세/차트 숫자 필드 파싱
|
||||||
|
*/
|
||||||
|
export function toNumber(value?: string) {
|
||||||
|
if (!value) return 0;
|
||||||
|
const normalized = value.replace(/,/g, "").trim();
|
||||||
|
if (!normalized) return 0;
|
||||||
|
const parsed = Number(normalized);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 숫자 문자열을 optional number로 변환합니다.
|
||||||
|
* @see lib/kis/domestic.ts 현재가 소스 선택 시 값 존재 여부 판단
|
||||||
|
*/
|
||||||
|
export function toOptionalNumber(value?: string) {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const normalized = value.replace(/,/g, "").trim();
|
||||||
|
if (!normalized) return undefined;
|
||||||
|
const parsed = Number(normalized);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description KIS 부호 코드를 실제 부호로 반영합니다.
|
||||||
|
* @see lib/kis/domestic.ts 지수/시세 변동값 정규화
|
||||||
|
*/
|
||||||
|
export function normalizeSignedValue(value: number, signCode?: string) {
|
||||||
|
const abs = Math.abs(value);
|
||||||
|
|
||||||
|
if (signCode === "4" || signCode === "5") return -abs;
|
||||||
|
if (signCode === "1" || signCode === "2") return abs;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 시장명을 코스피/코스닥으로 정규화합니다.
|
||||||
|
* @see lib/kis/domestic.ts 종목 overview 응답 market 값 결정
|
||||||
|
*/
|
||||||
|
export function resolveMarket(...values: Array<string | undefined>) {
|
||||||
|
const merged = values.filter(Boolean).join(" ");
|
||||||
|
if (merged.includes("코스닥") || merged.toUpperCase().includes("KOSDAQ")) {
|
||||||
|
return "KOSDAQ" as const;
|
||||||
|
}
|
||||||
|
return "KOSPI" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 일봉 output을 차트 공통 candle 포맷으로 변환합니다.
|
||||||
|
* @see lib/kis/domestic.ts getDomesticOverview candles 생성
|
||||||
|
*/
|
||||||
|
export function toCandles(
|
||||||
|
rows: Array<{
|
||||||
|
stck_bsop_date?: string;
|
||||||
|
stck_oprc?: string;
|
||||||
|
stck_hgpr?: string;
|
||||||
|
stck_lwpr?: string;
|
||||||
|
stck_clpr?: string;
|
||||||
|
acml_vol?: string;
|
||||||
|
}>,
|
||||||
|
currentPrice: number,
|
||||||
|
): StockCandlePoint[] {
|
||||||
|
const parsed = rows
|
||||||
|
.map((row) => ({
|
||||||
|
date: row.stck_bsop_date ?? "",
|
||||||
|
open: toNumber(row.stck_oprc),
|
||||||
|
high: toNumber(row.stck_hgpr),
|
||||||
|
low: toNumber(row.stck_lwpr),
|
||||||
|
close: toNumber(row.stck_clpr),
|
||||||
|
volume: toNumber(row.acml_vol),
|
||||||
|
}))
|
||||||
|
.filter((item) => item.date.length === 8 && item.close > 0)
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date))
|
||||||
|
.slice(-80)
|
||||||
|
.map((item) => ({
|
||||||
|
time: formatDate(item.date),
|
||||||
|
price: item.close,
|
||||||
|
open: item.open > 0 ? item.open : item.close,
|
||||||
|
high: item.high > 0 ? item.high : item.close,
|
||||||
|
low: item.low > 0 ? item.low : item.close,
|
||||||
|
close: item.close,
|
||||||
|
volume: item.volume,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (parsed.length > 0) return parsed;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const mm = `${now.getMonth() + 1}`.padStart(2, "0");
|
||||||
|
const dd = `${now.getDate()}`.padStart(2, "0");
|
||||||
|
const safePrice = Math.max(currentPrice, 0);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
time: `${mm}/${dd}`,
|
||||||
|
timestamp: Math.floor(now.getTime() / 1000),
|
||||||
|
price: safePrice,
|
||||||
|
open: safePrice,
|
||||||
|
high: safePrice,
|
||||||
|
low: safePrice,
|
||||||
|
close: safePrice,
|
||||||
|
volume: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: string) {
|
||||||
|
return `${date.slice(4, 6)}/${date.slice(6, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firstDefinedNumber(...values: Array<number | undefined>) {
|
||||||
|
return values.find((value) => value !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firstDefinedString(...values: Array<string | undefined>) {
|
||||||
|
return values.find((value) => Boolean(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 장중/시간외에 따라 현재가 우선 소스를 결정합니다.
|
||||||
|
* @see lib/kis/domestic.ts getDomesticOverview priceSource 계산
|
||||||
|
*/
|
||||||
|
export function resolveCurrentPriceSource(
|
||||||
|
marketPhase: "regular" | "afterHours",
|
||||||
|
overtime: { ovtm_untp_prpr?: string },
|
||||||
|
ccnl: { stck_prpr?: string },
|
||||||
|
quote: { stck_prpr?: string },
|
||||||
|
): "inquire-price" | "inquire-ccnl" | "inquire-overtime-price" {
|
||||||
|
const hasOvertimePrice =
|
||||||
|
toOptionalNumber(overtime.ovtm_untp_prpr) !== undefined;
|
||||||
|
const hasCcnlPrice = toOptionalNumber(ccnl.stck_prpr) !== undefined;
|
||||||
|
const hasQuotePrice = toOptionalNumber(quote.stck_prpr) !== undefined;
|
||||||
|
|
||||||
|
if (marketPhase === "afterHours") {
|
||||||
|
if (hasOvertimePrice) return "inquire-overtime-price";
|
||||||
|
if (hasCcnlPrice) return "inquire-ccnl";
|
||||||
|
return "inquire-price";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasCcnlPrice) return "inquire-ccnl";
|
||||||
|
if (hasQuotePrice) return "inquire-price";
|
||||||
|
return "inquire-price";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firstPositive(...values: number[]) {
|
||||||
|
return values.find((value) => value > 0) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description KIS 차트 응답에서 output2 배열을 안전하게 추출합니다.
|
||||||
|
* @see lib/kis/domestic.ts getDomesticDailyTimeChart/getDomesticChart
|
||||||
|
*/
|
||||||
|
export function parseOutput2Rows(envelope: {
|
||||||
|
output2?: unknown;
|
||||||
|
output1?: unknown;
|
||||||
|
output?: unknown;
|
||||||
|
}) {
|
||||||
|
if (Array.isArray(envelope.output2)) return envelope.output2 as DomesticChartRow[];
|
||||||
|
if (Array.isArray(envelope.output)) return envelope.output as DomesticChartRow[];
|
||||||
|
for (const key of ["output2", "output", "output1"] as const) {
|
||||||
|
const value = envelope[key];
|
||||||
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
return [value as DomesticChartRow];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [] as DomesticChartRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readRowString(row: DomesticChartRow, ...keys: string[]) {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = row[key];
|
||||||
|
if (typeof value === "string" && value.trim()) return value.trim();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readOhlcv(row: DomesticChartRow): OhlcvTuple | null {
|
||||||
|
const close = toNumber(
|
||||||
|
readRowString(row, "stck_clpr", "STCK_CLPR") ||
|
||||||
|
readRowString(row, "stck_prpr", "STCK_PRPR"),
|
||||||
|
);
|
||||||
|
if (close <= 0) return null;
|
||||||
|
|
||||||
|
const open = toNumber(readRowString(row, "stck_oprc", "STCK_OPRC")) || close;
|
||||||
|
const high =
|
||||||
|
toNumber(readRowString(row, "stck_hgpr", "STCK_HGPR")) ||
|
||||||
|
Math.max(open, close);
|
||||||
|
const low =
|
||||||
|
toNumber(readRowString(row, "stck_lwpr", "STCK_LWPR")) ||
|
||||||
|
Math.min(open, close);
|
||||||
|
const volume = toNumber(
|
||||||
|
readRowString(row, "acml_vol", "ACML_VOL") ||
|
||||||
|
readRowString(row, "cntg_vol", "CNTG_VOL"),
|
||||||
|
);
|
||||||
|
return { open, high, low, close, volume };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDayCandleRow(row: DomesticChartRow): StockCandlePoint | null {
|
||||||
|
const date = readRowString(row, "stck_bsop_date", "STCK_BSOP_DATE");
|
||||||
|
if (!/^\d{8}$/.test(date)) return null;
|
||||||
|
const ohlcv = readOhlcv(row);
|
||||||
|
if (!ohlcv) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
time: formatDate(date),
|
||||||
|
timestamp: toKstTimestamp(date, "090000"),
|
||||||
|
price: ohlcv.close,
|
||||||
|
...ohlcv,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMinuteCandleRow(
|
||||||
|
row: DomesticChartRow,
|
||||||
|
minuteBucket: number,
|
||||||
|
): StockCandlePoint | null {
|
||||||
|
let date = readRowString(row, "stck_bsop_date", "STCK_BSOP_DATE");
|
||||||
|
const rawTime = readRowString(row, "stck_cntg_hour", "STCK_CNTG_HOUR");
|
||||||
|
const time = /^\d{6}$/.test(rawTime)
|
||||||
|
? rawTime
|
||||||
|
: /^\d{4}$/.test(rawTime)
|
||||||
|
? `${rawTime}00`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (!/^\d{8}$/.test(date)) date = nowYmdInKst();
|
||||||
|
if (!/^\d{8}$/.test(date) || !/^\d{6}$/.test(time)) return null;
|
||||||
|
|
||||||
|
const ohlcv = readOhlcv(row);
|
||||||
|
if (!ohlcv) return null;
|
||||||
|
|
||||||
|
const bucketed = alignTimeToMinuteBucket(time, minuteBucket);
|
||||||
|
return {
|
||||||
|
time: `${bucketed.slice(0, 2)}:${bucketed.slice(2, 4)}`,
|
||||||
|
timestamp: toKstTimestamp(date, bucketed),
|
||||||
|
price: ohlcv.close,
|
||||||
|
...ohlcv,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeCandlesByTimestamp(rows: StockCandlePoint[]) {
|
||||||
|
const map = new Map<number, StockCandlePoint>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.timestamp) continue;
|
||||||
|
const prev = map.get(row.timestamp);
|
||||||
|
if (!prev) {
|
||||||
|
map.set(row.timestamp, row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
map.set(row.timestamp, {
|
||||||
|
...prev,
|
||||||
|
price: row.close ?? row.price,
|
||||||
|
close: row.close ?? row.price,
|
||||||
|
high: Math.max(prev.high ?? prev.price, row.high ?? row.price),
|
||||||
|
low: Math.min(prev.low ?? prev.price, row.low ?? row.price),
|
||||||
|
volume: (prev.volume ?? 0) + (row.volume ?? 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Array.from(map.values()).sort(
|
||||||
|
(a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alignTimeToMinuteBucket(hhmmss: string, bucket: number) {
|
||||||
|
if (/^\d{4}$/.test(hhmmss)) hhmmss = `${hhmmss}00`;
|
||||||
|
if (bucket <= 1) return hhmmss;
|
||||||
|
const hh = Number(hhmmss.slice(0, 2));
|
||||||
|
const mm = Number(hhmmss.slice(2, 4));
|
||||||
|
const aligned = Math.floor(mm / bucket) * bucket;
|
||||||
|
return `${hh.toString().padStart(2, "0")}${aligned.toString().padStart(2, "0")}00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toKstTimestamp(yyyymmdd: string, hhmmss: string) {
|
||||||
|
const y = Number(yyyymmdd.slice(0, 4));
|
||||||
|
const mo = Number(yyyymmdd.slice(4, 6));
|
||||||
|
const d = Number(yyyymmdd.slice(6, 8));
|
||||||
|
const hh = Number(hhmmss.slice(0, 2));
|
||||||
|
const mm = Number(hhmmss.slice(2, 4));
|
||||||
|
const ss = Number(hhmmss.slice(4, 6));
|
||||||
|
return Math.floor(Date.UTC(y, mo - 1, d, hh - 9, mm, ss) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shiftYmd(ymd: string, days: number) {
|
||||||
|
const utc = new Date(
|
||||||
|
Date.UTC(
|
||||||
|
Number(ymd.slice(0, 4)),
|
||||||
|
Number(ymd.slice(4, 6)) - 1,
|
||||||
|
Number(ymd.slice(6, 8)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
utc.setUTCDate(utc.getUTCDate() + days);
|
||||||
|
return toYmd(utc);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nowYmdInKst() {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: "Asia/Seoul",
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
}).formatToParts(new Date());
|
||||||
|
const map = new Map(parts.map((part) => [part.type, part.value]));
|
||||||
|
return `${map.get("year")}${map.get("month")}${map.get("day")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nowHmsInKst() {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: "Asia/Seoul",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
}).formatToParts(new Date());
|
||||||
|
const map = new Map(parts.map((part) => [part.type, part.value]));
|
||||||
|
return `${map.get("hour")}${map.get("minute")}${map.get("second")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function minutesForTimeframe(tf: DashboardChartTimeframe) {
|
||||||
|
if (tf === "30m") return 30;
|
||||||
|
if (tf === "1h") return 60;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subOneMinute(hhmmss: string) {
|
||||||
|
const hh = Number(hhmmss.slice(0, 2));
|
||||||
|
const mm = Number(hhmmss.slice(2, 4));
|
||||||
|
let totalMin = hh * 60 + mm - 1;
|
||||||
|
if (totalMin < 0) totalMin = 0;
|
||||||
|
|
||||||
|
const hour = Math.floor(totalMin / 60);
|
||||||
|
const minute = totalMin % 60;
|
||||||
|
return `${String(hour).padStart(2, "0")}${String(minute).padStart(2, "0")}00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toYmd(date: Date) {
|
||||||
|
return `${date.getUTCFullYear()}${`${date.getUTCMonth() + 1}`.padStart(2, "0")}${`${date.getUTCDate()}`.padStart(2, "0")}`;
|
||||||
|
}
|
||||||
@@ -10,6 +10,27 @@ import {
|
|||||||
resolveDomesticKisSession,
|
resolveDomesticKisSession,
|
||||||
shouldUseOvertimeOrderBookApi,
|
shouldUseOvertimeOrderBookApi,
|
||||||
} from "@/lib/kis/domestic-market-session";
|
} from "@/lib/kis/domestic-market-session";
|
||||||
|
import {
|
||||||
|
firstDefinedNumber,
|
||||||
|
firstDefinedString,
|
||||||
|
firstPositive,
|
||||||
|
mergeCandlesByTimestamp,
|
||||||
|
minutesForTimeframe,
|
||||||
|
normalizeSignedValue,
|
||||||
|
nowHmsInKst,
|
||||||
|
nowYmdInKst,
|
||||||
|
parseDayCandleRow,
|
||||||
|
parseMinuteCandleRow,
|
||||||
|
parseOutput2Rows,
|
||||||
|
readRowString,
|
||||||
|
resolveCurrentPriceSource,
|
||||||
|
resolveMarket,
|
||||||
|
shiftYmd,
|
||||||
|
subOneMinute,
|
||||||
|
toCandles,
|
||||||
|
toNumber,
|
||||||
|
toOptionalNumber,
|
||||||
|
} from "@/lib/kis/domestic-helpers";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @file lib/kis/domestic.ts
|
* @file lib/kis/domestic.ts
|
||||||
@@ -59,18 +80,6 @@ interface KisDomesticDailyPriceOutput {
|
|||||||
stck_clpr?: string;
|
stck_clpr?: string;
|
||||||
acml_vol?: string;
|
acml_vol?: string;
|
||||||
}
|
}
|
||||||
interface KisDomesticItemChartRow {
|
|
||||||
stck_bsop_date?: string;
|
|
||||||
stck_cntg_hour?: string;
|
|
||||||
stck_oprc?: string;
|
|
||||||
stck_hgpr?: string;
|
|
||||||
stck_lwpr?: string;
|
|
||||||
stck_clpr?: string;
|
|
||||||
stck_prpr?: string;
|
|
||||||
cntg_vol?: string;
|
|
||||||
acml_vol?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KisDomesticOrderBookOutput {
|
export interface KisDomesticOrderBookOutput {
|
||||||
stck_prpr?: string;
|
stck_prpr?: string;
|
||||||
total_askp_rsqn?: string;
|
total_askp_rsqn?: string;
|
||||||
@@ -394,87 +403,6 @@ export async function getDomesticOverview(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toNumber(value?: string) {
|
|
||||||
if (!value) return 0;
|
|
||||||
const normalized = value.replace(/,/g, "").trim();
|
|
||||||
if (!normalized) return 0;
|
|
||||||
const parsed = Number(normalized);
|
|
||||||
return Number.isFinite(parsed) ? parsed : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toOptionalNumber(value?: string) {
|
|
||||||
if (!value) return undefined;
|
|
||||||
const normalized = value.replace(/,/g, "").trim();
|
|
||||||
if (!normalized) return undefined;
|
|
||||||
const parsed = Number(normalized);
|
|
||||||
return Number.isFinite(parsed) ? parsed : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSignedValue(value: number, signCode?: string) {
|
|
||||||
const abs = Math.abs(value);
|
|
||||||
|
|
||||||
if (signCode === "4" || signCode === "5") return -abs;
|
|
||||||
if (signCode === "1" || signCode === "2") return abs;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveMarket(...values: Array<string | undefined>) {
|
|
||||||
const merged = values.filter(Boolean).join(" ");
|
|
||||||
if (merged.includes("코스닥") || merged.toUpperCase().includes("KOSDAQ"))
|
|
||||||
return "KOSDAQ" as const;
|
|
||||||
return "KOSPI" as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toCandles(
|
|
||||||
rows: KisDomesticDailyPriceOutput[],
|
|
||||||
currentPrice: number,
|
|
||||||
): StockCandlePoint[] {
|
|
||||||
const parsed = rows
|
|
||||||
.map((row) => ({
|
|
||||||
date: row.stck_bsop_date ?? "",
|
|
||||||
open: toNumber(row.stck_oprc),
|
|
||||||
high: toNumber(row.stck_hgpr),
|
|
||||||
low: toNumber(row.stck_lwpr),
|
|
||||||
close: toNumber(row.stck_clpr),
|
|
||||||
volume: toNumber(row.acml_vol),
|
|
||||||
}))
|
|
||||||
.filter((item) => item.date.length === 8 && item.close > 0)
|
|
||||||
.sort((a, b) => a.date.localeCompare(b.date))
|
|
||||||
.slice(-80)
|
|
||||||
.map((item) => ({
|
|
||||||
time: formatDate(item.date),
|
|
||||||
price: item.close,
|
|
||||||
open: item.open > 0 ? item.open : item.close,
|
|
||||||
high: item.high > 0 ? item.high : item.close,
|
|
||||||
low: item.low > 0 ? item.low : item.close,
|
|
||||||
close: item.close,
|
|
||||||
volume: item.volume,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (parsed.length > 0) return parsed;
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const mm = `${now.getMonth() + 1}`.padStart(2, "0");
|
|
||||||
const dd = `${now.getDate()}`.padStart(2, "0");
|
|
||||||
const safePrice = Math.max(currentPrice, 0);
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
time: `${mm}/${dd}`,
|
|
||||||
timestamp: Math.floor(now.getTime() / 1000),
|
|
||||||
price: safePrice,
|
|
||||||
open: safePrice,
|
|
||||||
high: safePrice,
|
|
||||||
low: safePrice,
|
|
||||||
close: safePrice,
|
|
||||||
volume: 0,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(date: string) {
|
|
||||||
return `${date.slice(4, 6)}/${date.slice(6, 8)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDomesticMarketPhaseInKst(
|
function getDomesticMarketPhaseInKst(
|
||||||
now = new Date(),
|
now = new Date(),
|
||||||
sessionOverride?: string | null,
|
sessionOverride?: string | null,
|
||||||
@@ -484,231 +412,16 @@ function getDomesticMarketPhaseInKst(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstDefinedNumber(...values: Array<number | undefined>) {
|
|
||||||
return values.find((value) => value !== undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstDefinedString(...values: Array<string | undefined>) {
|
|
||||||
return values.find((value) => Boolean(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveCurrentPriceSource(
|
|
||||||
marketPhase: DomesticMarketPhase,
|
|
||||||
overtime: KisDomesticOvertimePriceOutput,
|
|
||||||
ccnl: KisDomesticCcnlOutput,
|
|
||||||
quote: KisDomesticQuoteOutput,
|
|
||||||
): DomesticPriceSource {
|
|
||||||
const hasOvertimePrice =
|
|
||||||
toOptionalNumber(overtime.ovtm_untp_prpr) !== undefined;
|
|
||||||
const hasCcnlPrice = toOptionalNumber(ccnl.stck_prpr) !== undefined;
|
|
||||||
const hasQuotePrice = toOptionalNumber(quote.stck_prpr) !== undefined;
|
|
||||||
|
|
||||||
if (marketPhase === "afterHours") {
|
|
||||||
if (hasOvertimePrice) return "inquire-overtime-price";
|
|
||||||
if (hasCcnlPrice) return "inquire-ccnl";
|
|
||||||
return "inquire-price";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasCcnlPrice) return "inquire-ccnl";
|
|
||||||
if (hasQuotePrice) return "inquire-price";
|
|
||||||
return "inquire-price";
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolvePriceMarketDivCode() {
|
function resolvePriceMarketDivCode() {
|
||||||
return "J";
|
return "J";
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstPositive(...values: number[]) {
|
|
||||||
return values.find((value) => value > 0) ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DomesticChartResult {
|
export interface DomesticChartResult {
|
||||||
candles: StockCandlePoint[];
|
candles: StockCandlePoint[];
|
||||||
nextCursor: string | null;
|
nextCursor: string | null;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── KIS output2 배열 추출 ─────────────────────────────────
|
|
||||||
function parseOutput2Rows(envelope: {
|
|
||||||
output2?: unknown;
|
|
||||||
output1?: unknown;
|
|
||||||
output?: unknown;
|
|
||||||
}) {
|
|
||||||
if (Array.isArray(envelope.output2))
|
|
||||||
return envelope.output2 as KisDomesticItemChartRow[];
|
|
||||||
if (Array.isArray(envelope.output))
|
|
||||||
return envelope.output as KisDomesticItemChartRow[];
|
|
||||||
for (const key of ["output2", "output", "output1"] as const) {
|
|
||||||
const v = envelope[key];
|
|
||||||
if (v && typeof v === "object" && !Array.isArray(v))
|
|
||||||
return [v as KisDomesticItemChartRow];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Row → StockCandlePoint 변환 ───────────────────────────
|
|
||||||
function readRowString(row: KisDomesticItemChartRow, ...keys: string[]) {
|
|
||||||
const record = row as Record<string, unknown>;
|
|
||||||
for (const key of keys) {
|
|
||||||
const v = record[key];
|
|
||||||
if (typeof v === "string" && v.trim()) return v.trim();
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function readOhlcv(row: KisDomesticItemChartRow) {
|
|
||||||
const close = toNumber(
|
|
||||||
readRowString(row, "stck_clpr", "STCK_CLPR") ||
|
|
||||||
readRowString(row, "stck_prpr", "STCK_PRPR"),
|
|
||||||
);
|
|
||||||
if (close <= 0) return null;
|
|
||||||
|
|
||||||
const open = toNumber(readRowString(row, "stck_oprc", "STCK_OPRC")) || close;
|
|
||||||
const high =
|
|
||||||
toNumber(readRowString(row, "stck_hgpr", "STCK_HGPR")) ||
|
|
||||||
Math.max(open, close);
|
|
||||||
const low =
|
|
||||||
toNumber(readRowString(row, "stck_lwpr", "STCK_LWPR")) ||
|
|
||||||
Math.min(open, close);
|
|
||||||
const volume = toNumber(
|
|
||||||
readRowString(row, "acml_vol", "ACML_VOL") ||
|
|
||||||
readRowString(row, "cntg_vol", "CNTG_VOL"),
|
|
||||||
);
|
|
||||||
return { open, high, low, close, volume };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDayCandleRow(
|
|
||||||
row: KisDomesticItemChartRow,
|
|
||||||
): StockCandlePoint | null {
|
|
||||||
const date = readRowString(row, "stck_bsop_date", "STCK_BSOP_DATE");
|
|
||||||
if (!/^\d{8}$/.test(date)) return null;
|
|
||||||
const ohlcv = readOhlcv(row);
|
|
||||||
if (!ohlcv) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
time: formatDate(date),
|
|
||||||
timestamp: toKstTimestamp(date, "090000"),
|
|
||||||
price: ohlcv.close,
|
|
||||||
...ohlcv,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseMinuteCandleRow(
|
|
||||||
row: KisDomesticItemChartRow,
|
|
||||||
minuteBucket: number,
|
|
||||||
): StockCandlePoint | null {
|
|
||||||
let date = readRowString(row, "stck_bsop_date", "STCK_BSOP_DATE");
|
|
||||||
const rawTime = readRowString(row, "stck_cntg_hour", "STCK_CNTG_HOUR");
|
|
||||||
const time = /^\d{6}$/.test(rawTime)
|
|
||||||
? rawTime
|
|
||||||
: /^\d{4}$/.test(rawTime)
|
|
||||||
? `${rawTime}00`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
if (!/^\d{8}$/.test(date)) date = nowYmdInKst(); // 당일 분봉은 날짜가 빠져있을 수 있음
|
|
||||||
if (!/^\d{8}$/.test(date) || !/^\d{6}$/.test(time)) return null;
|
|
||||||
|
|
||||||
const ohlcv = readOhlcv(row);
|
|
||||||
if (!ohlcv) return null;
|
|
||||||
|
|
||||||
const bucketed = alignTimeToMinuteBucket(time, minuteBucket);
|
|
||||||
return {
|
|
||||||
time: `${bucketed.slice(0, 2)}:${bucketed.slice(2, 4)}`,
|
|
||||||
timestamp: toKstTimestamp(date, bucketed),
|
|
||||||
price: ohlcv.close,
|
|
||||||
...ohlcv,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 같은 타임스탬프 봉 병합 ───────────────────────────────
|
|
||||||
function mergeCandlesByTimestamp(rows: StockCandlePoint[]) {
|
|
||||||
const map = new Map<number, StockCandlePoint>();
|
|
||||||
for (const row of rows) {
|
|
||||||
if (!row.timestamp) continue;
|
|
||||||
const prev = map.get(row.timestamp);
|
|
||||||
if (!prev) {
|
|
||||||
map.set(row.timestamp, row);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
map.set(row.timestamp, {
|
|
||||||
...prev,
|
|
||||||
price: row.close ?? row.price,
|
|
||||||
close: row.close ?? row.price,
|
|
||||||
high: Math.max(prev.high ?? prev.price, row.high ?? row.price),
|
|
||||||
low: Math.min(prev.low ?? prev.price, row.low ?? row.price),
|
|
||||||
volume: (prev.volume ?? 0) + (row.volume ?? 0),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Array.from(map.values()).sort(
|
|
||||||
(a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 시간 유틸 ─────────────────────────────────────────────
|
|
||||||
function alignTimeToMinuteBucket(hhmmss: string, bucket: number) {
|
|
||||||
if (/^\d{4}$/.test(hhmmss)) hhmmss = `${hhmmss}00`;
|
|
||||||
if (bucket <= 1) return hhmmss;
|
|
||||||
const hh = Number(hhmmss.slice(0, 2));
|
|
||||||
const mm = Number(hhmmss.slice(2, 4));
|
|
||||||
const aligned = Math.floor(mm / bucket) * bucket;
|
|
||||||
return `${hh.toString().padStart(2, "0")}${aligned.toString().padStart(2, "0")}00`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toKstTimestamp(yyyymmdd: string, hhmmss: string) {
|
|
||||||
const y = Number(yyyymmdd.slice(0, 4));
|
|
||||||
const mo = Number(yyyymmdd.slice(4, 6));
|
|
||||||
const d = Number(yyyymmdd.slice(6, 8));
|
|
||||||
const hh = Number(hhmmss.slice(0, 2));
|
|
||||||
const mm = Number(hhmmss.slice(2, 4));
|
|
||||||
const ss = Number(hhmmss.slice(4, 6));
|
|
||||||
return Math.floor(Date.UTC(y, mo - 1, d, hh - 9, mm, ss) / 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toYmd(date: Date) {
|
|
||||||
return `${date.getUTCFullYear()}${`${date.getUTCMonth() + 1}`.padStart(2, "0")}${`${date.getUTCDate()}`.padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shiftYmd(ymd: string, days: number) {
|
|
||||||
const utc = new Date(
|
|
||||||
Date.UTC(
|
|
||||||
Number(ymd.slice(0, 4)),
|
|
||||||
Number(ymd.slice(4, 6)) - 1,
|
|
||||||
Number(ymd.slice(6, 8)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
utc.setUTCDate(utc.getUTCDate() + days);
|
|
||||||
return toYmd(utc);
|
|
||||||
}
|
|
||||||
|
|
||||||
function nowYmdInKst() {
|
|
||||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
||||||
timeZone: "Asia/Seoul",
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
}).formatToParts(new Date());
|
|
||||||
const m = new Map(parts.map((p) => [p.type, p.value]));
|
|
||||||
return `${m.get("year")}${m.get("month")}${m.get("day")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function nowHmsInKst() {
|
|
||||||
const parts = new Intl.DateTimeFormat("en-US", {
|
|
||||||
timeZone: "Asia/Seoul",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
}).formatToParts(new Date());
|
|
||||||
const m = new Map(parts.map((p) => [p.type, p.value]));
|
|
||||||
return `${m.get("hour")}${m.get("minute")}${m.get("second")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function minutesForTimeframe(tf: DashboardChartTimeframe) {
|
|
||||||
if (tf === "30m") return 30;
|
|
||||||
if (tf === "1h") return 60;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 국내주식 주식일별분봉조회 (과거 분봉)
|
* 국내주식 주식일별분봉조회 (과거 분봉)
|
||||||
* @param symbol 종목코드
|
* @param symbol 종목코드
|
||||||
@@ -794,7 +507,7 @@ export async function getDomesticChart(
|
|||||||
|
|
||||||
// ── 분봉 (1m / 30m / 1h) ──
|
// ── 분봉 (1m / 30m / 1h) ──
|
||||||
const minuteBucket = minutesForTimeframe(timeframe);
|
const minuteBucket = minutesForTimeframe(timeframe);
|
||||||
let rawRows: KisDomesticItemChartRow[] = [];
|
let rawRows: Array<Record<string, unknown>> = [];
|
||||||
let nextCursor: string | null = null;
|
let nextCursor: string | null = null;
|
||||||
|
|
||||||
// Case A: 과거 데이터 조회 (커서 존재)
|
// Case A: 과거 데이터 조회 (커서 존재)
|
||||||
@@ -896,14 +609,3 @@ export async function getDomesticChart(
|
|||||||
|
|
||||||
return { candles, hasMore: Boolean(nextCursor), nextCursor };
|
return { candles, hasMore: Boolean(nextCursor), nextCursor };
|
||||||
}
|
}
|
||||||
|
|
||||||
function subOneMinute(hhmmss: string) {
|
|
||||||
const hh = Number(hhmmss.slice(0, 2));
|
|
||||||
const mm = Number(hhmmss.slice(2, 4));
|
|
||||||
let totalMin = hh * 60 + mm - 1;
|
|
||||||
if (totalMin < 0) totalMin = 0;
|
|
||||||
|
|
||||||
const h = Math.floor(totalMin / 60);
|
|
||||||
const m = totalMin % 60;
|
|
||||||
return `${String(h).padStart(2, '0')}${String(m).padStart(2, '0')}00`;
|
|
||||||
}
|
|
||||||
|
|||||||
188
lib/kis/error-codes.ts
Normal file
188
lib/kis/error-codes.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* @file lib/kis/error-codes.ts
|
||||||
|
* @description KIS FAQ 오류코드(msg_cd) 문구를 공통으로 해석하는 유틸입니다.
|
||||||
|
* @see https://apiportal.koreainvestment.com/faq-error-code 한국투자증권 공식 오류코드 기준
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const KIS_ERROR_CODE_REFERENCE_URL =
|
||||||
|
"https://apiportal.koreainvestment.com/faq-error-code";
|
||||||
|
|
||||||
|
const KIS_ERROR_CODE_MESSAGE_MAP = {
|
||||||
|
EGW00001: "일시적 오류가 발생했습니다.",
|
||||||
|
EGW00002: "서버 에러가 발생했습니다.",
|
||||||
|
EGW00003: "접근이 거부되었습니다.",
|
||||||
|
EGW00004: "권한을 부여받지 않은 고객입니다.",
|
||||||
|
EGW00101: "유효하지 않은 요청입니다.",
|
||||||
|
EGW00102: "AppKey는 필수입니다.",
|
||||||
|
EGW00103: "유효하지 않은 AppKey입니다.",
|
||||||
|
EGW00104: "AppSecret은 필수입니다.",
|
||||||
|
EGW00105: "유효하지 않은 AppSecret입니다.",
|
||||||
|
EGW00106: "redirect_uri는 필수입니다.",
|
||||||
|
EGW00107: "유효하지 않은 redirect_uri입니다.",
|
||||||
|
EGW00108: "유효하지 않은 서비스구분(service)입니다.",
|
||||||
|
EGW00109: "scope는 필수입니다.",
|
||||||
|
EGW00110: "유효하지 않은 scope 입니다.",
|
||||||
|
EGW00111: "유효하지 않은 state 입니다.",
|
||||||
|
EGW00112: "유효하지 않은 grant 입니다.",
|
||||||
|
EGW00113: "응답구분(response_type)은 필수입니다.",
|
||||||
|
EGW00114: "지원하지 않는 응답구분(response_type)입니다.",
|
||||||
|
EGW00115: "권한부여 타입(grant_type)은 필수입니다.",
|
||||||
|
EGW00116: "지원하지 않는 권한부여 타입(grant_type)입니다.",
|
||||||
|
EGW00117: "지원하지 않는 토큰 타입(token_type)입니다.",
|
||||||
|
EGW00118: "유효하지 않은 code 입니다.",
|
||||||
|
EGW00119: "code를 찾을 수 없습니다.",
|
||||||
|
EGW00120: "기간이 만료된 code 입니다.",
|
||||||
|
EGW00121: "유효하지 않은 token 입니다.",
|
||||||
|
EGW00122: "token을 찾을 수 없습니다.",
|
||||||
|
EGW00123: "기간이 만료된 token 입니다.",
|
||||||
|
EGW00124: "유효하지 않은 session_key 입니다.",
|
||||||
|
EGW00125: "session_key를 찾을 수 없습니다.",
|
||||||
|
EGW00126: "기간이 만료된 session_key 입니다.",
|
||||||
|
EGW00127: "제휴사번호(corpno)는 필수입니다.",
|
||||||
|
EGW00128: "계좌번호(acctno)는 필수입니다.",
|
||||||
|
EGW00129: "HTS_ID는 필수입니다.",
|
||||||
|
EGW00130: "유효하지 않은 유저(user)입니다.",
|
||||||
|
EGW00131: "유효하지 않은 hashkey입니다.",
|
||||||
|
EGW00132: "Content-Type이 유효하지 않습니다.",
|
||||||
|
EGW00201: "초당 거래건수를 초과하였습니다.",
|
||||||
|
EGW00202: "GW라우팅 중 오류가 발생했습니다.",
|
||||||
|
EGW00203: "OPS라우팅 중 오류가 발생했습니다.",
|
||||||
|
EGW00204: "Internal Gateway 인스턴스를 잘못 입력했습니다.",
|
||||||
|
EGW00205: "credentials_type이 유효하지 않습니다.(Bearer)",
|
||||||
|
EGW00206: "API 사용 권한이 없습니다.",
|
||||||
|
EGW00207: "IP 주소가 없거나 유효하지 않습니다.",
|
||||||
|
EGW00208: "고객유형(custtype)이 유효하지 않습니다.",
|
||||||
|
EGW00209: "일련번호(seq_no)가 유효하지 않습니다.",
|
||||||
|
EGW00210: "법인고객의 경우 모의투자를 이용할 수 없습니다.",
|
||||||
|
EGW00211: "고객명(personalname)은 필수 입니다.",
|
||||||
|
EGW00212: "휴대전화번호(personalphone)는 필수 입니다.",
|
||||||
|
EGW00213: "제휴사명(corpname)은 필수 입니다. / 모의투자 tr이 아닙니다.",
|
||||||
|
EGW00300: "Gateway 라우팅 오류가 발생했습니다.",
|
||||||
|
EGW00301: "연결 시간이 초과되었습니다. 직전 거래를 반드시 확인하세요.",
|
||||||
|
EGW00302: "거래시간이 초과되었습니다. 직전 거래를 반드시 확인하세요.",
|
||||||
|
EGW00303: "법인고객에게 허용되지 않은 IP접근입니다.",
|
||||||
|
EGW00304:
|
||||||
|
"고객식별키(법인 personalSeckey, 개인 appSecret)가 유효하지 않습니다.",
|
||||||
|
OPSQ0001: "호출 전처리 오류 입니다.",
|
||||||
|
OPSQ0002: "없는 서비스 코드 입니다.",
|
||||||
|
OPSQ0003: "호출 오류 입니다.",
|
||||||
|
OPSQ0004: "호출 후처리 오류 입니다.",
|
||||||
|
OPSQ0005: "호출 후처리 오류 입니다.",
|
||||||
|
OPSQ0006: "호출 후처리 오류 입니다.",
|
||||||
|
OPSQ0007: "호출 후처리(헤더설정) 오류 입니다.",
|
||||||
|
OPSQ0008: "호출 후처리(MCI전송) 오류 입니다.",
|
||||||
|
OPSQ0009: "호출 후처리(MCI수신) 오류 입니다.",
|
||||||
|
OPSQ0010: "호출 결과처리(리소스 부족) 오류 입니다.",
|
||||||
|
OPSQ0011: "호출 결과처리(리소스 부족) 오류 입니다.",
|
||||||
|
OPSQ1002: "세션 연결 오류.",
|
||||||
|
OPSQ2000: "ERROR : INPUT INVALID_CHECK_ACNO",
|
||||||
|
OPSQ2001: "ERROR : INPUT INVALID_CHECK_MRKT_DIV_CODE",
|
||||||
|
OPSQ2002: "ERROR : INPUT INVALID_CHECK_FIELD_LENGTH",
|
||||||
|
OPSQ2003: "ERROR : SET_MCI_SEND_DATA",
|
||||||
|
OPSQ3001: "ERROR : RESPONSE_ADDITEMTOOBJECT",
|
||||||
|
OPSQ3002: "ERROR : GET_CALL_PARAM_MCI_SEND_DATA_LEN",
|
||||||
|
OPSQ3004: "ERROR : OUT_STRING_ARRAY ALLOC FAILED",
|
||||||
|
OPSQ9995: "JSON PARSING ERROR : body not found",
|
||||||
|
OPSQ9996: "JSON PARSING ERROR : header not found",
|
||||||
|
OPSQ9997: "JSON PARSING ERROR : invalid json format",
|
||||||
|
OPSQ9998: "JSON PARSING ERROR : seq_no not found",
|
||||||
|
OPSQ9999: "JSON PARSING ERROR : tr_id not found",
|
||||||
|
OPSP0000: "SUBSCRIBE SUCCESS",
|
||||||
|
OPSP0001: "UNSUBSCRIBE SUCCESS",
|
||||||
|
OPSP0002: "ALREADY IN SUBSCRIBE",
|
||||||
|
OPSP0003: "UNSUBSCRIBE ERROR(not found!)",
|
||||||
|
OPSP0007: "SUBSCRIBE INTERNAL ERROR",
|
||||||
|
OPSP0008: "MAX SUBSCRIBE OVER",
|
||||||
|
OPSP0009: "SUBSCRIBE ERROR : mci send failed",
|
||||||
|
OPSP0010: "SUBSCRIBE WARNNING : invalid appkey",
|
||||||
|
OPSP0011: "invalid approval(appkey) : NOT FOUND",
|
||||||
|
OPSP8991: "SUBSCRIBE ERROR : invalid tr_id",
|
||||||
|
OPSP8992: "SUBSCRIBE ERROR : invalid tr_key",
|
||||||
|
OPSP8993: "JSON PARSING ERROR : invalid tr_key",
|
||||||
|
OPSP8994: "JSON PARSING ERROR : personalseckey not found",
|
||||||
|
OPSP8995: "JSON PARSING ERROR : appsecret not found",
|
||||||
|
OPSP8996: "ALREADY IN USE appkey",
|
||||||
|
OPSP8997: "JSON PARSING ERROR : invalid tr_type",
|
||||||
|
OPSP8998: "JSON PARSING ERROR : invalid custtype",
|
||||||
|
OPSP8999: "resource not available (ALLOC_CALL_PARAM)",
|
||||||
|
OPSP9990: "JSON PARSING ERROR : tr_key not found",
|
||||||
|
OPSP9991: "JSON PARSING ERROR : input not found",
|
||||||
|
OPSP9992: "JSON PARSING ERROR : body not found",
|
||||||
|
OPSP9993: "JSON PARSING ERROR : internal error",
|
||||||
|
OPSP9994: "JSON PARSING ERROR : INVALID appkey",
|
||||||
|
OPSP9995: "JSON PARSING ERROR : resource not available",
|
||||||
|
OPSP9996: "JSON PARSING ERROR : appkey",
|
||||||
|
OPSP9997: "JSON PARSING ERROR : custtype not found",
|
||||||
|
OPSP9998: "JSON PARSING ERROR : header not found",
|
||||||
|
OPSP9999: "JSON PARSING ERROR : invalid json format",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface KisErrorGuide {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
referenceUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildKisErrorDetailParams {
|
||||||
|
message?: string;
|
||||||
|
msgCode?: string;
|
||||||
|
extraMessages?: Array<string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKisErrorCode(msgCode?: string) {
|
||||||
|
return msgCode?.trim().toUpperCase() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description KIS msg_cd를 공식 FAQ 문구와 매칭합니다.
|
||||||
|
* @param msgCode KIS 응답 msg_cd
|
||||||
|
* @returns 코드/문구/참고 URL 정보. 없으면 null
|
||||||
|
* @see lib/kis/client.ts kisGet/kisPost 비즈니스 오류 메시지 구성
|
||||||
|
* @see features/kis-realtime/stores/kisWebSocketStore.ts 실시간 제어 오류 안내문 구성
|
||||||
|
*/
|
||||||
|
export function getKisErrorGuide(msgCode?: string): KisErrorGuide | null {
|
||||||
|
const code = normalizeKisErrorCode(msgCode);
|
||||||
|
if (!code) return null;
|
||||||
|
|
||||||
|
const message =
|
||||||
|
KIS_ERROR_CODE_MESSAGE_MAP[
|
||||||
|
code as keyof typeof KIS_ERROR_CODE_MESSAGE_MAP
|
||||||
|
];
|
||||||
|
if (!message) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
referenceUrl: KIS_ERROR_CODE_REFERENCE_URL,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description KIS 오류 조각(msg1/msg_cd/부가메시지)을 사람이 읽기 쉬운 한 줄로 합칩니다.
|
||||||
|
* @param params 오류 문자열 조합 입력값
|
||||||
|
* @returns 중복 제거된 상세 메시지
|
||||||
|
* @see lib/kis/token.ts buildTokenIssueDetail 토큰 발급/폐기 오류 상세 구성
|
||||||
|
* @see lib/kis/approval.ts issueKisApprovalKey 승인키 발급 오류 상세 구성
|
||||||
|
*/
|
||||||
|
export function buildKisErrorDetail({
|
||||||
|
message,
|
||||||
|
msgCode,
|
||||||
|
extraMessages = [],
|
||||||
|
}: BuildKisErrorDetailParams) {
|
||||||
|
const tokens = new Set<string>();
|
||||||
|
|
||||||
|
for (const raw of [...extraMessages, message]) {
|
||||||
|
const normalized = raw?.trim();
|
||||||
|
if (normalized) tokens.add(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
const guide = getKisErrorGuide(msgCode);
|
||||||
|
const normalizedCode = normalizeKisErrorCode(msgCode);
|
||||||
|
if (guide) {
|
||||||
|
tokens.add(`${guide.code} (${guide.message})`);
|
||||||
|
} else if (normalizedCode) {
|
||||||
|
tokens.add(normalizedCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...tokens].join(" / ");
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config";
|
import { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
interface KisCredentialRequestBody {
|
const kisCredentialRequestBodySchema = z.object({
|
||||||
appKey?: string;
|
appKey: z.string().trim().optional(),
|
||||||
appSecret?: string;
|
appSecret: z.string().trim().optional(),
|
||||||
tradingEnv?: string;
|
tradingEnv: z.string().optional(),
|
||||||
}
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 요청 본문에서 KIS 인증 정보를 파싱합니다.
|
* @description 요청 본문에서 KIS 인증 정보를 파싱합니다.
|
||||||
@@ -14,14 +15,17 @@ interface KisCredentialRequestBody {
|
|||||||
export async function parseKisCredentialRequest(
|
export async function parseKisCredentialRequest(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
): Promise<KisCredentialInput> {
|
): Promise<KisCredentialInput> {
|
||||||
let body: KisCredentialRequestBody = {};
|
let rawBody: unknown = {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
body = (await request.json()) as KisCredentialRequestBody;
|
rawBody = (await request.json()) as unknown;
|
||||||
} catch {
|
} catch {
|
||||||
// 빈 본문 또는 JSON 파싱 실패는 아래 필수값 검증에서 처리합니다.
|
// 빈 본문 또는 JSON 파싱 실패는 아래 필수값 검증에서 처리합니다.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parsedBody = kisCredentialRequestBodySchema.safeParse(rawBody);
|
||||||
|
const body = parsedBody.success ? parsedBody.data : {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appKey: body.appKey?.trim(),
|
appKey: body.appKey?.trim(),
|
||||||
appSecret: body.appSecret?.trim(),
|
appSecret: body.appSecret?.trim(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|||||||
import { clearKisApprovalKeyCache } from "@/lib/kis/approval";
|
import { clearKisApprovalKeyCache } from "@/lib/kis/approval";
|
||||||
import type { KisConfig, KisCredentialInput } from "@/lib/kis/config";
|
import type { KisConfig, KisCredentialInput } from "@/lib/kis/config";
|
||||||
import { getKisConfig } from "@/lib/kis/config";
|
import { getKisConfig } from "@/lib/kis/config";
|
||||||
|
import { buildKisErrorDetail } from "@/lib/kis/error-codes";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @file lib/kis/token.ts
|
* @file lib/kis/token.ts
|
||||||
@@ -218,9 +219,11 @@ function buildTokenIssueBody(config: KisConfig) {
|
|||||||
* @see issueKisToken 토큰 발급 실패 에러 메시지 구성
|
* @see issueKisToken 토큰 발급 실패 에러 메시지 구성
|
||||||
*/
|
*/
|
||||||
function buildTokenIssueDetail(payload: KisTokenResponse) {
|
function buildTokenIssueDetail(payload: KisTokenResponse) {
|
||||||
return [payload.msg1, payload.error_description, payload.error, payload.msg_cd]
|
return buildKisErrorDetail({
|
||||||
.filter(Boolean)
|
message: payload.msg1,
|
||||||
.join(" / ");
|
msgCode: payload.msg_cd,
|
||||||
|
extraMessages: [payload.error_description, payload.error],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -321,7 +324,10 @@ export async function revokeKisAccessToken(credentials?: KisCredentialInput) {
|
|||||||
const isSuccessCode = code === "" || code === "200";
|
const isSuccessCode = code === "" || code === "200";
|
||||||
|
|
||||||
if (!response.ok || !isSuccessCode) {
|
if (!response.ok || !isSuccessCode) {
|
||||||
const detail = [payload.message, payload.msg1].filter(Boolean).join(" / ");
|
const detail = buildKisErrorDetail({
|
||||||
|
message: payload.message,
|
||||||
|
extraMessages: [payload.msg1],
|
||||||
|
});
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
detail
|
detail
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
import dynamic from "next/dynamic";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const ReactQueryDevtools = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@tanstack/react-query-devtools").then(
|
||||||
|
(mod) => mod.ReactQueryDevtools,
|
||||||
|
),
|
||||||
|
{ ssr: false },
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [React Query Provider]
|
* [React Query Provider]
|
||||||
*
|
*
|
||||||
@@ -41,7 +49,9 @@ export function QueryProvider({ children }: { children: React.ReactNode }) {
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
{children}
|
{children}
|
||||||
{/* ========== DevTools (개발 환경에서만 표시) ========== */}
|
{/* ========== DevTools (개발 환경에서만 표시) ========== */}
|
||||||
|
{process.env.NODE_ENV === "development" ? (
|
||||||
<ReactQueryDevtools initialIsOpen={false} position="bottom" />
|
<ReactQueryDevtools initialIsOpen={false} position="bottom" />
|
||||||
|
) : null}
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,31 +2,31 @@ import { type NextRequest } from "next/server";
|
|||||||
import { updateSession } from "@/utils/supabase/middleware";
|
import { updateSession } from "@/utils/supabase/middleware";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [Next.js 미들웨어 진입점]
|
* [Next.js Proxy 진입점]
|
||||||
*
|
*
|
||||||
* 웹사이트의 모든 요청은 이 함수를 가장 먼저 거쳐갑니다.
|
* 웹사이트의 모든 요청은 이 함수를 가장 먼저 거쳐갑니다.
|
||||||
* 여기서 로그인 여부를 체크하거나 세션을 갱신합니다.
|
* 여기서 로그인 여부를 체크하거나 세션을 갱신합니다.
|
||||||
*/
|
*/
|
||||||
export async function middleware(request: NextRequest) {
|
export async function proxy(request: NextRequest) {
|
||||||
// 방금 만든 updateSession 함수를 호출하여 쿠키/세션을 관리합니다.
|
// 방금 만든 updateSession 함수를 호출하여 쿠키/세션을 관리합니다.
|
||||||
return await updateSession(request);
|
return await updateSession(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [미들웨어 설정]
|
* [Proxy 설정]
|
||||||
*
|
*
|
||||||
* 미들웨어가 '어떤 경로'에서 실행될지, '어떤 경로는 무시할지' 정하는 규칙입니다.
|
* Proxy가 '어떤 경로'에서 실행될지, '어떤 경로는 무시할지' 정하는 규칙입니다.
|
||||||
*/
|
*/
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
/*
|
/*
|
||||||
* 아래 정규식(Regex)은 다음 파일들을 제외(exclude)하고 모든 요청을 미들웨어로 보냅니다:
|
* 아래 정규식(Regex)은 다음 파일들을 제외(exclude)하고 모든 요청을 Proxy로 보냅니다:
|
||||||
* - _next/static (이미 빌드된 정적 파일들)
|
* - _next/static (이미 빌드된 정적 파일들)
|
||||||
* - _next/image (이미지 최적화 API)
|
* - _next/image (이미지 최적화 API)
|
||||||
* - favicon.ico (파비콘 아이콘)
|
* - favicon.ico (파비콘 아이콘)
|
||||||
* - .svg, .png, .jpg 등 이미지 파일들
|
* - .svg, .png, .jpg 등 이미지 파일들
|
||||||
*
|
*
|
||||||
* 즉, html 페이지 요청이나 데이터 요청에만 미들웨어가 작동하도록 하여 성능을 최적화합니다.
|
* 즉, html 페이지 요청이나 데이터 요청에만 Proxy가 작동하도록 하여 성능을 최적화합니다.
|
||||||
*/
|
*/
|
||||||
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
||||||
],
|
],
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
import { persist } from "zustand/middleware";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [사용자 정보 타입]
|
|
||||||
*/
|
|
||||||
export interface User {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
name?: string;
|
|
||||||
avatar?: string;
|
|
||||||
createdAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [인증 상태 인터페이스]
|
|
||||||
*/
|
|
||||||
interface AuthState {
|
|
||||||
// ========== 상태 ==========
|
|
||||||
user: User | null;
|
|
||||||
isAuthenticated: boolean;
|
|
||||||
|
|
||||||
// ========== 액션 ==========
|
|
||||||
setUser: (user: User | null) => void;
|
|
||||||
updateUser: (updates: Partial<User>) => void;
|
|
||||||
logout: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [인증 스토어]
|
|
||||||
*
|
|
||||||
* 전역 사용자 인증 상태를 관리합니다.
|
|
||||||
* - localStorage에 자동 저장 (persist 미들웨어)
|
|
||||||
* - 페이지 새로고침 시에도 상태 유지
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* import { useAuthStore } from '@/stores/auth-store';
|
|
||||||
*
|
|
||||||
* function Profile() {
|
|
||||||
* const { user, isAuthenticated, setUser } = useAuthStore();
|
|
||||||
*
|
|
||||||
* if (!isAuthenticated) return <Login />;
|
|
||||||
* return <div>Welcome, {user?.email}</div>;
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export const useAuthStore = create<AuthState>()(
|
|
||||||
persist(
|
|
||||||
(set) => ({
|
|
||||||
// ========== 초기 상태 ==========
|
|
||||||
user: null,
|
|
||||||
isAuthenticated: false,
|
|
||||||
|
|
||||||
// ========== 사용자 설정 ==========
|
|
||||||
setUser: (user) =>
|
|
||||||
set({
|
|
||||||
user,
|
|
||||||
isAuthenticated: !!user,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// ========== 사용자 정보 업데이트 ==========
|
|
||||||
updateUser: (updates) =>
|
|
||||||
set((state) => ({
|
|
||||||
user: state.user ? { ...state.user, ...updates } : null,
|
|
||||||
})),
|
|
||||||
|
|
||||||
// ========== 로그아웃 ==========
|
|
||||||
logout: () =>
|
|
||||||
set({
|
|
||||||
user: null,
|
|
||||||
isAuthenticated: false,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
name: "auth-storage", // localStorage 키 이름
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
import { persist } from "zustand/middleware";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [UI 상태 인터페이스]
|
|
||||||
*/
|
|
||||||
interface UIState {
|
|
||||||
// ========== 테마 ==========
|
|
||||||
theme: "light" | "dark" | "system";
|
|
||||||
setTheme: (theme: "light" | "dark" | "system") => void;
|
|
||||||
|
|
||||||
// ========== 사이드바 ==========
|
|
||||||
isSidebarOpen: boolean;
|
|
||||||
toggleSidebar: () => void;
|
|
||||||
setSidebarOpen: (isOpen: boolean) => void;
|
|
||||||
|
|
||||||
// ========== 모달 ==========
|
|
||||||
isModalOpen: boolean;
|
|
||||||
modalContent: React.ReactNode | null;
|
|
||||||
openModal: (content: React.ReactNode) => void;
|
|
||||||
closeModal: () => void;
|
|
||||||
|
|
||||||
// ========== 토스트/알림 ==========
|
|
||||||
toasts: Toast[];
|
|
||||||
addToast: (toast: Omit<Toast, "id">) => void;
|
|
||||||
removeToast: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [토스트 메시지 타입]
|
|
||||||
*/
|
|
||||||
export interface Toast {
|
|
||||||
id: string;
|
|
||||||
type: "success" | "error" | "warning" | "info";
|
|
||||||
message: string;
|
|
||||||
duration?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [UI 스토어]
|
|
||||||
*
|
|
||||||
* 전역 UI 상태를 관리합니다.
|
|
||||||
* - 테마 설정 (다크/라이트 모드)
|
|
||||||
* - 사이드바 열림/닫힘
|
|
||||||
* - 모달 상태
|
|
||||||
* - 토스트 알림
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* import { useUIStore } from '@/stores/ui-store';
|
|
||||||
*
|
|
||||||
* function Header() {
|
|
||||||
* const { theme, setTheme, toggleSidebar } = useUIStore();
|
|
||||||
*
|
|
||||||
* return (
|
|
||||||
* <header>
|
|
||||||
* <button onClick={toggleSidebar}>Menu</button>
|
|
||||||
* <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
|
|
||||||
* Toggle Theme
|
|
||||||
* </button>
|
|
||||||
* </header>
|
|
||||||
* );
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export const useUIStore = create<UIState>()(
|
|
||||||
persist(
|
|
||||||
(set) => ({
|
|
||||||
// ========== 테마 ==========
|
|
||||||
theme: "system",
|
|
||||||
setTheme: (theme) => set({ theme }),
|
|
||||||
|
|
||||||
// ========== 사이드바 ==========
|
|
||||||
isSidebarOpen: true,
|
|
||||||
toggleSidebar: () =>
|
|
||||||
set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),
|
|
||||||
setSidebarOpen: (isOpen) => set({ isSidebarOpen: isOpen }),
|
|
||||||
|
|
||||||
// ========== 모달 ==========
|
|
||||||
isModalOpen: false,
|
|
||||||
modalContent: null,
|
|
||||||
openModal: (content) => set({ isModalOpen: true, modalContent: content }),
|
|
||||||
closeModal: () => set({ isModalOpen: false, modalContent: null }),
|
|
||||||
|
|
||||||
// ========== 토스트 ==========
|
|
||||||
toasts: [],
|
|
||||||
addToast: (toast) =>
|
|
||||||
set((state) => ({
|
|
||||||
toasts: [
|
|
||||||
...state.toasts,
|
|
||||||
{
|
|
||||||
...toast,
|
|
||||||
id: `toast-${Date.now()}-${Math.random()}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})),
|
|
||||||
removeToast: (id) =>
|
|
||||||
set((state) => ({
|
|
||||||
toasts: state.toasts.filter((toast) => toast.id !== id),
|
|
||||||
})),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
name: "ui-storage", // localStorage 키 이름
|
|
||||||
// 일부 상태는 지속하지 않음 (모달, 토스트)
|
|
||||||
partialize: (state) => ({
|
|
||||||
theme: state.theme,
|
|
||||||
isSidebarOpen: state.isSidebarOpen,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
Reference in New Issue
Block a user