2 Commits

Author SHA1 Message Date
871f864dce 대시보드 중간 커밋 2026-02-10 15:13:35 +09:00
851a2acd69 대시보드 2026-02-06 17:50:35 +09:00
77 changed files with 51148 additions and 358 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
# Supabase 환경 설정 예제 파일 # Supabase 환경 설정 예제 파일
# 이 파일의 이름을 .env.local 변경한 뒤, 실제 값을 채워넣으세요. # 이 파일을 .env.local로 복사한 뒤 실제 값을 채워세요.
# 값 확인: https://supabase.com/dashboard/project/_/settings/api # 값 확인: https://supabase.com/dashboard/project/_/settings/api
NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_URL=
@@ -7,3 +7,17 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=
# 세션 타임아웃(분 단위) # 세션 타임아웃(분 단위)
NEXT_PUBLIC_SESSION_TIMEOUT_MINUTES=30 NEXT_PUBLIC_SESSION_TIMEOUT_MINUTES=30
# KIS 거래 모드: real(실전) | mock(모의)
KIS_TRADING_ENV=real
# 서버 기본 키를 쓰고 싶은 경우(선택)
KIS_APP_KEY_REAL=
KIS_APP_SECRET_REAL=
KIS_BASE_URL_REAL=https://openapi.koreainvestment.com:9443
KIS_WS_URL_REAL=ws://ops.koreainvestment.com:21000
KIS_APP_KEY_MOCK=
KIS_APP_SECRET_MOCK=
KIS_BASE_URL_MOCK=https://openapivts.koreainvestment.com:29443
KIS_WS_URL_MOCK=ws://ops.koreainvestment.com:31000

View File

@@ -0,0 +1 @@
{"real:7777b1c958636e65539aadf6c4273a0dfa33c17e55d1beb7dbfd033d49457f6e":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIsImF1ZCI6ImFkMGFjN2Y3LWY5YTYtNDRlNy1iOGRjLWU0ZjRhY2Q0YmQ2NyIsInByZHRfY2QiOiIiLCJpc3MiOiJ1bm9ndyIsImV4cCI6MTc3MDcwNzkzMiwiaWF0IjoxNzcwNjIxNTMyLCJqdGkiOiJQUzA2TG12SndjRHl1MDZLVXpPRjVsSHJacWR6ZWJKTXhCVWIifQ.P1_T4XoIxPDyNIxS3rx73IqlNLpZRmKKXOtan74A7D19On9JlsIQIpTY8bVGPFfRxFkC0vfCZ1qDX-xpxGi6SA","expiresAt":1770707932000}}

1
.tmp/open-trading-api Submodule

Submodule .tmp/open-trading-api added at aea5e779da

View File

@@ -1,222 +1,163 @@
/** /**
* @file app/(home)/page.tsx * @file app/(home)/page.tsx
* @description 서비스 메인 랜딩 페이지 * @description 서비스 메인 랜딩 페이지(Server Component)
* @remarks
* - [레이어] Pages (Server Component)
* - [역할] 비로그인 유저 유입, 브랜드 소개, 로그인/대시보드 유도
* - [구성요소] Header, Hero Section (Spline 3D), Feature Section (Bento Grid)
* - [데이터 흐름] Server Auth Check -> Client Component Props
*/ */
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button"; import { ArrowRight, BarChart3, ShieldCheck, Sparkles, Zap } from "lucide-react";
import { createClient } from "@/utils/supabase/server";
import { Header } from "@/features/layout/components/header"; import { Header } from "@/features/layout/components/header";
import { AUTH_ROUTES } from "@/features/auth/constants"; import { AUTH_ROUTES } from "@/features/auth/constants";
import { SplineScene } from "@/features/home/components/spline-scene"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import ShaderBackground from "@/components/ui/shader-background";
import { createClient } from "@/utils/supabase/server";
/** /**
* 메인 페이지 컴포넌트 (비동기 서버 컴포넌트) * 메인 랜딩 페이지
* @returns Landing Page Elements * @returns 랜딩 UI
* @see layout.tsx - RootLayout 내에서 렌더링 * @see features/layout/components/header.tsx blendWithBackground 모드 헤더를 함께 사용
* @see spline-scene.tsx - 3D 인터랙션
*/ */
export default async function HomePage() { export default async function HomePage() {
// [Step 1] 서버 사이드 인증 상태 확인 // [로그인 상태 조회] 사용자 유무에 따라 CTA 링크를 분기합니다.
const supabase = await createClient(); const supabase = await createClient();
const { const {
data: { user }, data: { user },
} = await supabase.auth.getUser(); } = await supabase.auth.getUser();
return ( return (
<div className="flex min-h-screen flex-col overflow-x-hidden"> <div className="flex min-h-screen flex-col overflow-x-hidden bg-transparent">
<Header user={user} showDashboardLink={true} /> <Header user={user} showDashboardLink={true} blendWithBackground={true} />
<main className="flex-1 bg-background pt-16"> <main className="relative isolate flex-1 pt-16">
{/* Background Pattern */} {/* ========== SHADER BACKGROUND SECTION ========== */}
<div className="absolute inset-0 -z-10 h-full w-full bg-white dark:bg-black bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-size-[14px_24px] mask-[radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]" /> <ShaderBackground opacity={1} className="-z-20" />
<section className="container relative mx-auto max-w-7xl px-4 pt-12 md:pt-24 lg:pt-32"> {/* ========== HERO SECTION ========== */}
<div className="flex flex-col items-center justify-center text-center"> <section className="container mx-auto max-w-7xl px-4 pb-10 pt-16 md:pt-24">
{/* Badge */} <div className="p-2 md:p-6">
<div className="mb-6 inline-flex items-center rounded-full border border-brand-200/50 bg-brand-50/50 px-3 py-1 text-sm font-medium text-brand-600 backdrop-blur-md dark:border-brand-800/50 dark:bg-brand-900/50 dark:text-brand-300"> <div className="mx-auto max-w-4xl text-center">
<span className="mr-2 flex h-2 w-2 animate-pulse rounded-full bg-brand-500"></span> <span className="inline-flex items-center gap-2 rounded-full px-4 py-1.5 text-xs font-semibold text-brand-200 [text-shadow:0_2px_24px_rgba(0,0,0,0.65)]">
The Future of Trading <Sparkles className="h-3.5 w-3.5" />
</div> Shader Background Landing
</span>
<h1 className="font-heading text-4xl font-black tracking-tight text-foreground sm:text-6xl md:text-7xl lg:text-8xl"> <h1 className="mt-5 text-4xl font-black tracking-tight text-white [text-shadow:0_4px_30px_rgba(0,0,0,0.6)] md:text-6xl">
<br className="hidden sm:block" />
<span className="animate-gradient-x bg-linear-to-r from-brand-500 via-brand-600 to-brand-700 bg-clip-text text-transparent underline decoration-brand-500/30 decoration-4 underline-offset-8"> <br />
<span className="bg-linear-to-r from-brand-300 via-brand-400 to-brand-500 bg-clip-text text-transparent">
</span> </span>
</h1> </h1>
<p className="mt-6 max-w-2xl text-lg text-muted-foreground sm:text-xl leading-relaxed break-keep"> <p className="mx-auto mt-5 max-w-2xl text-sm leading-relaxed text-white/80 [text-shadow:0_2px_16px_rgba(0,0,0,0.5)] md:text-lg">
AutoTrade는 24 , ,
. . .
<br className="hidden md:block" />
.
</p> </p>
<div className="mt-8 flex flex-col items-center gap-4 sm:flex-row"> <div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
{/* [분기 렌더] 로그인 사용자는 대시보드, 비로그인 사용자는 가입/로그인 동선을 노출합니다. */}
{user ? ( {user ? (
<Button <Button asChild size="lg" className="h-12 rounded-full bg-primary px-8 text-base hover:bg-primary/90">
asChild <Link href={AUTH_ROUTES.DASHBOARD}>
size="lg"
className="h-14 rounded-full px-10 text-lg font-bold shadow-xl shadow-brand-500/20 transition-all hover:scale-105 hover:shadow-brand-500/40" <ArrowRight className="ml-1.5 h-4 w-4" />
> </Link>
<Link href={AUTH_ROUTES.DASHBOARD}> </Link>
</Button> </Button>
) : ( ) : (
<Button <>
asChild <Button asChild size="lg" className="h-12 rounded-full bg-primary px-8 text-base hover:bg-primary/90">
size="lg" <Link href={AUTH_ROUTES.SIGNUP}>
className="h-14 rounded-full px-10 text-lg font-bold shadow-xl shadow-brand-500/20 transition-all hover:scale-105 hover:shadow-brand-500/40"
> <ArrowRight className="ml-1.5 h-4 w-4" />
<Link href={AUTH_ROUTES.LOGIN}> </Link> </Link>
</Button> </Button>
)}
{!user && (
<Button <Button
asChild asChild
variant="outline" variant="outline"
size="lg" size="lg"
className="h-14 rounded-full border-border bg-background/50 px-10 text-lg hover:bg-accent/50 backdrop-blur-sm" className="h-12 rounded-full border-white/40 bg-transparent px-8 text-base text-white hover:bg-white/10 hover:text-white"
> >
<Link href={AUTH_ROUTES.LOGIN}> </Link> <Link href={AUTH_ROUTES.LOGIN}></Link>
</Button> </Button>
</>
)} )}
</div> </div>
</div>
{/* Spline Scene - Centered & Wide */} <div className="mt-8 grid gap-3 sm:grid-cols-3">
<div className="relative mt-16 w-full max-w-5xl"> <div className="rounded-2xl p-4 text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.35)]">
<div className="group relative aspect-video w-full overflow-hidden rounded-3xl border border-white/20 bg-linear-to-b from-white/10 to-transparent shadow-2xl backdrop-blur-2xl dark:border-white/10 dark:bg-black/20"> <p className="text-xs text-white/70"> </p>
{/* Glow Effect */} <p className="mt-1 text-lg font-bold">Low Latency</p>
<div className="absolute -inset-1 rounded-3xl bg-linear-to-r from-brand-500 via-brand-600 to-brand-700 opacity-20 blur-2xl transition-opacity duration-500 group-hover:opacity-40" /> </div>
<div className="rounded-2xl p-4 text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.35)]">
<SplineScene <p className="text-xs text-white/70"></p>
sceneUrl="https://prod.spline.design/6Wq1Q7YGyM-iab9i/scene.splinecode" <p className="mt-1 text-lg font-bold"> </p>
className="relative z-10 h-full w-full rounded-2xl" </div>
/> <div className="rounded-2xl p-4 text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.35)]">
<p className="text-xs text-white/70"> </p>
<p className="mt-1 text-lg font-bold"> </p>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
{/* Features Section - Bento Grid */} {/* ========== FEATURE SECTION ========== */}
<section className="container mx-auto max-w-7xl px-4 py-24 sm:py-32"> <section className="container mx-auto max-w-7xl px-4 pb-16 md:pb-24">
<div className="mb-16 text-center"> <div className="grid gap-4 md:grid-cols-3">
<h2 className="font-heading text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl"> <Card className="border-0 bg-transparent text-white shadow-none">
,{" "} <CardHeader>
<span className="text-brand-500"> </span> <div className="mb-2 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-brand-400/20 text-brand-200">
<BarChart3 className="h-5 w-5" />
</div>
<CardTitle className="text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.4)]"> </CardTitle>
</CardHeader>
<CardContent className="text-sm leading-relaxed text-white/75">
.
</CardContent>
</Card>
<Card className="border-0 bg-transparent text-white shadow-none">
<CardHeader>
<div className="mb-2 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-brand-400/20 text-brand-200">
<Zap className="h-5 w-5" />
</div>
<CardTitle className="text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.4)]"> </CardTitle>
</CardHeader>
<CardContent className="text-sm leading-relaxed text-white/75">
.
</CardContent>
</Card>
<Card className="border-0 bg-transparent text-white shadow-none">
<CardHeader>
<div className="mb-2 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-brand-400/20 text-brand-200">
<ShieldCheck className="h-5 w-5" />
</div>
<CardTitle className="text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.4)]"> </CardTitle>
</CardHeader>
<CardContent className="text-sm leading-relaxed text-white/75">
.
</CardContent>
</Card>
</div>
</section>
{/* ========== CTA SECTION ========== */}
<section className="container mx-auto max-w-7xl px-4 pb-20">
<div className="p-2 md:p-4">
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<div>
<p className="text-sm font-semibold text-brand-200 [text-shadow:0_2px_18px_rgba(0,0,0,0.45)]"> </p>
<h2 className="mt-1 text-2xl font-bold tracking-tight text-white [text-shadow:0_2px_18px_rgba(0,0,0,0.45)] md:text-3xl">
AutoTrade에서
</h2> </h2>
<p className="mt-4 text-lg text-muted-foreground">
.
</p>
</div> </div>
<Button asChild className="h-11 rounded-full bg-primary px-7 hover:bg-primary/90">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 md:gap-8"> <Link href={user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.SIGNUP}>
{/* Feature 1 */} {user ? "대시보드 열기" : "회원가입 시작"}
<div className="group relative col-span-1 overflow-hidden rounded-3xl border border-border/50 bg-background/50 p-8 shadow-sm transition-all hover:shadow-xl hover:-translate-y-1 dark:bg-zinc-900/50 md:col-span-2"> <ArrowRight className="ml-1.5 h-4 w-4" />
<div className="relative z-10 flex flex-col justify-between h-full gap-6"> </Link>
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-50 text-brand-600 dark:bg-brand-900/20 dark:text-brand-300"> </Button>
<svg
className="w-8 h-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
/>
</svg>
</div>
<div>
<h3 className="text-2xl font-bold mb-2"> </h3>
<p className="text-muted-foreground text-lg leading-relaxed">
.
<br />
.
</p>
</div>
</div>
<div className="absolute top-0 right-0 h-64 w-64 translate-x-1/2 -translate-y-1/2 rounded-full bg-brand-500/10 blur-3xl transition-all duration-500 group-hover:bg-brand-500/20" />
</div>
{/* Feature 2 (Tall) */}
<div className="group relative col-span-1 row-span-2 overflow-hidden rounded-3xl border border-border/50 bg-background/50 p-8 shadow-sm transition-all hover:shadow-xl hover:-translate-y-1 dark:bg-zinc-900/50">
<div className="relative z-10 flex flex-col h-full gap-6">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-50 text-brand-600 dark:bg-brand-900/20 dark:text-brand-300">
<svg
className="w-8 h-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19.428 15.428a2 2 0 00-1.022-.547l-2.384-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"
/>
</svg>
</div>
<h3 className="text-2xl font-bold"> </h3>
<p className="text-muted-foreground text-lg">
24 .
</p>
<div className="mt-auto space-y-4 pt-4">
{[
"추세 추종 전략",
"변동성 돌파",
"AI 예측 모델",
"리스크 관리",
].map((item) => (
<div
key={item}
className="flex items-center gap-3 text-sm font-medium text-foreground/80"
>
<div className="h-2 w-2 rounded-full bg-brand-500" />
{item}
</div>
))}
</div>
</div>
<div className="absolute bottom-0 left-0 h-64 w-64 -translate-x-1/2 translate-y-1/2 rounded-full bg-brand-500/10 blur-3xl transition-all duration-500 group-hover:bg-brand-500/20" />
</div>
{/* Feature 3 */}
<div className="group relative col-span-1 overflow-hidden rounded-3xl border border-border/50 bg-background/50 p-8 shadow-sm transition-all hover:shadow-xl hover:-translate-y-1 dark:bg-zinc-900/50 md:col-span-2">
<div className="relative z-10 flex flex-col justify-between h-full gap-6">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-50 text-brand-600 dark:bg-brand-900/20 dark:text-brand-300">
<svg
className="w-8 h-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
</div>
<div>
<h3 className="text-2xl font-bold mb-2"> </h3>
<p className="text-muted-foreground text-lg leading-relaxed">
, MDD를
<br />
.
</p>
</div>
</div>
<div className="absolute right-0 bottom-0 h-40 w-40 translate-x-1/3 translate-y-1/3 rounded-full bg-brand-500/10 blur-3xl transition-all duration-500 group-hover:bg-brand-500/20" />
</div> </div>
</div> </div>
</section> </section>

View File

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

View File

@@ -15,7 +15,7 @@ export default async function MainLayout({
return ( return (
<div className="flex min-h-screen flex-col bg-zinc-50 dark:bg-black"> <div className="flex min-h-screen flex-col bg-zinc-50 dark:bg-black">
<Header user={user} /> <Header user={user} />
<div className="flex flex-1"> <div className="flex flex-1 pt-16">
<Sidebar /> <Sidebar />
<main className="flex-1 w-full p-6 md:p-8 lg:p-10">{children}</main> <main className="flex-1 w-full p-6 md:p-8 lg:p-10">{children}</main>
</div> </div>

View File

@@ -0,0 +1,98 @@
import type {
DashboardChartTimeframe,
DashboardStockChartResponse,
} from "@/features/dashboard/types/dashboard.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticChart } from "@/lib/kis/domestic";
import { NextRequest, NextResponse } from "next/server";
const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
"1m",
"30m",
"1h",
"1d",
"1w",
];
/**
* @file app/api/kis/domestic/chart/route.ts
* @description 국내주식 차트(분봉/일봉/주봉) 조회 API
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim();
const timeframe = (
searchParams.get("timeframe") ?? "1d"
).trim() as DashboardChartTimeframe;
const cursor = (searchParams.get("cursor") ?? "").trim() || undefined;
if (!/^\d{6}$/.test(symbol)) {
return NextResponse.json(
{ error: "symbol은 6자리 숫자여야 합니다." },
{ status: 400 },
);
}
if (!VALID_TIMEFRAMES.includes(timeframe)) {
return NextResponse.json(
{ error: "지원하지 않는 timeframe입니다." },
{ status: 400 },
);
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error:
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 차트를 조회할 수 없습니다.",
},
{ status: 400 },
);
}
try {
const chart = await getDomesticChart(
symbol,
timeframe,
credentials,
cursor,
);
const response: DashboardStockChartResponse = {
symbol,
timeframe,
candles: chart.candles,
nextCursor: chart.nextCursor,
hasMore: chart.hasMore,
fetchedAt: new Date().toISOString(),
};
return NextResponse.json(response, {
headers: {
"cache-control": "no-store",
},
});
} catch (error) {
const message =
error instanceof Error
? error.message
: "KIS 차트 조회 중 오류가 발생했습니다.";
return NextResponse.json({ error: message }, { status: 500 });
}
}
function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
const appKey = headers.get("x-kis-app-key")?.trim();
const appSecret = headers.get("x-kis-app-secret")?.trim();
const tradingEnv = normalizeTradingEnv(
headers.get("x-kis-trading-env") ?? undefined,
);
return {
appKey,
appSecret,
tradingEnv,
};
}

View File

@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from "next/server";
import { executeOrderCash } from "@/lib/kis/trade";
import {
DashboardStockCashOrderRequest,
DashboardStockCashOrderResponse,
} from "@/features/dashboard/types/dashboard.types";
import {
KisCredentialInput,
hasKisConfig,
normalizeTradingEnv,
} from "@/lib/kis/config";
/**
* @file app/api/kis/domestic/order-cash/route.ts
* @description 국내주식 현금 주문 API
*/
export async function POST(request: NextRequest) {
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message: "KIS API 키 설정이 필요합니다.",
},
{ status: 400 },
);
}
try {
const body = (await request.json()) as DashboardStockCashOrderRequest;
// TODO: Validate body fields (symbol, quantity, price, etc.)
if (
!body.symbol ||
!body.accountNo ||
!body.accountProductCode ||
body.quantity <= 0
) {
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message:
"주문 정보가 올바르지 않습니다. (종목코드, 계좌번호, 수량 확인)",
},
{ status: 400 },
);
}
const output = await executeOrderCash(
{
symbol: body.symbol,
side: body.side,
orderType: body.orderType,
quantity: body.quantity,
price: body.price,
accountNo: body.accountNo,
accountProductCode: body.accountProductCode,
},
credentials,
);
const response: DashboardStockCashOrderResponse = {
ok: true,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message: "주문이 전송되었습니다.",
orderNo: output.ODNO,
orderTime: output.ORD_TMD,
orderOrgNo: output.KRX_FWDG_ORD_ORGNO,
};
return NextResponse.json(response);
} catch (error) {
const message =
error instanceof Error
? error.message
: "주문 전송 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message,
},
{ status: 500 },
);
}
}
function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
const appKey = headers.get("x-kis-app-key")?.trim();
const appSecret = headers.get("x-kis-app-secret")?.trim();
const tradingEnv = normalizeTradingEnv(
headers.get("x-kis-trading-env") ?? undefined,
);
return {
appKey,
appSecret,
tradingEnv,
};
}

View File

@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from "next/server";
import {
getDomesticOrderBook,
KisDomesticOrderBookOutput,
} from "@/lib/kis/domestic";
import { DashboardStockOrderBookResponse } from "@/features/dashboard/types/dashboard.types";
import {
KisCredentialInput,
hasKisConfig,
normalizeTradingEnv,
} from "@/lib/kis/config";
/**
* @file app/api/kis/domestic/orderbook/route.ts
* @description 국내주식 호가 조회 API
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim();
if (!/^\d{6}$/.test(symbol)) {
return NextResponse.json(
{ error: "symbol은 6자리 숫자여야 합니다." },
{ status: 400 },
);
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error: "KIS API 키 설정이 필요합니다.",
},
{ status: 400 },
);
}
try {
const raw = await getDomesticOrderBook(symbol, credentials);
const levels = Array.from({ length: 10 }, (_, i) => {
const idx = i + 1;
return {
askPrice: readOrderBookNumber(raw, `askp${idx}`),
bidPrice: readOrderBookNumber(raw, `bidp${idx}`),
askSize: readOrderBookNumber(raw, `askp_rsqn${idx}`),
bidSize: readOrderBookNumber(raw, `bidp_rsqn${idx}`),
};
});
const response: DashboardStockOrderBookResponse = {
symbol,
source: "kis",
levels,
totalAskSize: readOrderBookNumber(raw, "total_askp_rsqn"),
totalBidSize: readOrderBookNumber(raw, "total_bidp_rsqn"),
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
fetchedAt: new Date().toISOString(),
};
return NextResponse.json(response, {
headers: {
"cache-control": "no-store",
},
});
} catch (error) {
const message =
error instanceof Error
? error.message
: "호가 조회 중 오류가 발생했습니다.";
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,
};
}
/**
* @description 호가 응답 필드를 대소문자 모두 허용해 숫자로 읽습니다.
* @see app/api/kis/domestic/orderbook/route.ts GET에서 output/output1 키 차이 방어 로직으로 사용합니다.
*/
function readOrderBookNumber(raw: KisDomesticOrderBookOutput, key: string) {
const record = raw as Record<string, unknown>;
const direct = record[key];
const upper = record[key.toUpperCase()];
const value = direct ?? upper ?? "0";
const normalized =
typeof value === "string"
? value.replaceAll(",", "").trim()
: String(value ?? "0");
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : 0;
}

View File

@@ -0,0 +1,78 @@
import { KOREAN_STOCK_INDEX } from "@/features/dashboard/data/korean-stocks";
import type { DashboardStockOverviewResponse } from "@/features/dashboard/types/dashboard.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticOverview } from "@/lib/kis/domestic";
import { NextRequest, NextResponse } from "next/server";
/**
* @file app/api/kis/domestic/overview/route.ts
* @description 국내주식 종목 상세(현재가 + 차트) API
*/
/**
* 국내주식 종목 상세 API
* @param request query string의 symbol(6자리 종목코드) 사용
* @returns 대시보드 상세 모델
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim();
if (!/^\d{6}$/.test(symbol)) {
return NextResponse.json({ error: "symbol은 6자리 숫자여야 합니다." }, { status: 400 });
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error:
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 시세를 조회할 수 없습니다.",
},
{ status: 400 },
);
}
const fallbackMeta = KOREAN_STOCK_INDEX.find((item) => item.symbol === symbol);
try {
const overview = await getDomesticOverview(symbol, fallbackMeta, credentials);
const response: DashboardStockOverviewResponse = {
stock: overview.stock,
source: "kis",
priceSource: overview.priceSource,
marketPhase: overview.marketPhase,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
fetchedAt: new Date().toISOString(),
};
return NextResponse.json(response, {
headers: {
"cache-control": "no-store",
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "KIS 조회 중 오류가 발생했습니다.";
return NextResponse.json({ error: message }, { status: 500 });
}
}
/**
* 요청 헤더에서 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,
};
}

View File

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

View File

@@ -0,0 +1,64 @@
import type { DashboardKisRevokeResponse } from "@/features/dashboard/types/dashboard.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { revokeKisAccessToken } from "@/lib/kis/token";
import { NextRequest, NextResponse } from "next/server";
/**
* @file app/api/kis/revoke/route.ts
* @description 사용자 입력 KIS API 키 기반 접근토큰 폐기 라우트
*/
/**
* KIS API 접근토큰 폐기
* @param request appKey/appSecret/tradingEnv JSON 본문
* @returns 폐기 성공/실패 정보
* @see features/dashboard/components/dashboard-main.tsx handleRevokeKis - 접근 폐기 버튼 클릭 이벤트
*/
export async function POST(request: NextRequest) {
const body = (await request.json()) as Partial<DashboardKisRevokeRequest>;
const credentials: KisCredentialInput = {
appKey: body.appKey?.trim(),
appSecret: body.appSecret?.trim(),
tradingEnv: normalizeTradingEnv(body.tradingEnv),
};
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message: "앱 키와 앱 시크릿을 모두 입력해 주세요.",
} satisfies DashboardKisRevokeResponse,
{ status: 400 },
);
}
try {
const message = await revokeKisAccessToken(credentials);
return NextResponse.json({
ok: true,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message,
} satisfies DashboardKisRevokeResponse);
} catch (error) {
const message = error instanceof Error ? error.message : "API 키 접근 폐기 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message,
} satisfies DashboardKisRevokeResponse,
{ status: 401 },
);
}
}
interface DashboardKisRevokeRequest {
appKey: string;
appSecret: string;
tradingEnv: "real" | "mock";
}

View File

@@ -0,0 +1,65 @@
import type { DashboardKisValidateResponse } from "@/features/dashboard/types/dashboard.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getKisAccessToken } from "@/lib/kis/token";
import { NextRequest, NextResponse } from "next/server";
/**
* @file app/api/kis/validate/route.ts
* @description 사용자 입력 KIS API 키 검증 라우트
*/
/**
* KIS API 키 검증
* @param request appKey/appSecret/tradingEnv JSON 본문
* @returns 검증 성공/실패 정보
* @see features/dashboard/components/dashboard-main.tsx handleValidateKis - 검증 버튼 클릭 시 호출
*/
export async function POST(request: NextRequest) {
const body = (await request.json()) as Partial<DashboardKisValidateRequest>;
const credentials: KisCredentialInput = {
appKey: body.appKey?.trim(),
appSecret: body.appSecret?.trim(),
tradingEnv: normalizeTradingEnv(body.tradingEnv),
};
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message: "앱 키와 앱 시크릿을 모두 입력해 주세요.",
} satisfies DashboardKisValidateResponse,
{ status: 400 },
);
}
try {
// 검증 단계는 토큰 발급 성공 여부만 확인합니다.
await getKisAccessToken(credentials);
return NextResponse.json({
ok: true,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message: "API 키 검증이 완료되었습니다. (토큰 발급 성공)",
} satisfies DashboardKisValidateResponse);
} catch (error) {
const message = error instanceof Error ? error.message : "API 키 검증 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message,
} satisfies DashboardKisValidateResponse,
{ status: 401 },
);
}
}
interface DashboardKisValidateRequest {
appKey: string;
appSecret: string;
tradingEnv: "real" | "mock";
}

View File

@@ -0,0 +1,67 @@
import type { DashboardKisWsApprovalResponse } from "@/features/dashboard/types/dashboard.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getKisApprovalKey, resolveKisWebSocketUrl } from "@/lib/kis/approval";
import { NextRequest, NextResponse } from "next/server";
/**
* @file app/api/kis/ws/approval/route.ts
* @description KIS 웹소켓 approval key 발급 라우트
*/
/**
* 실시간 웹소켓 승인키 발급
* @param request appKey/appSecret/tradingEnv JSON 본문
* @returns approval key + ws url
* @see features/dashboard/components/dashboard-main.tsx connectKisRealtimePrice - 실시간 체결가 구독 진입점
*/
export async function POST(request: NextRequest) {
const body = (await request.json()) as Partial<DashboardKisWsApprovalRequest>;
const credentials: KisCredentialInput = {
appKey: body.appKey?.trim(),
appSecret: body.appSecret?.trim(),
tradingEnv: normalizeTradingEnv(body.tradingEnv),
};
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message: "앱 키와 앱 시크릿을 모두 입력해 주세요.",
} satisfies DashboardKisWsApprovalResponse,
{ status: 400 },
);
}
try {
const approvalKey = await getKisApprovalKey(credentials);
const wsUrl = resolveKisWebSocketUrl(credentials);
return NextResponse.json({
ok: true,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
approvalKey,
wsUrl,
message: "KIS 실시간 웹소켓 승인키 발급이 완료되었습니다.",
} satisfies DashboardKisWsApprovalResponse);
} catch (error) {
const message = error instanceof Error ? error.message : "웹소켓 승인키 발급 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message,
} satisfies DashboardKisWsApprovalResponse,
{ status: 401 },
);
}
}
interface DashboardKisWsApprovalRequest {
appKey: string;
appSecret: string;
tradingEnv: "real" | "mock";
}

View File

@@ -13,6 +13,7 @@ import { Geist, Geist_Mono, Outfit } from "next/font/google";
import { QueryProvider } from "@/providers/query-provider"; import { QueryProvider } from "@/providers/query-provider";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { SessionManager } from "@/features/auth/components/session-manager"; import { SessionManager } from "@/features/auth/components/session-manager";
import { Toaster } from "sonner";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const geistSans = Geist({
@@ -61,6 +62,13 @@ export default function RootLayout({
> >
<SessionManager /> <SessionManager />
<QueryProvider>{children}</QueryProvider> <QueryProvider>{children}</QueryProvider>
<Toaster
richColors
position="top-right"
toastOptions={{
duration: 4000,
}}
/>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>

View File

@@ -12,6 +12,7 @@
import * as React from "react"; import * as React from "react";
import { Moon, Sun } from "lucide-react"; import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -21,23 +22,38 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
interface ThemeToggleProps {
className?: string;
iconClassName?: string;
}
/** /**
* 테마 토글 컴포넌트 * 테마 토글 컴포넌트
* @remarks next-themes의 useTheme 훅 사용 * @remarks next-themes의 useTheme 훅 사용
* @returns Dropdown 메뉴 형태의 테마 선택기 * @returns Dropdown 메뉴 형태의 테마 선택기
*/ */
export function ThemeToggle() { export function ThemeToggle({ className, iconClassName }: ThemeToggleProps) {
const { setTheme } = useTheme(); const { setTheme } = useTheme();
return ( return (
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
{/* ========== 트리거 버튼 ========== */} {/* ========== 트리거 버튼 ========== */}
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon" className={className}>
{/* 라이트 모드 아이콘 (회전 애니메이션) */} {/* 라이트 모드 아이콘 (회전 애니메이션) */}
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> <Sun
className={cn(
"h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0",
iconClassName,
)}
/>
{/* 다크 모드 아이콘 (회전 애니메이션) */} {/* 다크 모드 아이콘 (회전 애니메이션) */}
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> <Moon
className={cn(
"absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100",
iconClassName,
)}
/>
<span className="sr-only">Toggle theme</span> <span className="sr-only">Toggle theme</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -0,0 +1,82 @@
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardKisRevokeResponse,
DashboardKisValidateResponse,
DashboardKisWsApprovalResponse,
} from "@/features/dashboard/types/dashboard.types";
/**
* KIS API 키 검증 요청
* @param credentials 검증할 키 정보
*/
export async function validateKisCredentials(
credentials: KisRuntimeCredentials,
): Promise<DashboardKisValidateResponse> {
const response = await fetch("/api/kis/validate", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(credentials),
cache: "no-store",
});
const payload = (await response.json()) as DashboardKisValidateResponse;
if (!response.ok || !payload.ok) {
throw new Error(payload.message || "API 키 검증에 실패했습니다.");
}
return payload;
}
/**
* KIS 접근토큰 폐기 요청
* @param credentials 폐기할 키 정보
*/
export async function revokeKisCredentials(
credentials: KisRuntimeCredentials,
): Promise<DashboardKisRevokeResponse> {
const response = await fetch("/api/kis/revoke", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(credentials),
cache: "no-store",
});
const payload = (await response.json()) as DashboardKisRevokeResponse;
if (!response.ok || !payload.ok) {
throw new Error(payload.message || "API 키 접근 폐기에 실패했습니다.");
}
return payload;
}
/**
* KIS 실시간 웹소켓 승인키 발급 요청
* @param credentials 인증 정보
*/
export async function fetchKisWebSocketApproval(
credentials: KisRuntimeCredentials,
): Promise<DashboardKisWsApprovalResponse> {
const response = await fetch("/api/kis/ws/approval", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(credentials),
cache: "no-store",
});
const payload = (await response.json()) as DashboardKisWsApprovalResponse;
if (!response.ok || !payload.ok || !payload.approvalKey || !payload.wsUrl) {
throw new Error(
payload.message || "KIS 실시간 웹소켓 승인키 발급에 실패했습니다.",
);
}
return payload;
}

View File

@@ -0,0 +1,179 @@
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardChartTimeframe,
DashboardStockCashOrderRequest,
DashboardStockCashOrderResponse,
DashboardStockChartResponse,
DashboardStockOrderBookResponse,
DashboardStockOverviewResponse,
DashboardStockSearchResponse,
} from "@/features/dashboard/types/dashboard.types";
/**
* 종목 검색 API 호출
* @param keyword 검색어
*/
export async function fetchStockSearch(
keyword: string,
signal?: AbortSignal,
): Promise<DashboardStockSearchResponse> {
const response = await fetch(
`/api/kis/domestic/search?q=${encodeURIComponent(keyword)}`,
{
cache: "no-store",
signal,
},
);
const payload = (await response.json()) as
| DashboardStockSearchResponse
| { error?: string };
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "종목 검색 중 오류가 발생했습니다.",
);
}
return payload as DashboardStockSearchResponse;
}
/**
* 종목 상세 개요 조회 API 호출
* @param symbol 종목코드
* @param credentials KIS 인증 정보
*/
export async function fetchStockOverview(
symbol: string,
credentials: KisRuntimeCredentials,
): Promise<DashboardStockOverviewResponse> {
const response = await fetch(
`/api/kis/domestic/overview?symbol=${encodeURIComponent(symbol)}`,
{
method: "GET",
headers: {
"x-kis-app-key": credentials.appKey,
"x-kis-app-secret": credentials.appSecret,
"x-kis-trading-env": credentials.tradingEnv,
},
cache: "no-store",
},
);
const payload = (await response.json()) as
| DashboardStockOverviewResponse
| { error?: string };
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "종목 조회 중 오류가 발생했습니다.",
);
}
return payload as DashboardStockOverviewResponse;
}
/**
* 종목 호가 조회 API 호출
* @param symbol 종목코드
* @param credentials KIS 인증 정보
*/
export async function fetchStockOrderBook(
symbol: string,
credentials: KisRuntimeCredentials,
signal?: AbortSignal,
): Promise<DashboardStockOrderBookResponse> {
const response = await fetch(
`/api/kis/domestic/orderbook?symbol=${encodeURIComponent(symbol)}`,
{
method: "GET",
headers: {
"x-kis-app-key": credentials.appKey,
"x-kis-app-secret": credentials.appSecret,
"x-kis-trading-env": credentials.tradingEnv,
},
cache: "no-store", // 호가는 실시간성이 중요하므로 항상 최신 데이터 조회
signal,
},
);
const payload = (await response.json()) as
| DashboardStockOrderBookResponse
| { error?: string };
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "호가 조회 중 오류가 발생했습니다.",
);
}
return payload as DashboardStockOrderBookResponse;
}
/**
* 종목 차트(분봉/일봉/주봉) 조회 API 호출
*/
export async function fetchStockChart(
symbol: string,
timeframe: DashboardChartTimeframe,
credentials: KisRuntimeCredentials,
cursor?: string,
): Promise<DashboardStockChartResponse> {
const query = new URLSearchParams({
symbol,
timeframe,
});
if (cursor) query.set("cursor", cursor);
const response = await fetch(`/api/kis/domestic/chart?${query.toString()}`, {
method: "GET",
headers: {
"x-kis-app-key": credentials.appKey,
"x-kis-app-secret": credentials.appSecret,
"x-kis-trading-env": credentials.tradingEnv,
},
cache: "no-store",
});
const payload = (await response.json()) as
| DashboardStockChartResponse
| { error?: string };
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "차트 조회 중 오류가 발생했습니다.",
);
}
return payload as DashboardStockChartResponse;
}
/**
* 주식 현금 주문 API 호출
* @param request 주문 요청 데이터
* @param credentials KIS 인증 정보
*/
export async function fetchOrderCash(
request: DashboardStockCashOrderRequest,
credentials: KisRuntimeCredentials,
): Promise<DashboardStockCashOrderResponse> {
const response = await fetch("/api/kis/domestic/order-cash", {
method: "POST",
headers: {
"content-type": "application/json",
"x-kis-app-key": credentials.appKey,
"x-kis-app-secret": credentials.appSecret,
"x-kis-trading-env": credentials.tradingEnv,
},
body: JSON.stringify(request),
cache: "no-store",
});
const payload = (await response.json()) as DashboardStockCashOrderResponse;
if (!response.ok) {
throw new Error(payload.message || "주문 전송 중 오류가 발생했습니다.");
}
return payload;
}

View File

@@ -0,0 +1,279 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { cn } from "@/lib/utils";
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
import { KisAuthForm } from "@/features/dashboard/components/auth/KisAuthForm";
import { StockSearchForm } from "@/features/dashboard/components/search/StockSearchForm";
import { StockSearchResults } from "@/features/dashboard/components/search/StockSearchResults";
import { useStockSearch } from "@/features/dashboard/hooks/useStockSearch";
import { useOrderBook } from "@/features/dashboard/hooks/useOrderBook";
import { useKisTradeWebSocket } from "@/features/dashboard/hooks/useKisTradeWebSocket";
import { useStockOverview } from "@/features/dashboard/hooks/useStockOverview";
import { DashboardLayout } from "@/features/dashboard/components/layout/DashboardLayout";
import { StockHeader } from "@/features/dashboard/components/header/StockHeader";
import { OrderBook } from "@/features/dashboard/components/orderbook/OrderBook";
import { OrderForm } from "@/features/dashboard/components/order/OrderForm";
import { StockLineChart } from "@/features/dashboard/components/chart/StockLineChart";
import type {
DashboardStockItem,
DashboardStockOrderBookResponse,
DashboardStockSearchItem,
} from "@/features/dashboard/types/dashboard.types";
/**
* @description 대시보드 메인 컨테이너
* @see app/(main)/dashboard/page.tsx 로그인 완료 후 이 컴포넌트를 렌더링합니다.
* @see features/dashboard/hooks/useStockSearch.ts 검색 입력/요청 상태를 관리합니다.
* @see features/dashboard/hooks/useStockOverview.ts 선택 종목 상세 상태를 관리합니다.
*/
export function DashboardContainer() {
const skipNextAutoSearchRef = useRef(false);
const { verifiedCredentials, isKisVerified } = useKisRuntimeStore(
useShallow((state) => ({
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
})),
);
const {
keyword,
setKeyword,
searchResults,
setSearchResults,
setError: setSearchError,
isSearching,
search,
clearSearch,
} = useStockSearch();
const { selectedStock, loadOverview, updateRealtimeTradeTick } =
useStockOverview();
// 호가 실시간 데이터 (체결 WS에서 동일 소켓으로 수신)
const [realtimeOrderBook, setRealtimeOrderBook] =
useState<DashboardStockOrderBookResponse | null>(null);
const handleOrderBookMessage = useCallback(
(data: DashboardStockOrderBookResponse) => {
setRealtimeOrderBook(data);
},
[],
);
// 1. Trade WebSocket (체결 + 호가 통합)
const { latestTick, realtimeCandles, recentTradeTicks } =
useKisTradeWebSocket(
selectedStock?.symbol,
verifiedCredentials,
isKisVerified,
updateRealtimeTradeTick,
{
orderBookSymbol: selectedStock?.symbol,
onOrderBookMessage: handleOrderBookMessage,
},
);
// 2. OrderBook (REST 초기 조회 + WS 실시간 병합)
const { orderBook, isLoading: isOrderBookLoading } = useOrderBook(
selectedStock?.symbol,
selectedStock?.market,
verifiedCredentials,
isKisVerified,
{
enabled: !!selectedStock && !!verifiedCredentials && isKisVerified,
externalRealtimeOrderBook: realtimeOrderBook,
},
);
useEffect(() => {
if (skipNextAutoSearchRef.current) {
skipNextAutoSearchRef.current = false;
return;
}
if (!isKisVerified || !verifiedCredentials) {
clearSearch();
return;
}
const trimmed = keyword.trim();
if (!trimmed) {
clearSearch();
return;
}
const timer = window.setTimeout(() => {
search(trimmed, verifiedCredentials);
}, 220);
return () => window.clearTimeout(timer);
}, [keyword, isKisVerified, verifiedCredentials, search, clearSearch]);
// Price Calculation Logic
// Prioritize latestTick (Real Exec) > OrderBook Ask1 (Proxy) > REST Data
let currentPrice = selectedStock?.currentPrice;
let change = selectedStock?.change;
let changeRate = selectedStock?.changeRate;
if (latestTick) {
currentPrice = latestTick.price;
change = latestTick.change;
changeRate = latestTick.changeRate;
} else if (orderBook?.levels[0]?.askPrice) {
// Fallback: Use Best Ask Price as proxy for current price
const askPrice = orderBook.levels[0].askPrice;
if (askPrice > 0) {
currentPrice = askPrice;
// Recalculate change/rate based on prevClose
if (selectedStock && selectedStock.prevClose > 0) {
change = currentPrice - selectedStock.prevClose;
changeRate = (change / selectedStock.prevClose) * 100;
}
}
}
function handleSearchSubmit(e: React.FormEvent) {
e.preventDefault();
if (!isKisVerified || !verifiedCredentials) {
setSearchError("API 키 검증을 먼저 완료해 주세요.");
return;
}
search(keyword, verifiedCredentials);
}
function handleSelectStock(item: DashboardStockSearchItem) {
if (!isKisVerified || !verifiedCredentials) {
setSearchError("API 키 검증을 먼저 완료해 주세요.");
return;
}
// 이미 선택된 종목을 다시 누른 경우 불필요한 개요 API 재호출을 막습니다.
if (selectedStock?.symbol === item.symbol) {
setSearchResults([]);
return;
}
// 카드 선택으로 keyword가 바뀔 때 자동 검색이 다시 돌지 않도록 1회 건너뜁니다.
skipNextAutoSearchRef.current = true;
setKeyword(item.name);
setSearchResults([]);
loadOverview(item.symbol, verifiedCredentials, item.market);
}
return (
<div className="relative h-full flex flex-col">
{/* ========== AUTH STATUS ========== */}
<div className="flex-none border-b bg-muted/40 transition-all duration-300 ease-in-out">
<div className="flex items-center justify-between px-4 py-2 text-xs">
<div className="flex items-center gap-2">
<span className="font-semibold">KIS API :</span>
{isKisVerified ? (
<span className="text-green-600 font-medium flex items-center">
<span className="mr-1.5 h-2 w-2 rounded-full bg-green-500 ring-2 ring-green-100" />
(
{verifiedCredentials?.tradingEnv === "real" ? "실전" : "모의"})
</span>
) : (
<span className="text-muted-foreground flex items-center">
<span className="mr-1.5 h-2 w-2 rounded-full bg-gray-300" />
</span>
)}
</div>
</div>
<div className="overflow-hidden transition-[max-height,opacity] duration-300 ease-in-out max-h-[500px] opacity-100">
<div className="p-4 border-t bg-background">
<KisAuthForm />
</div>
</div>
</div>
{/* ========== SEARCH ========== */}
<div className="flex-none p-4 border-b bg-background/95 backdrop-blur-sm z-30">
<div className="max-w-2xl mx-auto space-y-2 relative">
<StockSearchForm
keyword={keyword}
onKeywordChange={setKeyword}
onSubmit={handleSearchSubmit}
disabled={!isKisVerified}
isLoading={isSearching}
/>
{searchResults.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 z-50 bg-background border rounded-md shadow-lg max-h-80 overflow-y-auto overflow-x-hidden">
<StockSearchResults
items={searchResults}
onSelect={handleSelectStock}
selectedSymbol={
(selectedStock as DashboardStockItem | null)?.symbol
}
/>
</div>
)}
</div>
</div>
{/* ========== MAIN CONTENT ========== */}
<div
className={cn(
"transition-opacity duration-200 h-full flex-1 min-h-0 overflow-x-hidden",
!selectedStock && "opacity-20 pointer-events-none",
)}
>
<DashboardLayout
header={
selectedStock ? (
<StockHeader
stock={selectedStock}
price={currentPrice?.toLocaleString() ?? "0"}
change={change?.toLocaleString() ?? "0"}
changeRate={changeRate?.toFixed(2) ?? "0.00"}
high={latestTick ? latestTick.high.toLocaleString() : undefined} // High/Low/Vol only from Tick or Static
low={latestTick ? latestTick.low.toLocaleString() : undefined}
volume={
latestTick
? latestTick.accumulatedVolume.toLocaleString()
: undefined
}
/>
) : null
}
chart={
selectedStock ? (
<div className="p-0 h-full flex flex-col">
<StockLineChart
symbol={selectedStock.symbol}
candles={
realtimeCandles.length > 0
? realtimeCandles
: selectedStock.candles
}
credentials={verifiedCredentials}
/>
</div>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
</div>
)
}
orderBook={
<OrderBook
symbol={selectedStock?.symbol}
referencePrice={selectedStock?.prevClose}
currentPrice={currentPrice}
latestTick={latestTick}
recentTicks={recentTradeTicks}
orderBook={orderBook}
isLoading={isOrderBookLoading}
/>
}
orderForm={<OrderForm stock={selectedStock ?? undefined} />}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,230 @@
import { useState, useTransition } from "react";
import { useShallow } from "zustand/react/shallow";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
import {
revokeKisCredentials,
validateKisCredentials,
} from "@/features/dashboard/apis/kis-auth.api";
/**
* @description KIS 인증 입력 폼
* @see features/dashboard/store/use-kis-runtime-store.ts 인증 입력값/검증 상태를 저장합니다.
*/
export function KisAuthForm() {
const {
kisTradingEnvInput,
kisAppKeyInput,
kisAppSecretInput,
verifiedCredentials,
isKisVerified,
setKisTradingEnvInput,
setKisAppKeyInput,
setKisAppSecretInput,
setVerifiedKisSession,
invalidateKisVerification,
clearKisRuntimeSession,
} = useKisRuntimeStore(
useShallow((state) => ({
kisTradingEnvInput: state.kisTradingEnvInput,
kisAppKeyInput: state.kisAppKeyInput,
kisAppSecretInput: state.kisAppSecretInput,
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
setKisTradingEnvInput: state.setKisTradingEnvInput,
setKisAppKeyInput: state.setKisAppKeyInput,
setKisAppSecretInput: state.setKisAppSecretInput,
setVerifiedKisSession: state.setVerifiedKisSession,
invalidateKisVerification: state.invalidateKisVerification,
clearKisRuntimeSession: state.clearKisRuntimeSession,
})),
);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isValidating, startValidateTransition] = useTransition();
const [isRevoking, startRevokeTransition] = useTransition();
function handleValidate() {
startValidateTransition(async () => {
try {
setErrorMessage(null);
setStatusMessage(null);
const appKey = kisAppKeyInput.trim();
const appSecret = kisAppSecretInput.trim();
if (!appKey || !appSecret) {
throw new Error("App Key와 App Secret을 모두 입력해 주세요.");
}
// 주문 기능에서 계좌번호가 필요할 수 있어 구조는 유지하되, 인증 단계에서는 입력받지 않습니다.
const credentials = {
appKey,
appSecret,
tradingEnv: kisTradingEnvInput,
accountNo: verifiedCredentials?.accountNo ?? "",
};
const result = await validateKisCredentials(credentials);
setVerifiedKisSession(credentials, result.tradingEnv);
setStatusMessage(
`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"})`,
);
} catch (err) {
invalidateKisVerification();
setErrorMessage(
err instanceof Error
? err.message
: "API 키 검증 중 오류가 발생했습니다.",
);
}
});
}
function handleRevoke() {
if (!verifiedCredentials) return;
startRevokeTransition(async () => {
try {
setErrorMessage(null);
setStatusMessage(null);
const result = await revokeKisCredentials(verifiedCredentials);
clearKisRuntimeSession(result.tradingEnv);
setStatusMessage(
`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"})`,
);
} catch (err) {
setErrorMessage(
err instanceof Error
? err.message
: "연결 해제 중 오류가 발생했습니다.",
);
}
});
}
return (
<Card className="border-brand-200 bg-linear-to-r from-brand-50/60 to-background">
<CardHeader>
<CardTitle>KIS API </CardTitle>
<CardDescription>
, API .
.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* ========== CREDENTIAL INPUTS ========== */}
<div className="grid gap-3 md:grid-cols-3">
<div>
<label className="mb-1 block text-xs text-muted-foreground">
</label>
<div className="flex gap-2">
<Button
type="button"
variant={kisTradingEnvInput === "real" ? "default" : "outline"}
className={cn(
"flex-1",
kisTradingEnvInput === "real"
? "bg-brand-600 hover:bg-brand-700"
: "",
)}
onClick={() => setKisTradingEnvInput("real")}
>
</Button>
<Button
type="button"
variant={kisTradingEnvInput === "mock" ? "default" : "outline"}
className={cn(
"flex-1",
kisTradingEnvInput === "mock"
? "bg-brand-600 hover:bg-brand-700"
: "",
)}
onClick={() => setKisTradingEnvInput("mock")}
>
</Button>
</div>
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">
KIS App Key
</label>
<Input
type="password"
value={kisAppKeyInput}
onChange={(e) => setKisAppKeyInput(e.target.value)}
placeholder="App Key 입력"
autoComplete="off"
/>
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">
KIS App Secret
</label>
<Input
type="password"
value={kisAppSecretInput}
onChange={(e) => setKisAppSecretInput(e.target.value)}
placeholder="App Secret 입력"
autoComplete="off"
/>
</div>
</div>
{/* ========== ACTIONS ========== */}
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
onClick={handleValidate}
disabled={
isValidating || !kisAppKeyInput.trim() || !kisAppSecretInput.trim()
}
className="bg-brand-600 hover:bg-brand-700"
>
{isValidating ? "검증 중..." : "API 키 검증"}
</Button>
<Button
type="button"
variant="outline"
onClick={handleRevoke}
disabled={isRevoking || !isKisVerified || !verifiedCredentials}
className="border-brand-200 text-brand-700 hover:bg-brand-50 hover:text-brand-800"
>
{isRevoking ? "해제 중..." : "연결 끊기"}
</Button>
{isKisVerified ? (
<span className="flex items-center text-sm font-medium text-green-600">
<span className="mr-1.5 h-2 w-2 rounded-full bg-green-500 ring-2 ring-green-100" />
({verifiedCredentials?.tradingEnv === "real" ? "실전" : "모의"})
</span>
) : (
<span className="text-sm text-muted-foreground"></span>
)}
</div>
{errorMessage && (
<div className="text-sm font-medium text-destructive">{errorMessage}</div>
)}
{statusMessage && <div className="text-sm text-blue-600">{statusMessage}</div>}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,484 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
CandlestickSeries,
ColorType,
HistogramSeries,
createChart,
type IChartApi,
type ISeriesApi,
type Time,
} from "lightweight-charts";
import { ChevronDown } from "lucide-react";
import { toast } from "sonner";
import { fetchStockChart } from "@/features/dashboard/apis/kis-stock.api";
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardChartTimeframe,
StockCandlePoint,
} from "@/features/dashboard/types/dashboard.types";
import { cn } from "@/lib/utils";
import {
type ChartBar,
convertCandleToBar,
formatPrice,
formatSignedPercent,
isMinuteTimeframe,
mergeBars,
normalizeCandles,
upsertRealtimeBar,
} from "./chart-utils";
const UP_COLOR = "#ef4444";
const DOWN_COLOR = "#2563eb";
// 분봉 드롭다운 옵션
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: "주" },
];
// ChartBar 타입은 chart-utils.ts에서 import
interface StockLineChartProps {
symbol?: string;
candles: StockCandlePoint[];
credentials?: KisRuntimeCredentials | null;
}
/**
* @description TradingView 스타일 캔들 차트 + 거래량 + 무한 과거 로딩
* @see https://tradingview.github.io/lightweight-charts/tutorials/demos/realtime-updates
* @see https://tradingview.github.io/lightweight-charts/tutorials/demos/infinite-history
*/
export function StockLineChart({
symbol,
candles,
credentials,
}: StockLineChartProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<IChartApi | null>(null);
const candleSeriesRef = useRef<ISeriesApi<"Candlestick", Time> | null>(null);
const volumeSeriesRef = useRef<ISeriesApi<"Histogram", Time> | null>(null);
const [timeframe, setTimeframe] = useState<DashboardChartTimeframe>("1d");
const [isMinuteDropdownOpen, setIsMinuteDropdownOpen] = useState(false);
const [bars, setBars] = useState<ChartBar[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [isChartReady, setIsChartReady] = useState(false);
const lastRealtimeKeyRef = useRef<string>("");
const loadingMoreRef = useRef(false);
const loadMoreHandlerRef = useRef<() => Promise<void>>(async () => {});
const initialLoadCompleteRef = useRef(false);
// candles prop을 ref로 관리하여 useEffect 디펜던시에서 제거 (무한 페칭 방지)
const latestCandlesRef = useRef(candles);
useEffect(() => {
latestCandlesRef.current = candles;
}, [candles]);
const latest = bars.at(-1);
const prevClose = bars.length > 1 ? (bars.at(-2)?.close ?? 0) : 0;
const change = latest ? latest.close - prevClose : 0;
const changeRate = prevClose > 0 ? (change / prevClose) * 100 : 0;
const renderableBars = useMemo(() => {
const dedup = new Map<number, ChartBar>();
for (const bar of bars) {
if (
!Number.isFinite(bar.time) ||
!Number.isFinite(bar.open) ||
!Number.isFinite(bar.high) ||
!Number.isFinite(bar.low) ||
!Number.isFinite(bar.close) ||
bar.close <= 0
) {
continue;
}
dedup.set(bar.time, bar);
}
return [...dedup.values()].sort((a, b) => a.time - b.time);
}, [bars]);
const setSeriesData = useCallback((nextBars: ChartBar[]) => {
const candleSeries = candleSeriesRef.current;
const volumeSeries = volumeSeriesRef.current;
if (!candleSeries || !volumeSeries) return;
// lightweight-charts는 시간 오름차순/유효 숫자 조건이 깨지면 렌더를 멈출 수 있어
// 전달 직전 데이터를 한 번 더 정리합니다.
const safeBars = nextBars;
try {
candleSeries.setData(
safeBars.map((bar) => ({
time: bar.time,
open: bar.open,
high: bar.high,
low: bar.low,
close: bar.close,
})),
);
volumeSeries.setData(
safeBars.map((bar) => ({
time: bar.time,
value: Number.isFinite(bar.volume) ? bar.volume : 0,
color:
bar.close >= bar.open
? "rgba(239,68,68,0.45)"
: "rgba(37,99,235,0.45)",
})),
);
} catch (error) {
console.error("Failed to render chart series data:", error);
}
}, []);
const handleLoadMore = useCallback(async () => {
// 분봉은 당일 데이터만 제공되므로 과거 로딩 불가
if (isMinuteTimeframe(timeframe)) return;
if (!symbol || !credentials || !nextCursor || loadingMoreRef.current)
return;
loadingMoreRef.current = true;
setIsLoadingMore(true);
try {
const response = await fetchStockChart(
symbol,
timeframe,
credentials,
nextCursor,
);
const older = normalizeCandles(response.candles, timeframe);
setBars((prev) => mergeBars(older, prev));
setNextCursor(response.hasMore ? response.nextCursor : null);
} catch (error) {
const msg =
error instanceof Error
? error.message
: "과거 차트 조회에 실패했습니다.";
toast.error(msg);
} finally {
loadingMoreRef.current = false;
setIsLoadingMore(false);
}
}, [credentials, nextCursor, symbol, timeframe]);
useEffect(() => {
loadMoreHandlerRef.current = handleLoadMore;
}, [handleLoadMore]);
useEffect(() => {
const container = containerRef.current;
if (!container || chartRef.current) return;
const initialWidth = Math.max(container.clientWidth, 320);
const initialHeight = Math.max(container.clientHeight, 340);
const chart = createChart(container, {
width: initialWidth,
height: initialHeight,
layout: {
background: { type: ColorType.Solid, color: "#ffffff" },
textColor: "#475569",
attributionLogo: true,
},
localization: {
locale: "ko-KR",
},
rightPriceScale: {
borderColor: "#e2e8f0",
scaleMargins: {
top: 0.08,
bottom: 0.24,
},
},
grid: {
vertLines: { color: "#edf1f5" },
horzLines: { color: "#edf1f5" },
},
crosshair: {
vertLine: { color: "#94a3b8", width: 1, style: 2 },
horzLine: { color: "#94a3b8", width: 1, style: 2 },
},
timeScale: {
borderColor: "#e2e8f0",
timeVisible: true,
secondsVisible: false,
rightOffset: 2,
},
handleScroll: {
mouseWheel: true,
pressedMouseMove: true,
},
handleScale: {
mouseWheel: true,
pinch: true,
axisPressedMouseMove: true,
},
});
const candleSeries = chart.addSeries(CandlestickSeries, {
upColor: UP_COLOR,
downColor: DOWN_COLOR,
wickUpColor: UP_COLOR,
wickDownColor: DOWN_COLOR,
borderUpColor: UP_COLOR,
borderDownColor: DOWN_COLOR,
priceLineVisible: true,
lastValueVisible: true,
});
const volumeSeries = chart.addSeries(HistogramSeries, {
priceScaleId: "volume",
priceLineVisible: false,
lastValueVisible: false,
base: 0,
});
chart.priceScale("volume").applyOptions({
scaleMargins: {
top: 0.78,
bottom: 0,
},
borderVisible: false,
});
// 스크롤 디바운스 타이머
let scrollTimeout: NodeJS.Timeout | null = null;
chart.timeScale().subscribeVisibleLogicalRangeChange((range) => {
if (!range) return;
// 분봉은 당일 데이터만 제공되므로 무한 스크롤 비활성화
if (range.from < 10 && initialLoadCompleteRef.current) {
if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
void loadMoreHandlerRef.current();
}, 300);
}
});
chartRef.current = chart;
candleSeriesRef.current = candleSeries;
volumeSeriesRef.current = volumeSeries;
setIsChartReady(true);
const resizeObserver = new ResizeObserver(() => {
const nextWidth = Math.max(container.clientWidth, 320);
const nextHeight = Math.max(container.clientHeight, 340);
chart.resize(nextWidth, nextHeight);
});
resizeObserver.observe(container);
// 첫 렌더 직후 부모 레이아웃 계산이 끝난 시점에 한 번 더 사이즈를 맞춥니다.
const rafId = window.requestAnimationFrame(() => {
const nextWidth = Math.max(container.clientWidth, 320);
const nextHeight = Math.max(container.clientHeight, 340);
chart.resize(nextWidth, nextHeight);
});
return () => {
window.cancelAnimationFrame(rafId);
resizeObserver.disconnect();
chart.remove();
chartRef.current = null;
candleSeriesRef.current = null;
volumeSeriesRef.current = null;
setIsChartReady(false);
};
}, []);
useEffect(() => {
if (symbol && credentials) return;
setBars(normalizeCandles(candles, "1d"));
setNextCursor(null);
}, [candles, credentials, symbol]);
useEffect(() => {
if (!symbol || !credentials) return;
// 초기 로딩 보호 플래그 초기화 (타임프레임/종목 변경 시)
initialLoadCompleteRef.current = false;
let disposed = false;
const load = async () => {
setIsLoading(true);
try {
const response = await fetchStockChart(symbol, timeframe, credentials);
if (disposed) return;
const normalized = normalizeCandles(response.candles, timeframe);
setBars(normalized);
setNextCursor(response.hasMore ? response.nextCursor : null);
// 초기 로딩 완료 후 500ms 지연 후 무한 스크롤 활성화
// (fitContent 후 range가 0이 되어 즉시 트리거되는 것 방지)
setTimeout(() => {
if (!disposed) {
initialLoadCompleteRef.current = true;
}
}, 500);
} catch (error) {
if (disposed) return;
const message =
error instanceof Error ? error.message : "차트 조회에 실패했습니다.";
toast.error(message);
// 에러 발생 시 fallback으로 props로 전달된 candles 사용
setBars(normalizeCandles(latestCandlesRef.current, timeframe));
setNextCursor(null);
} finally {
if (!disposed) setIsLoading(false);
}
};
void load();
return () => {
disposed = true;
};
}, [credentials, symbol, timeframe]);
useEffect(() => {
if (!isChartReady) return;
setSeriesData(renderableBars);
// 초기 로딩 시에만 fitContent 수행
if (!initialLoadCompleteRef.current && renderableBars.length > 0) {
chartRef.current?.timeScale().fitContent();
}
}, [isChartReady, renderableBars, setSeriesData]);
const latestRealtime = useMemo(() => candles.at(-1), [candles]);
useEffect(() => {
if (!latestRealtime || bars.length === 0) return;
if (timeframe === "1w" && !latestRealtime.timestamp && !latestRealtime.time)
return;
const key = `${latestRealtime.time}-${latestRealtime.price}-${latestRealtime.volume ?? 0}`;
if (lastRealtimeKeyRef.current === key) return;
lastRealtimeKeyRef.current = key;
const nextBar = convertCandleToBar(latestRealtime, timeframe);
if (!nextBar) return;
setBars((prev) => upsertRealtimeBar(prev, nextBar));
}, [bars.length, candles, latestRealtime, timeframe]);
// 상태 메시지 결정 (오버레이로 표시)
const statusMessage = (() => {
if (isLoading && bars.length === 0)
return "차트 데이터를 불러오는 중입니다.";
if (bars.length === 0) return "차트 데이터가 없습니다.";
if (renderableBars.length === 0)
return "차트 데이터 형식이 올바르지 않아 렌더링할 수 없습니다.";
return null;
})();
return (
<div className="flex h-full min-h-[340px] flex-col bg-white">
{/* ========== CHART TOOLBAR ========== */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-2 py-2 sm:px-3">
<div className="flex flex-wrap items-center gap-1 text-xs sm:text-sm">
{/* 분봉 드롭다운 */}
<div className="relative">
<button
type="button"
onClick={() => setIsMinuteDropdownOpen((v) => !v)}
onBlur={() =>
setTimeout(() => setIsMinuteDropdownOpen(false), 200)
}
className={cn(
"flex items-center gap-1 rounded px-2 py-1 text-slate-600 transition-colors hover:bg-slate-100",
MINUTE_TIMEFRAMES.some((t) => t.value === timeframe) &&
"bg-brand-100 font-semibold text-brand-700",
)}
>
{MINUTE_TIMEFRAMES.find((t) => t.value === timeframe)?.label ??
"분봉"}
<ChevronDown className="h-3 w-3" />
</button>
{isMinuteDropdownOpen && (
<div className="absolute left-0 top-full z-10 mt-1 rounded border border-slate-200 bg-white shadow-lg">
{MINUTE_TIMEFRAMES.map((item) => (
<button
key={item.value}
type="button"
onClick={() => {
setTimeframe(item.value);
setIsMinuteDropdownOpen(false);
}}
className={cn(
"block w-full whitespace-nowrap px-3 py-1.5 text-left hover:bg-slate-100",
timeframe === item.value &&
"bg-brand-50 font-semibold text-brand-700",
)}
>
{item.label}
</button>
))}
</div>
)}
</div>
{/* 일/주 버튼 */}
{PERIOD_TIMEFRAMES.map((item) => (
<button
key={item.value}
type="button"
onClick={() => setTimeframe(item.value)}
className={cn(
"rounded px-2 py-1 text-slate-600 transition-colors hover:bg-slate-100",
timeframe === item.value &&
"bg-brand-100 font-semibold text-brand-700",
)}
>
{item.label}
</button>
))}
{isLoadingMore && (
<span className="ml-2 text-[11px] text-muted-foreground">
...
</span>
)}
</div>
<div className="text-[11px] text-slate-600 sm:text-xs">
O {formatPrice(latest?.open ?? 0)} H {formatPrice(latest?.high ?? 0)}{" "}
L {formatPrice(latest?.low ?? 0)} C{" "}
<span className={cn(change >= 0 ? "text-red-600" : "text-blue-600")}>
{formatPrice(latest?.close ?? 0)} ({formatSignedPercent(changeRate)}
)
</span>
</div>
</div>
{/* ========== CHART BODY ========== */}
<div className="relative min-h-0 flex-1 overflow-hidden">
{/* 차트 캔버스 컨테이너 - 항상 렌더링 */}
<div ref={containerRef} className="h-full w-full" />
{/* 상태 메시지 오버레이 */}
{statusMessage && (
<div className="absolute inset-0 flex items-center justify-center bg-white/80 text-sm text-muted-foreground">
{statusMessage}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,205 @@
/**
* @file chart-utils.ts
* @description StockLineChart에서 사용하는 유틸리티 함수 모음
*/
import type { UTCTimestamp } from "lightweight-charts";
import type {
DashboardChartTimeframe,
StockCandlePoint,
} from "@/features/dashboard/types/dashboard.types";
const KRW_FORMATTER = new Intl.NumberFormat("ko-KR");
// ─── 타입 ──────────────────────────────────────────────────
export type ChartBar = {
time: UTCTimestamp;
open: number;
high: number;
low: number;
close: number;
volume: number;
};
// ─── StockCandlePoint → ChartBar 변환 ─────────────────────
/**
* candles 배열을 ChartBar 배열로 정규화 (무효값 필터 + 병합 + 정렬)
*/
export function normalizeCandles(
candles: StockCandlePoint[],
timeframe: DashboardChartTimeframe,
): ChartBar[] {
const rows = candles
.map((c) => convertCandleToBar(c, timeframe))
.filter((b): b is ChartBar => Boolean(b));
return mergeBars([], rows);
}
/**
* 단일 candle → ChartBar 변환. 유효하지 않으면 null
*/
export function convertCandleToBar(
candle: StockCandlePoint,
timeframe: DashboardChartTimeframe,
): ChartBar | null {
const close = candle.close ?? candle.price;
if (!Number.isFinite(close) || close <= 0) return null;
const open = candle.open ?? close;
const high = candle.high ?? Math.max(open, close);
const low = candle.low ?? Math.min(open, close);
const volume = candle.volume ?? 0;
const time = resolveBarTimestamp(candle, timeframe);
if (!time) return null;
return {
time,
open,
high: Math.max(high, open, close),
low: Math.min(low, open, close),
close,
volume,
};
}
// ─── 타임스탬프 해석/정렬 ─────────────────────────────────
function resolveBarTimestamp(
candle: StockCandlePoint,
timeframe: DashboardChartTimeframe,
): UTCTimestamp | null {
// timestamp 필드가 있으면 우선 사용
if (
typeof candle.timestamp === "number" &&
Number.isFinite(candle.timestamp)
) {
return alignTimestamp(candle.timestamp, timeframe);
}
const text = typeof candle.time === "string" ? candle.time.trim() : "";
if (!text) return null;
// "MM/DD" 형식 (일봉)
if (/^\d{2}\/\d{2}$/.test(text)) {
const [mm, dd] = text.split("/");
const year = new Date().getFullYear();
const ts = Math.floor(
new Date(`${year}-${mm}-${dd}T09:00:00+09:00`).getTime() / 1000,
);
return alignTimestamp(ts, timeframe);
}
// "HH:MM" 또는 "HH:MM:SS" 형식 (분봉)
if (/^\d{2}:\d{2}(:\d{2})?$/.test(text)) {
const [hh, mi, ss] = text.split(":");
const now = new Date();
const y = now.getFullYear();
const m = `${now.getMonth() + 1}`.padStart(2, "0");
const d = `${now.getDate()}`.padStart(2, "0");
const ts = Math.floor(
new Date(`${y}-${m}-${d}T${hh}:${mi}:${ss ?? "00"}+09:00`).getTime() /
1000,
);
return alignTimestamp(ts, timeframe);
}
return null;
}
/**
* 타임스탬프를 타임프레임 버킷 경계에 정렬
* - 1m: 그대로
* - 30m/1h: 분 단위를 버킷에 정렬
* - 1d: 00:00:00
* - 1w: 월요일 00:00:00
*/
function alignTimestamp(
timestamp: number,
timeframe: DashboardChartTimeframe,
): UTCTimestamp {
const d = new Date(timestamp * 1000);
if (timeframe === "30m" || timeframe === "1h") {
const bucket = timeframe === "30m" ? 30 : 60;
d.setUTCMinutes(Math.floor(d.getUTCMinutes() / bucket) * bucket, 0, 0);
} else if (timeframe === "1d") {
d.setUTCHours(0, 0, 0, 0);
} else if (timeframe === "1w") {
const day = d.getUTCDay();
d.setUTCDate(d.getUTCDate() + (day === 0 ? -6 : 1 - day));
d.setUTCHours(0, 0, 0, 0);
}
return Math.floor(d.getTime() / 1000) as UTCTimestamp;
}
// ─── 봉 병합 ──────────────────────────────────────────────
/**
* 두 ChartBar 배열을 시간 기준으로 병합. 같은 시간대는 OHLCV 통합
*/
export function mergeBars(left: ChartBar[], right: ChartBar[]): ChartBar[] {
const map = new Map<number, ChartBar>();
for (const bar of [...left, ...right]) {
const prev = map.get(bar.time);
if (!prev) {
map.set(bar.time, bar);
continue;
}
map.set(bar.time, {
time: bar.time,
open: prev.open,
high: Math.max(prev.high, bar.high),
low: Math.min(prev.low, bar.low),
close: bar.close,
volume: Math.max(prev.volume, bar.volume),
});
}
return [...map.values()].sort((a, b) => a.time - b.time);
}
/**
* 실시간 봉 업데이트: 같은 시간이면 기존 봉에 병합, 새 시간이면 추가
*/
export function upsertRealtimeBar(
prev: ChartBar[],
incoming: ChartBar,
): ChartBar[] {
if (prev.length === 0) return [incoming];
const last = prev[prev.length - 1];
if (incoming.time > last.time) return [...prev, incoming];
if (incoming.time < last.time) return prev;
return [
...prev.slice(0, -1),
{
time: last.time,
open: last.open,
high: Math.max(last.high, incoming.high),
low: Math.min(last.low, incoming.low),
close: incoming.close,
volume: Math.max(last.volume, incoming.volume),
},
];
}
// ─── 포맷터 ───────────────────────────────────────────────
export function formatPrice(value: number) {
return KRW_FORMATTER.format(Math.round(value));
}
export function formatSignedPercent(value: number) {
const sign = value > 0 ? "+" : "";
return `${sign}${value.toFixed(2)}%`;
}
/**
* 분봉 타임프레임인지 판별
*/
export function isMinuteTimeframe(tf: DashboardChartTimeframe) {
return tf === "1m" || tf === "30m" || tf === "1h";
}

View File

@@ -0,0 +1,144 @@
import { Activity, ShieldCheck } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { StockLineChart } from "@/features/dashboard/components/chart/StockLineChart";
import { StockPriceBadge } from "@/features/dashboard/components/details/StockPriceBadge";
import type {
DashboardStockItem,
DashboardPriceSource,
DashboardMarketPhase,
} from "@/features/dashboard/types/dashboard.types";
const PRICE_FORMATTER = new Intl.NumberFormat("ko-KR");
function formatVolume(value: number) {
return `${PRICE_FORMATTER.format(value)}`;
}
function getPriceSourceLabel(
source: DashboardPriceSource,
marketPhase: DashboardMarketPhase,
) {
switch (source) {
case "inquire-overtime-price":
return "시간외 현재가(inquire-overtime-price)";
case "inquire-ccnl":
return marketPhase === "afterHours"
? "체결가 폴백(inquire-ccnl)"
: "체결가(inquire-ccnl)";
default:
return "현재가(inquire-price)";
}
}
function PriceStat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl border border-border/70 bg-background/70 p-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-sm font-semibold text-foreground">{value}</p>
</div>
);
}
interface StockOverviewCardProps {
stock: DashboardStockItem;
priceSource: DashboardPriceSource;
marketPhase: DashboardMarketPhase;
isRealtimeConnected: boolean;
realtimeTrId: string | null;
lastRealtimeTickAt: number | null;
}
export function StockOverviewCard({
stock,
priceSource,
marketPhase,
isRealtimeConnected,
realtimeTrId,
lastRealtimeTickAt,
}: StockOverviewCardProps) {
const apiPriceSourceLabel = getPriceSourceLabel(priceSource, marketPhase);
const effectivePriceSourceLabel =
isRealtimeConnected && lastRealtimeTickAt
? `실시간 체결(WebSocket ${realtimeTrId || ""})`
: apiPriceSourceLabel;
return (
<Card className="overflow-hidden border-brand-200">
<CardHeader className="border-b border-border/50 bg-muted/30 pb-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex items-center gap-2">
<CardTitle className="text-xl font-bold">{stock.name}</CardTitle>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
{stock.symbol}
</span>
<span className="rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700">
{stock.market}
</span>
</div>
<CardDescription className="mt-1 flex items-center gap-1.5">
<span>{effectivePriceSourceLabel}</span>
{isRealtimeConnected && (
<span className="inline-flex items-center gap-1 rounded bg-green-100 px-1.5 py-0.5 text-xs font-medium text-green-700">
<Activity className="h-3 w-3" />
</span>
)}
</CardDescription>
</div>
<div className="flex flex-col items-end gap-1">
<StockPriceBadge
currentPrice={stock.currentPrice}
change={stock.change}
changeRate={stock.changeRate}
/>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="grid border-b border-border/50 lg:grid-cols-3">
<div className="col-span-2 border-r border-border/50">
{/* Chart Area */}
<div className="p-6">
<StockLineChart candles={stock.candles} />
</div>
</div>
<div className="col-span-1 bg-muted/10 p-6">
<div className="mb-4 flex items-center gap-2 text-sm font-semibold text-foreground/80">
<ShieldCheck className="h-4 w-4 text-brand-600" />
</div>
<div className="grid grid-cols-2 gap-3">
<PriceStat
label="시가"
value={`${PRICE_FORMATTER.format(stock.open)}`}
/>
<PriceStat
label="고가"
value={`${PRICE_FORMATTER.format(stock.high)}`}
/>
<PriceStat
label="저가"
value={`${PRICE_FORMATTER.format(stock.low)}`}
/>
<PriceStat
label="전일종가"
value={`${PRICE_FORMATTER.format(stock.prevClose)}`}
/>
<div className="col-span-2">
<PriceStat label="거래량" value={formatVolume(stock.volume)} />
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,48 @@
import { TrendingDown, TrendingUp } from "lucide-react";
import { cn } from "@/lib/utils";
const PRICE_FORMATTER = new Intl.NumberFormat("ko-KR");
function formatPrice(value: number) {
return `${PRICE_FORMATTER.format(value)}`;
}
interface StockPriceBadgeProps {
currentPrice: number;
change: number;
changeRate: number;
}
export function StockPriceBadge({
currentPrice,
change,
changeRate,
}: StockPriceBadgeProps) {
const isPositive = change >= 0;
const ChangeIcon = isPositive ? TrendingUp : TrendingDown;
const changeColor = isPositive ? "text-red-500" : "text-blue-500";
const changeSign = isPositive ? "+" : "";
return (
<div className="flex items-baseline gap-2">
<span className={cn("text-3xl font-bold", changeColor)}>
{formatPrice(currentPrice)}
</span>
<div
className={cn(
"flex items-center gap-1 text-sm font-medium",
changeColor,
)}
>
<ChangeIcon className="h-4 w-4" />
<span>
{changeSign}
{PRICE_FORMATTER.format(change)}
</span>
<span>
({changeSign}
{changeRate.toFixed(2)}%)
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
// import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { DashboardStockItem } from "@/features/dashboard/types/dashboard.types";
import { cn } from "@/lib/utils";
interface StockHeaderProps {
stock: DashboardStockItem;
price: string;
change: string;
changeRate: string;
high?: string;
low?: string;
volume?: string;
}
export function StockHeader({
stock,
price,
change,
changeRate,
high,
low,
volume,
}: StockHeaderProps) {
const isRise = changeRate.startsWith("+") || parseFloat(changeRate) > 0;
const isFall = changeRate.startsWith("-") || parseFloat(changeRate) < 0;
const colorClass = isRise
? "text-red-500"
: isFall
? "text-blue-500"
: "text-foreground";
return (
<div className="flex items-center justify-between px-4 py-3">
{/* Left: Stock Info */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold">{stock.name}</h1>
<span className="text-sm text-muted-foreground">
{stock.symbol}/{stock.market}
</span>
</div>
<Separator orientation="vertical" className="h-6" />
<div className={cn("flex items-end gap-2", colorClass)}>
<span className="text-2xl font-bold tracking-tight">{price}</span>
<span className="text-sm font-medium mb-1">
{changeRate}% <span className="text-xs ml-1">{change}</span>
</span>
</div>
</div>
{/* Right: 24h Stats */}
<div className="hidden md:flex items-center gap-6 text-sm">
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs"></span>
<span className="font-medium text-red-500">{high || "--"}</span>
</div>
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs"></span>
<span className="font-medium text-blue-500">{low || "--"}</span>
</div>
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs">(24H)</span>
<span className="font-medium">{volume || "--"}</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface DashboardLayoutProps {
header: ReactNode;
chart: ReactNode;
orderBook: ReactNode;
orderForm: ReactNode;
className?: string;
}
export function DashboardLayout({
header,
chart,
orderBook,
orderForm,
className,
}: DashboardLayoutProps) {
return (
<div
className={cn(
"flex h-[calc(100vh-64px)] flex-col overflow-hidden",
className,
)}
>
{/* 1. Header Area */}
<div className="flex-none border-b border-border bg-background">
{header}
</div>
{/* 2. Main Content Area */}
<div className="flex flex-1 flex-col overflow-hidden xl:flex-row">
{/* Left Column: Chart & Info */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col border-border xl:border-r">
<div className="flex-1 min-h-0">{chart}</div>
{/* Future: Transaction History / Market Depth can go here */}
</div>
{/* Right Column: Order Book & Order Form */}
<div className="flex min-h-0 w-full flex-none flex-col bg-background xl:w-[460px] 2xl:w-[500px]">
{/* Top: Order Book (Hoga) */}
<div className="min-h-[360px] flex-1 overflow-hidden border-t border-border xl:min-h-0 xl:border-t-0 xl:border-b">
{orderBook}
</div>
{/* Bottom: Order Form */}
<div className="flex-none h-[320px] sm:h-[360px] xl:h-[380px]">
{orderForm}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,249 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
DashboardStockItem,
DashboardOrderSide,
} from "@/features/dashboard/types/dashboard.types";
import { useOrder } from "@/features/dashboard/hooks/useOrder";
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
import { Loader2 } from "lucide-react";
interface OrderFormProps {
stock?: DashboardStockItem;
}
export function OrderForm({ stock }: OrderFormProps) {
const verifiedCredentials = useKisRuntimeStore(
(state) => state.verifiedCredentials,
);
const { placeOrder, isLoading, error } = useOrder();
// Form State
// Initial price set from stock current price if available, relying on component remount (key) for updates
const [price, setPrice] = useState<string>(
stock?.currentPrice.toString() || "",
);
const [quantity, setQuantity] = useState<string>("");
const [activeTab, setActiveTab] = useState("buy");
const handleOrder = async (side: DashboardOrderSide) => {
if (!stock || !verifiedCredentials) return;
const priceNum = parseInt(price.replace(/,/g, ""), 10);
const qtyNum = parseInt(quantity.replace(/,/g, ""), 10);
if (isNaN(priceNum) || priceNum <= 0) {
alert("가격을 올바르게 입력해주세요.");
return;
}
if (isNaN(qtyNum) || qtyNum <= 0) {
alert("수량을 올바르게 입력해주세요.");
return;
}
if (!verifiedCredentials.accountNo) {
alert(
"계좌번호가 설정되지 않았습니다. 설정에서 계좌번호를 입력해주세요.",
);
return;
}
const response = await placeOrder(
{
symbol: stock.symbol,
side: side,
orderType: "limit", // 지정가 고정
price: priceNum,
quantity: qtyNum,
accountNo: verifiedCredentials.accountNo,
accountProductCode: "01", // Default to '01' (위탁)
},
verifiedCredentials,
);
if (response && response.orderNo) {
alert(`주문 전송 완료! 주문번호: ${response.orderNo}`);
setQuantity("");
}
};
const totalPrice =
parseInt(price.replace(/,/g, "") || "0", 10) *
parseInt(quantity.replace(/,/g, "") || "0", 10);
const setPercent = (pct: string) => {
// Placeholder logic for percent click
console.log("Percent clicked:", pct);
};
const isMarketDataAvailable = !!stock;
return (
<div className="h-full bg-background p-4 border-l border-border">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full h-full flex flex-col"
>
<TabsList className="grid w-full grid-cols-2 mb-4">
<TabsTrigger
value="buy"
className="data-[state=active]:bg-red-600 data-[state=active]:text-white transition-colors"
>
</TabsTrigger>
<TabsTrigger
value="sell"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white transition-colors"
>
</TabsTrigger>
</TabsList>
<TabsContent
value="buy"
className="flex-1 flex flex-col space-y-4 data-[state=inactive]:hidden"
>
<OrderInputs
type="buy"
price={price}
setPrice={setPrice}
quantity={quantity}
setQuantity={setQuantity}
totalPrice={totalPrice}
disabled={!isMarketDataAvailable}
hasError={!!error}
errorMessage={error}
/>
<PercentButtons onSelect={setPercent} />
<Button
className="w-full bg-red-600 hover:bg-red-700 mt-auto text-lg h-12"
disabled={isLoading || !isMarketDataAvailable}
onClick={() => handleOrder("buy")}
>
{isLoading ? <Loader2 className="animate-spin mr-2" /> : "매수하기"}
</Button>
</TabsContent>
<TabsContent
value="sell"
className="flex-1 flex flex-col space-y-4 data-[state=inactive]:hidden"
>
<OrderInputs
type="sell"
price={price}
setPrice={setPrice}
quantity={quantity}
setQuantity={setQuantity}
totalPrice={totalPrice}
disabled={!isMarketDataAvailable}
hasError={!!error}
errorMessage={error}
/>
<PercentButtons onSelect={setPercent} />
<Button
className="w-full bg-blue-600 hover:bg-blue-700 mt-auto text-lg h-12"
disabled={isLoading || !isMarketDataAvailable}
onClick={() => handleOrder("sell")}
>
{isLoading ? <Loader2 className="animate-spin mr-2" /> : "매도하기"}
</Button>
</TabsContent>
</Tabs>
</div>
);
}
function OrderInputs({
type,
price,
setPrice,
quantity,
setQuantity,
totalPrice,
disabled,
hasError,
errorMessage,
}: {
type: "buy" | "sell";
price: string;
setPrice: (v: string) => void;
quantity: string;
setQuantity: (v: string) => void;
totalPrice: number;
disabled: boolean;
hasError: boolean;
errorMessage: string | null;
}) {
return (
<div className="space-y-4">
<div className="flex justify-between text-xs text-muted-foreground">
<span></span>
<span>- {type === "buy" ? "KRW" : "주"}</span>
</div>
{hasError && (
<div className="p-2 bg-destructive/10 text-destructive text-xs rounded break-keep">
{errorMessage}
</div>
)}
<div className="grid grid-cols-4 gap-2 items-center">
<span className="text-sm font-medium">
{type === "buy" ? "매수가격" : "매도가격"}
</span>
<Input
className="col-span-3 text-right font-mono"
placeholder="0"
value={price}
onChange={(e) => setPrice(e.target.value)}
disabled={disabled}
/>
</div>
<div className="grid grid-cols-4 gap-2 items-center">
<span className="text-sm font-medium"></span>
<Input
className="col-span-3 text-right font-mono"
placeholder="0"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
disabled={disabled}
/>
</div>
<div className="grid grid-cols-4 gap-2 items-center">
<span className="text-sm font-medium"></span>
<Input
className="col-span-3 text-right font-mono bg-muted/50"
value={totalPrice.toLocaleString()}
readOnly
disabled={disabled}
/>
</div>
</div>
);
}
function PercentButtons({ onSelect }: { onSelect: (pct: string) => void }) {
return (
<div className="grid grid-cols-4 gap-2 mt-2">
{["10%", "25%", "50%", "100%"].map((pct) => (
<Button
key={pct}
variant="outline"
size="sm"
className="text-xs"
onClick={() => onSelect(pct)}
>
{pct}
</Button>
))}
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { AnimatePresence, motion } from "framer-motion";
interface AnimatedQuantityProps {
value: number;
format?: (val: number) => string;
className?: string;
/** 값 변동 시 배경 깜빡임 */
useColor?: boolean;
}
/**
* 실시간 수량 표시 — 값이 변할 때 ±diff를 인라인으로 보여줍니다.
*/
export function AnimatedQuantity({
value,
format = (v) => v.toLocaleString(),
className,
useColor = false,
}: AnimatedQuantityProps) {
const prevRef = useRef(value);
const [diff, setDiff] = useState<number | null>(null);
const [flash, setFlash] = useState<"up" | "down" | null>(null);
useEffect(() => {
if (prevRef.current === value) return;
const delta = value - prevRef.current;
prevRef.current = value;
if (delta === 0) return;
setDiff(delta);
setFlash(delta > 0 ? "up" : "down");
const timer = setTimeout(() => {
setDiff(null);
setFlash(null);
}, 1200);
return () => clearTimeout(timer);
}, [value]);
return (
<span
className={cn(
"relative inline-flex items-center gap-1 tabular-nums",
className,
)}
>
{/* 배경 깜빡임 */}
<AnimatePresence>
{useColor && flash && (
<motion.span
initial={{ opacity: 0.5 }}
animate={{ opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 1 }}
className={cn(
"absolute inset-0 z-0 rounded-sm",
flash === "up" ? "bg-red-200/50" : "bg-blue-200/50",
)}
/>
)}
</AnimatePresence>
{/* 수량 값 */}
<span className="relative z-10">{format(value)}</span>
{/* ±diff (인라인 표시) */}
<AnimatePresence>
{diff != null && diff !== 0 && (
<motion.span
initial={{ opacity: 1, scale: 1 }}
animate={{ opacity: 0, scale: 0.85 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.2, ease: "easeOut" }}
className={cn(
"relative z-20 whitespace-nowrap text-[9px] font-bold leading-none",
diff > 0 ? "text-red-500" : "text-blue-500",
)}
>
{diff > 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()}
</motion.span>
)}
</AnimatePresence>
</span>
);
}

View File

@@ -0,0 +1,567 @@
import { useMemo } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import type {
DashboardRealtimeTradeTick,
DashboardStockOrderBookResponse,
} from "@/features/dashboard/types/dashboard.types";
import { cn } from "@/lib/utils";
import { AnimatedQuantity } from "./AnimatedQuantity";
// ─── 타입 ───────────────────────────────────────────────
interface OrderBookProps {
symbol?: string;
referencePrice?: number;
currentPrice?: number;
latestTick: DashboardRealtimeTradeTick | null;
recentTicks: DashboardRealtimeTradeTick[];
orderBook: DashboardStockOrderBookResponse | null;
isLoading?: boolean;
}
interface BookRow {
price: number;
size: number;
changePercent: number | null;
isHighlighted: boolean;
}
// ─── 유틸리티 함수 ──────────────────────────────────────
/** 천단위 구분 포맷 */
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;
}
/** 체결 시각 포맷 */
function fmtTime(hms: string) {
if (!hms || hms.length !== 6) return "--:--:--";
return `${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}`;
}
// ─── 메인 컴포넌트 ──────────────────────────────────────
/**
* 호가창 — 실시간 매도·매수 10호가와 체결 목록을 표시합니다.
*/
export function OrderBook({
symbol,
referencePrice,
currentPrice,
latestTick,
recentTicks,
orderBook,
isLoading,
}: OrderBookProps) {
const levels = useMemo(() => orderBook?.levels ?? [], [orderBook]);
// 체결가: tick에서 우선, 없으면 0
const latestPrice =
latestTick?.price && latestTick.price > 0 ? latestTick.price : 0;
// 등락률 기준가
const basePrice =
(referencePrice ?? 0) > 0
? referencePrice!
: (currentPrice ?? 0) > 0
? currentPrice!
: latestPrice > 0
? latestPrice
: 0;
// 매도호가 (역순: 10호가 → 1호가)
const askRows: BookRow[] = useMemo(
() =>
[...levels].reverse().map((l) => ({
price: l.askPrice,
size: Math.max(l.askSize, 0),
changePercent:
l.askPrice > 0 && basePrice > 0
? pctChange(l.askPrice, basePrice)
: null,
isHighlighted: latestPrice > 0 && l.askPrice === latestPrice,
})),
[levels, basePrice, latestPrice],
);
// 매수호가 (1호가 → 10호가)
const bidRows: BookRow[] = useMemo(
() =>
levels.map((l) => ({
price: l.bidPrice,
size: Math.max(l.bidSize, 0),
changePercent:
l.bidPrice > 0 && basePrice > 0
? pctChange(l.bidPrice, basePrice)
: null,
isHighlighted: latestPrice > 0 && l.bidPrice === latestPrice,
})),
[levels, basePrice, latestPrice],
);
const askMax = Math.max(1, ...askRows.map((r) => r.size));
const bidMax = Math.max(1, ...bidRows.map((r) => r.size));
// 스프레드·수급 불균형
const bestAsk = levels.find((l) => l.askPrice > 0)?.askPrice ?? 0;
const bestBid = levels.find((l) => l.bidPrice > 0)?.bidPrice ?? 0;
const spread = bestAsk > 0 && bestBid > 0 ? bestAsk - bestBid : 0;
const totalAsk = orderBook?.totalAskSize ?? 0;
const totalBid = orderBook?.totalBidSize ?? 0;
const imbalance =
totalAsk + totalBid > 0
? ((totalBid - totalAsk) / (totalAsk + totalBid)) * 100
: 0;
// 체결가 행 중앙 스크롤
// ─── 빈/로딩 상태 ───
if (!symbol) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
.
</div>
);
}
if (isLoading && !orderBook) return <OrderBookSkeleton />;
if (!orderBook) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
.
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<Tabs defaultValue="normal" className="h-full min-h-0">
{/* 탭 헤더 */}
<div className="border-b px-2 pt-2">
<TabsList variant="line" className="w-full justify-start">
<TabsTrigger value="normal" className="px-3">
</TabsTrigger>
<TabsTrigger value="cumulative" className="px-3">
</TabsTrigger>
<TabsTrigger value="order" className="px-3">
</TabsTrigger>
</TabsList>
</div>
{/* ── 일반호가 탭 ── */}
<TabsContent value="normal" className="min-h-0 flex-1">
<div className="grid h-full min-h-0 grid-rows-[1fr_190px] overflow-hidden border-t">
<div className="grid min-h-0 grid-cols-[minmax(0,1fr)_150px] overflow-hidden">
{/* 호가 테이블 */}
<div className="min-h-0 border-r">
<BookHeader />
<ScrollArea className="h-[calc(100%-32px)]">
{/* 매도호가 */}
<BookSideRows rows={askRows} side="ask" maxSize={askMax} />
{/* 중앙 바: 현재 체결가 */}
<div className="grid h-9 grid-cols-3 items-center border-y-2 border-amber-400 bg-amber-50/80 dark:bg-amber-900/30">
<div className="px-2 text-right text-[10px] font-medium text-muted-foreground">
{totalAsk > 0 ? fmt(totalAsk) : ""}
</div>
<div className="flex items-center justify-center gap-1">
<span className="text-xs font-bold tabular-nums">
{latestPrice > 0
? fmt(latestPrice)
: bestAsk > 0
? fmt(bestAsk)
: "-"}
</span>
{latestPrice > 0 && basePrice > 0 && (
<span
className={cn(
"text-[10px] font-medium",
latestPrice >= basePrice
? "text-red-500"
: "text-blue-500",
)}
>
{fmtPct(pctChange(latestPrice, basePrice))}
</span>
)}
</div>
<div className="px-2 text-left text-[10px] font-medium text-muted-foreground">
{totalBid > 0 ? fmt(totalBid) : ""}
</div>
</div>
{/* 매수호가 */}
<BookSideRows rows={bidRows} side="bid" maxSize={bidMax} />
</ScrollArea>
</div>
{/* 우측 요약 패널 */}
<SummaryPanel
orderBook={orderBook}
latestTick={latestTick}
spread={spread}
imbalance={imbalance}
totalAsk={totalAsk}
totalBid={totalBid}
/>
</div>
{/* 체결 목록 */}
<TradeTape ticks={recentTicks} />
</div>
</TabsContent>
{/* ── 누적호가 탭 ── */}
<TabsContent value="cumulative" className="min-h-0 flex-1">
<ScrollArea className="h-full border-t">
<div className="p-3">
<div className="mb-2 grid grid-cols-3 text-[11px] font-medium text-muted-foreground">
<span></span>
<span className="text-center"></span>
<span className="text-right"></span>
</div>
<CumulativeRows asks={askRows} bids={bidRows} />
</div>
</ScrollArea>
</TabsContent>
{/* ── 호가주문 탭 ── */}
<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">
.
</div>
</TabsContent>
</Tabs>
</div>
);
}
// ─── 하위 컴포넌트 ──────────────────────────────────────
/** 호가 표 헤더 */
function BookHeader() {
return (
<div className="grid h-8 grid-cols-3 border-b bg-muted/20 text-[11px] font-medium text-muted-foreground">
<div className="flex items-center justify-end px-2"></div>
<div className="flex items-center justify-center border-x"></div>
<div className="flex items-center justify-start px-2"></div>
</div>
);
}
/** 매도 또는 매수 호가 행 목록 */
function BookSideRows({
rows,
side,
maxSize,
}: {
rows: BookRow[];
side: "ask" | "bid";
maxSize: number;
}) {
const isAsk = side === "ask";
return (
<div className={isAsk ? "bg-red-50/15" : "bg-blue-50/15"}>
{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-8 grid-cols-3 border-b border-border/40 text-xs",
row.isHighlighted &&
"ring-1 ring-inset ring-amber-400 bg-amber-100/50 dark:bg-amber-900/30",
)}
>
{/* 매도잔량 (좌측) */}
<div className="relative flex items-center justify-end overflow-hidden px-2">
{isAsk && (
<>
<DepthBar ratio={ratio} side="ask" />
<AnimatedQuantity
value={row.size}
format={fmt}
useColor
className="relative z-10"
/>
</>
)}
</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-900/25",
)}
>
<span className={isAsk ? "text-red-600" : "text-blue-600"}>
{row.price > 0 ? fmt(row.price) : "-"}
</span>
<span
className={cn(
"text-[10px]",
row.changePercent !== null
? row.changePercent >= 0
? "text-red-500"
: "text-blue-500"
: "text-muted-foreground",
)}
>
{row.changePercent === null ? "-" : fmtPct(row.changePercent)}
</span>
</div>
{/* 매수잔량 (우측) */}
<div className="relative flex items-center justify-start overflow-hidden px-1">
{!isAsk && (
<>
<DepthBar ratio={ratio} side="bid" />
<AnimatedQuantity
value={row.size}
format={fmt}
useColor
className="relative z-10"
/>
</>
)}
</div>
</div>
);
})}
</div>
);
}
/** 우측 요약 패널 */
function SummaryPanel({
orderBook,
latestTick,
spread,
imbalance,
totalAsk,
totalBid,
}: {
orderBook: DashboardStockOrderBookResponse;
latestTick: DashboardRealtimeTradeTick | null;
spread: number;
imbalance: number;
totalAsk: number;
totalBid: number;
}) {
return (
<div className="border-l bg-muted/15 p-2 text-[11px]">
<Row
label="실시간"
value={orderBook ? "연결됨" : "끊김"}
tone={orderBook ? "bid" : undefined}
/>
<Row
label="거래량"
value={fmt(latestTick?.tradeVolume ?? orderBook.anticipatedVolume ?? 0)}
/>
<Row
label="누적거래량"
value={fmt(
latestTick?.accumulatedVolume ?? orderBook.accumulatedVolume ?? 0,
)}
/>
<Row
label="체결강도"
value={
latestTick
? `${latestTick.tradeStrength.toFixed(2)}%`
: orderBook.anticipatedChangeRate !== undefined
? `${orderBook.anticipatedChangeRate.toFixed(2)}%`
: "-"
}
/>
<Row label="예상체결가" value={fmt(orderBook.anticipatedPrice ?? 0)} />
<Row
label="매도1호가"
value={latestTick ? fmt(latestTick.askPrice1) : "-"}
tone="ask"
/>
<Row
label="매수1호가"
value={latestTick ? fmt(latestTick.bidPrice1) : "-"}
tone="bid"
/>
<Row
label="매수체결"
value={latestTick ? fmt(latestTick.buyExecutionCount) : "-"}
/>
<Row
label="매도체결"
value={latestTick ? fmt(latestTick.sellExecutionCount) : "-"}
/>
<Row
label="순매수체결"
value={latestTick ? fmt(latestTick.netBuyExecutionCount) : "-"}
/>
<Row label="총 매도잔량" value={fmt(totalAsk)} tone="ask" />
<Row label="총 매수잔량" value={fmt(totalBid)} tone="bid" />
<Row label="스프레드" value={fmt(spread)} />
<Row
label="수급 불균형"
value={`${imbalance >= 0 ? "+" : ""}${imbalance.toFixed(2)}%`}
tone={imbalance >= 0 ? "bid" : "ask"}
/>
</div>
);
}
/** 요약 패널 단일 행 */
function Row({
label,
value,
tone,
}: {
label: string;
value: string;
tone?: "ask" | "bid";
}) {
return (
<div className="mb-1.5 flex items-center justify-between rounded border bg-background px-2 py-1">
<span className="text-muted-foreground">{label}</span>
<span
className={cn(
"font-medium tabular-nums",
tone === "ask" && "text-red-600",
tone === "bid" && "text-blue-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-1 z-0 rounded-sm",
side === "ask" ? "right-1 bg-red-200/50" : "left-1 bg-blue-200/50",
)}
style={{ width: `${ratio}%` }}
/>
);
}
/** 체결 목록 (Trade Tape) */
function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
return (
<div className="border-t bg-background">
<div className="grid h-7 grid-cols-4 border-b bg-muted/20 px-2 text-[11px] font-medium text-muted-foreground">
<div className="flex items-center"></div>
<div className="flex items-center justify-end"></div>
<div className="flex items-center justify-end"></div>
<div className="flex items-center justify-end"></div>
</div>
<ScrollArea className="h-[162px]">
<div>
{ticks.length === 0 && (
<div className="flex h-[160px] items-center justify-center text-xs text-muted-foreground">
.
</div>
)}
{ticks.map((t, i) => (
<div
key={`${t.tickTime}-${t.price}-${i}`}
className="grid h-8 grid-cols-4 border-b border-border/40 px-2 text-xs"
>
<div className="flex items-center tabular-nums">
{fmtTime(t.tickTime)}
</div>
<div className="flex items-center justify-end tabular-nums text-red-600">
{fmt(t.price)}
</div>
<div className="flex items-center justify-end tabular-nums text-blue-600">
{fmt(t.tradeVolume)}
</div>
<div className="flex items-center justify-end tabular-nums">
{t.tradeStrength.toFixed(2)}%
</div>
</div>
))}
</div>
</ScrollArea>
</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"
>
<span className="tabular-nums text-red-600">{fmt(r.askAcc)}</span>
<span className="text-center font-medium tabular-nums">
{fmt(r.price)}
</span>
<span className="text-right tabular-nums text-blue-600">
{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>
);
}

View File

@@ -0,0 +1,37 @@
import type { FormEvent } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search } from "lucide-react";
interface StockSearchFormProps {
keyword: string;
onKeywordChange: (value: string) => void;
onSubmit: (e: FormEvent) => void;
disabled?: boolean;
isLoading?: boolean;
}
export function StockSearchForm({
keyword,
onKeywordChange,
onSubmit,
disabled,
isLoading,
}: StockSearchFormProps) {
return (
<form onSubmit={onSubmit} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="종목명 또는 코드(6자리) 입력..."
className="pl-9"
value={keyword}
onChange={(e) => onKeywordChange(e.target.value)}
/>
</div>
<Button type="submit" disabled={disabled || isLoading}>
{isLoading ? "검색 중..." : "검색"}
</Button>
</form>
);
}

View File

@@ -0,0 +1,47 @@
import { Button } from "@/components/ui/button";
// import { Activity, TrendingDown, TrendingUp } from "lucide-react";
import { cn } from "@/lib/utils";
import type { DashboardStockSearchItem } from "@/features/dashboard/types/dashboard.types";
interface StockSearchResultsProps {
items: DashboardStockSearchItem[];
onSelect: (item: DashboardStockSearchItem) => void;
selectedSymbol?: string;
}
export function StockSearchResults({
items,
onSelect,
selectedSymbol,
}: StockSearchResultsProps) {
if (items.length === 0) return null;
return (
<div className="flex flex-col p-2">
{items.map((item) => {
const isSelected = item.symbol === selectedSymbol;
return (
<Button
key={item.symbol}
variant="outline"
className={cn(
"h-auto w-full flex-col items-start gap-1 p-3 text-left",
isSelected && "border-brand-500 bg-brand-50 hover:bg-brand-100",
)}
onClick={() => onSelect(item)}
>
<div className="flex w-full items-center justify-between gap-2">
<span className="font-semibold truncate">{item.name}</span>
<span className="text-xs text-muted-foreground shrink-0">
{item.symbol}
</span>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{item.market}
</div>
</Button>
);
})}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
import rawStocks from "@/features/dashboard/data/korean-stocks.json";
import type { KoreanStockIndexItem } from "@/features/dashboard/types/dashboard.types";
/**
* 국내주식 검색 인덱스(KOSPI + KOSDAQ)
* - 파일 원본: korean-stocks.json
* - 사용처: /api/kis/domestic/search 라우트의 메모리 검색
* @see app/api/kis/domestic/search/route.ts 종목명/종목코드 검색에 사용합니다.
*/
export const KOREAN_STOCK_INDEX = rawStocks as KoreanStockIndexItem[];

View File

@@ -0,0 +1,126 @@
/**
* @file features/dashboard/data/mock-stocks.ts
* @description 대시보드 1단계 UI 검증용 목업 종목 데이터
* @remarks
* - 한국투자증권 API 연동 전까지 화면 동작 검증에 사용합니다.
* - 2단계 이후 실제 화면은 app/api/kis/* 응답을 사용합니다.
* - 현재는 레거시/비교용 샘플 데이터로만 남겨둔 상태입니다.
*/
import type { DashboardStockItem } from "@/features/dashboard/types/dashboard.types";
/**
* 대시보드 목업 종목 목록
* @see app/(main)/dashboard/page.tsx DashboardPage가 DashboardMain을 통해 이 데이터를 조회합니다.
* @see features/dashboard/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 },
],
},
];

View File

@@ -0,0 +1,291 @@
import { useEffect, useRef, useState } from "react";
import {
type KisRuntimeCredentials,
useKisRuntimeStore,
} from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardRealtimeTradeTick,
DashboardStockOrderBookResponse,
StockCandlePoint,
} from "@/features/dashboard/types/dashboard.types";
import {
appendRealtimeTick,
buildKisRealtimeMessage,
formatRealtimeTickTime,
parseKisRealtimeOrderbook,
parseKisRealtimeTickBatch,
toTickOrderValue,
} from "@/features/dashboard/utils/kis-realtime.utils";
// ─── TR ID 상수 ─────────────────────────────────────────
const TRADE_TR_ID = "H0STCNT0"; // 체결 (실전/모의 공통)
const TRADE_TR_ID_OVERTIME = "H0STOUP0"; // 시간외 단일가
const ORDERBOOK_TR_ID = "H0STASP0"; // 호가 (정규장)
const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0"; // 호가 (시간외)
const MAX_TRADE_TICKS = 10;
// ─── 시간대별 TR ID 선택 ────────────────────────────────
function isOvertimeHours() {
const now = new Date();
const t = now.getHours() * 100 + now.getMinutes();
return t >= 1600 && t < 1800;
}
function resolveTradeTrId(env: KisRuntimeCredentials["tradingEnv"]) {
if (env === "mock") return TRADE_TR_ID;
return isOvertimeHours() ? TRADE_TR_ID_OVERTIME : TRADE_TR_ID;
}
function resolveOrderBookTrId() {
return isOvertimeHours() ? ORDERBOOK_TR_ID_OVERTIME : ORDERBOOK_TR_ID;
}
// ─── 메인 훅 ────────────────────────────────────────────
/**
* 통합 실시간 웹소켓 훅 — 체결(H0STCNT0) + 호가(H0STASP0)를 단일 WS로 수신합니다.
*
* @param symbol 종목코드
* @param credentials KIS 인증 정보
* @param isVerified 인증 완료 여부
* @param onTick 체결 콜백 (StockHeader 갱신용)
* @param options.orderBookSymbol 호가 구독 종목코드
* @param options.onOrderBookMessage 호가 수신 콜백
*/
export function useKisTradeWebSocket(
symbol: string | undefined,
credentials: KisRuntimeCredentials | null,
isVerified: boolean,
onTick?: (tick: DashboardRealtimeTradeTick) => void,
options?: {
orderBookSymbol?: string;
onOrderBookMessage?: (data: DashboardStockOrderBookResponse) => void;
},
) {
const [latestTick, setLatestTick] =
useState<DashboardRealtimeTradeTick | null>(null);
const [realtimeCandles, setRealtimeCandles] = useState<StockCandlePoint[]>(
[],
);
const [recentTradeTicks, setRecentTradeTicks] = useState<
DashboardRealtimeTradeTick[]
>([]);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastTickAt, setLastTickAt] = useState<number | null>(null);
const socketRef = useRef<WebSocket | null>(null);
const approvalKeyRef = useRef<string | null>(null);
const lastTickOrderRef = useRef<number>(-1);
const seenTickRef = useRef<Set<string>>(new Set());
const trId = credentials ? resolveTradeTrId(credentials.tradingEnv) : null;
const obSymbol = options?.orderBookSymbol;
const onOrderBookMsg = options?.onOrderBookMessage;
const obTrId = obSymbol ? resolveOrderBookTrId() : null;
// 8초간 데이터 없을 시 안내 메시지
useEffect(() => {
if (!isConnected || lastTickAt) return;
const timer = window.setTimeout(() => {
setError(
"실시간 연결은 되었지만 체결 데이터가 없습니다. 장중(09:00~15:30)인지 확인해 주세요.",
);
}, 8000);
return () => window.clearTimeout(timer);
}, [isConnected, lastTickAt]);
// ─── 웹소켓 연결 ─────────────────────────────────────
useEffect(() => {
setLatestTick(null);
setRealtimeCandles([]);
setRecentTradeTicks([]);
setError(null);
seenTickRef.current.clear();
if (!symbol || !isVerified || !credentials) {
socketRef.current?.close();
socketRef.current = null;
approvalKeyRef.current = null;
setIsConnected(false);
return;
}
let disposed = false;
let socket: WebSocket | null = null;
const currentTrId = resolveTradeTrId(credentials.tradingEnv);
const connect = async () => {
try {
setError(null);
setIsConnected(false);
const approvalKey = await useKisRuntimeStore
.getState()
.getOrFetchApprovalKey();
if (!approvalKey) throw new Error("웹소켓 승인키 발급에 실패했습니다.");
if (disposed) return;
approvalKeyRef.current = approvalKey;
const wsBase =
process.env.NEXT_PUBLIC_KIS_WS_URL ||
"ws://ops.koreainvestment.com:21000";
socket = new WebSocket(`${wsBase}/tryitout/${currentTrId}`);
socketRef.current = socket;
// ── onopen: 체결 + 호가 구독 ──
socket.onopen = () => {
if (disposed || !approvalKeyRef.current) return;
socket?.send(
JSON.stringify(
buildKisRealtimeMessage(
approvalKeyRef.current,
symbol,
currentTrId,
"1",
),
),
);
if (obSymbol && obTrId) {
socket?.send(
JSON.stringify(
buildKisRealtimeMessage(
approvalKeyRef.current,
obSymbol,
obTrId,
"1",
),
),
);
}
setIsConnected(true);
};
// ── onmessage: TR ID 기반 분기 ──
socket.onmessage = (event) => {
if (disposed || typeof event.data !== "string") return;
// 호가 메시지 확인
if (obSymbol && onOrderBookMsg) {
const ob = parseKisRealtimeOrderbook(event.data, obSymbol);
if (ob) {
if (credentials) ob.tradingEnv = credentials.tradingEnv;
onOrderBookMsg(ob);
return;
}
}
// 체결 메시지 파싱
const ticks = parseKisRealtimeTickBatch(event.data, symbol);
if (ticks.length === 0) return;
// 중복 제거 (TradeTape용)
const meaningful = ticks.filter((t) => t.tradeVolume > 0);
const deduped = meaningful.filter((t) => {
const key = `${t.tickTime}-${t.price}-${t.tradeVolume}`;
if (seenTickRef.current.has(key)) return false;
seenTickRef.current.add(key);
return true;
});
// 최신 틱 → Header
const latest = ticks[ticks.length - 1];
setLatestTick(latest);
// 캔들 → Chart
const order = toTickOrderValue(latest.tickTime);
if (order > 0 && lastTickOrderRef.current <= order) {
lastTickOrderRef.current = order;
setRealtimeCandles((prev) =>
appendRealtimeTick(prev, {
time: formatRealtimeTickTime(latest.tickTime),
price: latest.price,
}),
);
}
// 체결 테이프
if (deduped.length > 0) {
setRecentTradeTicks((prev) =>
[...deduped.reverse(), ...prev].slice(0, MAX_TRADE_TICKS),
);
}
setError(null);
setLastTickAt(Date.now());
onTick?.(latest);
};
socket.onerror = () => {
if (!disposed) setIsConnected(false);
};
socket.onclose = () => {
if (!disposed) setIsConnected(false);
};
} catch (err) {
if (disposed) return;
setError(
err instanceof Error
? err.message
: "실시간 웹소켓 초기화 중 오류가 발생했습니다.",
);
setIsConnected(false);
}
};
void connect();
const seenRef = seenTickRef.current;
// ── cleanup: 구독 해제 ──
return () => {
disposed = true;
setIsConnected(false);
const key = approvalKeyRef.current;
if (socket?.readyState === WebSocket.OPEN && key) {
socket.send(
JSON.stringify(
buildKisRealtimeMessage(key, symbol, currentTrId, "2"),
),
);
if (obSymbol && obTrId) {
socket.send(
JSON.stringify(buildKisRealtimeMessage(key, obSymbol, obTrId, "2")),
);
}
}
socket?.close();
if (socketRef.current === socket) socketRef.current = null;
approvalKeyRef.current = null;
seenRef.clear();
};
}, [
isVerified,
symbol,
credentials,
onTick,
obSymbol,
obTrId,
onOrderBookMsg,
]);
return {
latestTick,
realtimeCandles,
recentTradeTicks,
isConnected,
error,
lastTickAt,
realtimeTrId: trId ?? TRADE_TR_ID,
};
}

View File

@@ -0,0 +1,61 @@
import { useState, useCallback } from "react";
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardStockCashOrderRequest,
DashboardStockCashOrderResponse,
} from "@/features/dashboard/types/dashboard.types";
import { fetchOrderCash } from "@/features/dashboard/apis/kis-stock.api";
export function useOrder() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<DashboardStockCashOrderResponse | null>(
null,
);
const placeOrder = useCallback(
async (
request: DashboardStockCashOrderRequest,
credentials: KisRuntimeCredentials | null,
) => {
if (!credentials) {
setError("KIS API 자격 증명이 없습니다.");
return null;
}
setIsLoading(true);
setError(null);
setResult(null);
try {
const data = await fetchOrderCash(request, credentials);
setResult(data);
return data;
} catch (err) {
const message =
err instanceof Error
? err.message
: "주문 처리 중 오류가 발생했습니다.";
setError(message);
return null;
} finally {
setIsLoading(false);
}
},
[],
);
const reset = useCallback(() => {
setError(null);
setResult(null);
setIsLoading(false);
}, []);
return {
placeOrder,
isLoading,
error,
result,
reset,
};
}

View File

@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from "react";
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type { DashboardStockOrderBookResponse } from "@/features/dashboard/types/dashboard.types";
import { fetchStockOrderBook } from "@/features/dashboard/apis/kis-stock.api";
import { toast } from "sonner";
/**
* @description 초기 REST 호가를 한 번 조회하고, 이후에는 웹소켓 호가를 우선 사용합니다.
* 웹소켓 호가 데이터는 DashboardContainer에서 useKisTradeWebSocket을 통해
* 단일 WebSocket으로 수신되어 externalRealtimeOrderBook으로 주입됩니다.
* @see features/dashboard/components/DashboardContainer.tsx 호가 데이터 흐름
* @see features/dashboard/components/orderbook/OrderBook.tsx 호가창 렌더링 데이터 공급
*/
export function useOrderBook(
symbol: string | undefined,
market: "KOSPI" | "KOSDAQ" | undefined,
credentials: KisRuntimeCredentials | null,
isVerified: boolean,
options: {
enabled?: boolean;
/** 체결 WS에서 받은 실시간 호가 데이터 (단일 WS 통합) */
externalRealtimeOrderBook?: DashboardStockOrderBookResponse | null;
} = {},
) {
const { enabled = true, externalRealtimeOrderBook = null } = options;
const isRequestEnabled = enabled && !!symbol && !!credentials;
const requestSeqRef = useRef(0);
const lastErrorToastRef = useRef<string>("");
const [initialData, setInitialData] =
useState<DashboardStockOrderBookResponse | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isRequestEnabled || !symbol || !credentials) {
return;
}
const requestSeq = ++requestSeqRef.current;
let isDisposed = false;
const loadInitialOrderBook = async () => {
setInitialData(null);
setIsLoading(true);
setError(null);
try {
const data = await fetchStockOrderBook(symbol, credentials);
if (isDisposed || requestSeq !== requestSeqRef.current) return;
setInitialData(data);
} catch (err) {
if (isDisposed || requestSeq !== requestSeqRef.current) return;
console.error("Failed to fetch initial orderbook:", err);
const message =
err instanceof Error
? err.message
: "호가 정보를 불러오지 못했습니다. 잠시 후 다시 시도해주세요.";
setError(message);
if (lastErrorToastRef.current !== message) {
lastErrorToastRef.current = message;
toast.error(message);
}
} finally {
if (isDisposed || requestSeq !== requestSeqRef.current) return;
setIsLoading(false);
}
};
void loadInitialOrderBook();
return () => {
isDisposed = true;
};
}, [isRequestEnabled, symbol, credentials]);
// 외부 실시간 호가 → 초기 데이터 → null 순 우선
const orderBook = isRequestEnabled
? (externalRealtimeOrderBook ?? initialData)
: null;
const mergedError = isRequestEnabled ? error : null;
const mergedLoading = isRequestEnabled ? isLoading && !orderBook : false;
return {
orderBook,
isLoading: mergedLoading,
error: mergedError,
isWsConnected: !!externalRealtimeOrderBook,
};
}

View File

@@ -0,0 +1,118 @@
import { useCallback, useState, useTransition } from "react";
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardMarketPhase,
DashboardPriceSource,
DashboardRealtimeTradeTick,
DashboardStockSearchItem,
DashboardStockItem,
} from "@/features/dashboard/types/dashboard.types";
import { fetchStockOverview } from "@/features/dashboard/apis/kis-stock.api";
interface OverviewMeta {
priceSource: DashboardPriceSource;
marketPhase: DashboardMarketPhase;
fetchedAt: string;
}
export function useStockOverview() {
const [selectedStock, setSelectedStock] = useState<DashboardStockItem | null>(
null,
);
const [meta, setMeta] = useState<OverviewMeta | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, startTransition] = useTransition();
const loadOverview = useCallback(
(
symbol: string,
credentials: KisRuntimeCredentials | null,
marketHint?: DashboardStockSearchItem["market"],
) => {
if (!credentials) return;
startTransition(async () => {
try {
setError(null);
const data = await fetchStockOverview(symbol, credentials);
setSelectedStock({
...data.stock,
market: marketHint ?? data.stock.market,
});
setMeta({
priceSource: data.priceSource,
marketPhase: data.marketPhase,
fetchedAt: data.fetchedAt,
});
} catch (err) {
const message =
err instanceof Error
? err.message
: "종목 조회 중 오류가 발생했습니다.";
setError(message);
setMeta(null);
}
});
},
[],
);
// 실시간 체결 데이터 수신 시 헤더/차트 기준 가격을 갱신합니다.
const updateRealtimeTradeTick = useCallback(
(tick: DashboardRealtimeTradeTick) => {
setSelectedStock((prev) => {
if (!prev) return prev;
const { price, accumulatedVolume, change, changeRate, tickTime } = tick;
const pointTime =
tickTime && tickTime.length === 6
? `${tickTime.slice(0, 2)}:${tickTime.slice(2, 4)}`
: "실시간";
const nextChange = change;
const nextChangeRate = Number.isFinite(changeRate)
? changeRate
: prev.prevClose > 0
? (nextChange / prev.prevClose) * 100
: prev.changeRate;
const nextHigh = prev.high > 0 ? Math.max(prev.high, price) : price;
const nextLow = prev.low > 0 ? Math.min(prev.low, price) : price;
const nextCandles =
prev.candles.length > 0 &&
prev.candles[prev.candles.length - 1]?.time === pointTime
? [
...prev.candles.slice(0, -1),
{
...prev.candles[prev.candles.length - 1],
time: pointTime,
price,
},
]
: [...prev.candles, { time: pointTime, price }].slice(-80);
return {
...prev,
currentPrice: price,
change: nextChange,
changeRate: nextChangeRate,
high: nextHigh,
low: nextLow,
volume: accumulatedVolume > 0 ? accumulatedVolume : prev.volume,
candles: nextCandles,
};
});
},
[],
);
return {
selectedStock,
setSelectedStock,
meta,
setMeta,
error,
setError,
isLoading,
loadOverview,
updateRealtimeTradeTick,
};
}

View File

@@ -0,0 +1,91 @@
import { useCallback, useRef, useState } from "react";
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type { DashboardStockSearchItem } from "@/features/dashboard/types/dashboard.types";
import { fetchStockSearch } from "@/features/dashboard/apis/kis-stock.api";
export function useStockSearch() {
const [keyword, setKeyword] = useState("삼성전자");
const [searchResults, setSearchResults] = useState<
DashboardStockSearchItem[]
>([]);
const [error, setError] = useState<string | null>(null);
const [isSearching, setIsSearching] = useState(false);
const requestIdRef = useRef(0);
const abortRef = useRef<AbortController | null>(null);
const loadSearch = useCallback(async (query: string) => {
const requestId = ++requestIdRef.current;
const controller = new AbortController();
abortRef.current?.abort();
abortRef.current = controller;
setIsSearching(true);
setError(null);
try {
const data = await fetchStockSearch(query, controller.signal);
if (requestId === requestIdRef.current) {
setSearchResults(data.items);
}
return data.items;
} catch (err) {
if (controller.signal.aborted) {
return [];
}
if (requestId === requestIdRef.current) {
setError(
err instanceof Error
? err.message
: "종목 검색 중 오류가 발생했습니다.",
);
}
return [];
} finally {
if (requestId === requestIdRef.current) {
setIsSearching(false);
}
}
}, []);
const search = useCallback(
(query: string, credentials: KisRuntimeCredentials | null) => {
if (!credentials) {
setError("API 키 검증이 필요합니다.");
setSearchResults([]);
setIsSearching(false);
return;
}
const trimmed = query.trim();
if (!trimmed) {
abortRef.current?.abort();
setSearchResults([]);
setError(null);
setIsSearching(false);
return;
}
void loadSearch(trimmed);
},
[loadSearch],
);
const clearSearch = useCallback(() => {
abortRef.current?.abort();
setSearchResults([]);
setError(null);
setIsSearching(false);
}, []);
return {
keyword,
setKeyword,
searchResults,
setSearchResults,
error,
setError,
isSearching,
search,
clearSearch,
};
}

View File

@@ -0,0 +1,229 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { KisTradingEnv } from "@/features/dashboard/types/dashboard.types";
import { fetchKisWebSocketApproval } from "@/features/dashboard/apis/kis-auth.api";
/**
* @file features/dashboard/store/use-kis-runtime-store.ts
* @description KIS 키 입력/검증 상태를 zustand로 관리하고 새로고침 시 복원합니다.
*/
export interface KisRuntimeCredentials {
appKey: string;
appSecret: string;
tradingEnv: KisTradingEnv;
accountNo: string;
}
interface KisRuntimeStoreState {
// [State] 입력 폼 상태
kisTradingEnvInput: KisTradingEnv;
kisAppKeyInput: string;
kisAppSecretInput: string;
kisAccountNoInput: string;
// [State] 검증/연동 상태
verifiedCredentials: KisRuntimeCredentials | null;
isKisVerified: boolean;
tradingEnv: KisTradingEnv;
// [State] 웹소켓 승인키
wsApprovalKey: string | null;
}
interface KisRuntimeStoreActions {
/**
* 거래 모드 입력값을 변경하고 기존 검증 상태를 무효화합니다.
* @param tradingEnv 거래 모드
* @see features/dashboard/components/dashboard-main.tsx 거래 모드 버튼 onClick 이벤트
*/
setKisTradingEnvInput: (tradingEnv: KisTradingEnv) => void;
/**
* 앱 키 입력값을 변경하고 기존 검증 상태를 무효화합니다.
* @param appKey 앱 키
* @see features/dashboard/components/dashboard-main.tsx App Key onChange 이벤트
*/
setKisAppKeyInput: (appKey: string) => void;
/**
* 앱 시크릿 입력값을 변경하고 기존 검증 상태를 무효화합니다.
* @param appSecret 앱 시크릿
* @see features/dashboard/components/dashboard-main.tsx App Secret onChange 이벤트
*/
setKisAppSecretInput: (appSecret: string) => void;
/**
* 계좌번호 입력값을 변경하고 기존 검증 상태를 무효화합니다.
* @param accountNo 계좌번호 (8자리-2자리)
*/
setKisAccountNoInput: (accountNo: string) => void;
/**
* 검증 성공 상태를 저장합니다.
* @param credentials 검증 완료된 키
* @param tradingEnv 현재 연동 모드
* @see features/dashboard/components/dashboard-main.tsx handleValidateKis
*/
setVerifiedKisSession: (
credentials: KisRuntimeCredentials,
tradingEnv: KisTradingEnv,
) => void;
/**
* 검증 실패 또는 입력 변경 시 검증 상태만 초기화합니다.
* @see features/dashboard/components/dashboard-main.tsx handleValidateKis catch
*/
invalidateKisVerification: () => void;
/**
* 접근 폐기 시 입력값/검증값을 모두 제거합니다.
* @param tradingEnv 표시용 모드
* @see features/dashboard/components/dashboard-main.tsx handleRevokeKis
*/
clearKisRuntimeSession: (tradingEnv: KisTradingEnv) => void;
/**
* 웹소켓 승인키를 가져오거나 없으면 발급받습니다.
* @returns approvalKey
*/
getOrFetchApprovalKey: () => Promise<string | null>;
}
const INITIAL_STATE: KisRuntimeStoreState = {
kisTradingEnvInput: "real",
kisAppKeyInput: "",
kisAppSecretInput: "",
kisAccountNoInput: "",
verifiedCredentials: null,
isKisVerified: false,
tradingEnv: "real",
wsApprovalKey: null,
};
// 동시 요청 방지를 위한 모듈 스코프 변수
let approvalPromise: Promise<string | null> | null = null;
export const useKisRuntimeStore = create<
KisRuntimeStoreState & KisRuntimeStoreActions
>()(
persist(
(set, get) => ({
...INITIAL_STATE,
setKisTradingEnvInput: (tradingEnv) =>
set({
kisTradingEnvInput: tradingEnv,
verifiedCredentials: null,
isKisVerified: false,
wsApprovalKey: null,
}),
setKisAppKeyInput: (appKey) =>
set({
kisAppKeyInput: appKey,
verifiedCredentials: null,
isKisVerified: false,
wsApprovalKey: null,
}),
setKisAppSecretInput: (appSecret) =>
set({
kisAppSecretInput: appSecret,
verifiedCredentials: null,
isKisVerified: false,
wsApprovalKey: null,
}),
setKisAccountNoInput: (accountNo) =>
set({
kisAccountNoInput: accountNo,
verifiedCredentials: null,
isKisVerified: false,
wsApprovalKey: null,
}),
setVerifiedKisSession: (credentials, tradingEnv) =>
set({
verifiedCredentials: credentials,
isKisVerified: true,
tradingEnv,
// 인증이 바뀌면 승인키도 초기화
wsApprovalKey: null,
}),
invalidateKisVerification: () =>
set({
verifiedCredentials: null,
isKisVerified: false,
wsApprovalKey: null,
}),
clearKisRuntimeSession: (tradingEnv) =>
set({
kisTradingEnvInput: tradingEnv,
kisAppKeyInput: "",
kisAppSecretInput: "",
kisAccountNoInput: "",
verifiedCredentials: null,
isKisVerified: false,
tradingEnv,
wsApprovalKey: null,
}),
getOrFetchApprovalKey: async () => {
const { wsApprovalKey, verifiedCredentials } = get();
// 1. 이미 키가 있으면 반환
if (wsApprovalKey) {
return wsApprovalKey;
}
// 2. 인증 정보가 없으면 실패
if (!verifiedCredentials) {
return null;
}
// 3. 이미 진행 중인 요청이 있다면 해당 Promise 반환 (Deduping)
if (approvalPromise) {
return approvalPromise;
}
// 4. API 호출
approvalPromise = (async () => {
try {
const data = await fetchKisWebSocketApproval(verifiedCredentials);
if (data.approvalKey) {
set({ wsApprovalKey: data.approvalKey });
return data.approvalKey;
}
return null;
} catch (error) {
console.error(error);
return null;
} finally {
approvalPromise = null;
}
})();
return approvalPromise;
},
}),
{
name: "autotrade-kis-runtime-store",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
kisTradingEnvInput: state.kisTradingEnvInput,
kisAppKeyInput: state.kisAppKeyInput,
kisAppSecretInput: state.kisAppSecretInput,
kisAccountNoInput: state.kisAccountNoInput,
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
tradingEnv: state.tradingEnv,
// wsApprovalKey도 로컬 스토리지에 저장하여 새로고침 후에도 유지 (선택사항이나 유지하는 게 유리)
// 단, 승인키 유효기간 문제가 있을 수 있으나 API 실패 시 재발급 로직을 넣거나,
// 현재 로직상 인증 정보가 바뀌면 초기화되므로 저장해도 무방.
// 하지만 유효기간 만료 처리가 없으므로 일단 저장하지 않는 게 안전할 수도 있음.
// 사용자가 "새로고침"을 하는 빈도보다 "일반적인 사용"이 많으므로 저장하지 않음 (partialize에서 제외)
// -> 코드를 보니 여기 포함시키지 않으면 저장이 안 됨.
// 유효기간 처리가 없으니 승인키는 메모리에만 유지하도록 함 (새로고침 시 재발급)
}),
},
),
);

View File

@@ -0,0 +1,212 @@
/**
* @file features/dashboard/types/dashboard.types.ts
* @description 대시보드(검색/시세/차트)에서 공통으로 쓰는 타입 모음
*/
export type KisTradingEnv = "real" | "mock";
export type DashboardPriceSource =
| "inquire-price"
| "inquire-ccnl"
| "inquire-overtime-price";
export type DashboardMarketPhase = "regular" | "afterHours";
/**
* KOSPI/KOSDAQ 종목 인덱스 항목
*/
export interface KoreanStockIndexItem {
symbol: string;
name: string;
market: "KOSPI" | "KOSDAQ";
standardCode: string;
}
/**
* 차트 1개 점(시점 + 가격)
*/
export interface StockCandlePoint {
time: string;
price: number;
open?: number;
high?: number;
low?: number;
close?: number;
volume?: number;
timestamp?: number;
}
export type DashboardChartTimeframe = "1m" | "30m" | "1h" | "1d" | "1w";
/**
* 호가창 1레벨(가격 + 잔량)
*/
export interface DashboardOrderBookLevel {
askPrice: number;
bidPrice: number;
askSize: number;
bidSize: number;
}
/**
* 대시보드 종목 상세 모델
*/
export interface DashboardStockItem {
symbol: string;
name: string;
market: "KOSPI" | "KOSDAQ";
currentPrice: number;
change: number;
changeRate: number;
open: number;
high: number;
low: number;
prevClose: number;
volume: number;
candles: StockCandlePoint[];
}
/**
* 검색 결과 1개 항목
*/
export interface DashboardStockSearchItem {
symbol: string;
name: string;
market: "KOSPI" | "KOSDAQ";
}
/**
* 종목 검색 API 응답
*/
export interface DashboardStockSearchResponse {
query: string;
items: DashboardStockSearchItem[];
total: number;
}
/**
* 종목 개요 API 응답
*/
export interface DashboardStockOverviewResponse {
stock: DashboardStockItem;
source: "kis";
priceSource: DashboardPriceSource;
marketPhase: DashboardMarketPhase;
tradingEnv: KisTradingEnv;
fetchedAt: string;
}
export interface DashboardStockChartResponse {
symbol: string;
timeframe: DashboardChartTimeframe;
candles: StockCandlePoint[];
nextCursor: string | null;
hasMore: boolean;
fetchedAt: string;
}
/**
* 종목 호가 API 응답
*/
export interface DashboardStockOrderBookResponse {
symbol: string;
source: "kis" | "REALTIME";
levels: DashboardOrderBookLevel[];
totalAskSize: number;
totalBidSize: number;
businessHour?: string;
hourClassCode?: string;
accumulatedVolume?: number;
anticipatedPrice?: number;
anticipatedVolume?: number;
anticipatedTotalVolume?: number;
anticipatedChange?: number;
anticipatedChangeSign?: string;
anticipatedChangeRate?: number;
totalAskSizeDelta?: number;
totalBidSizeDelta?: number;
tradingEnv: KisTradingEnv | string;
fetchedAt: string;
}
/**
* 실시간 체결(틱) 1건 모델
*/
export interface DashboardRealtimeTradeTick {
symbol: string;
tickTime: string;
price: number;
change: number;
changeRate: number;
tradeVolume: number;
accumulatedVolume: number;
tradeStrength: number;
askPrice1: number;
bidPrice1: number;
sellExecutionCount: number;
buyExecutionCount: number;
netBuyExecutionCount: number;
open: number;
high: number;
low: number;
}
export type DashboardOrderSide = "buy" | "sell";
export type DashboardOrderType = "limit" | "market";
/**
* 국내주식 현금 주문 요청 모델
*/
export interface DashboardStockCashOrderRequest {
symbol: string;
side: DashboardOrderSide;
orderType: DashboardOrderType;
quantity: number;
price: number;
accountNo: string;
accountProductCode: string;
}
/**
* 국내주식 현금 주문 응답 모델
*/
export interface DashboardStockCashOrderResponse {
ok: boolean;
tradingEnv: KisTradingEnv;
message: string;
orderNo?: string;
orderTime?: string;
orderOrgNo?: string;
}
/**
* KIS 키 검증 API 응답
*/
export interface DashboardKisValidateResponse {
ok: boolean;
tradingEnv: KisTradingEnv;
message: string;
sample?: {
symbol: string;
name: string;
currentPrice: number;
};
}
/**
* KIS 키 접근 폐기 API 응답
*/
export interface DashboardKisRevokeResponse {
ok: boolean;
tradingEnv: KisTradingEnv;
message: string;
}
/**
* KIS 웹소켓 승인키 발급 API 응답
*/
export interface DashboardKisWsApprovalResponse {
ok: boolean;
tradingEnv: KisTradingEnv;
message: string;
approvalKey?: string;
wsUrl?: string;
}

View File

@@ -0,0 +1,269 @@
import type {
DashboardRealtimeTradeTick,
DashboardStockOrderBookResponse,
StockCandlePoint,
} from "@/features/dashboard/types/dashboard.types";
const REALTIME_SIGN_NEGATIVE = new Set(["4", "5"]);
const TICK_FIELD_INDEX = {
symbol: 0,
tickTime: 1,
price: 2,
sign: 3,
change: 4,
changeRate: 5,
open: 7,
high: 8,
low: 9,
askPrice1: 10,
bidPrice1: 11,
tradeVolume: 12,
accumulatedVolume: 13,
sellExecutionCount: 15,
buyExecutionCount: 16,
netBuyExecutionCount: 17,
tradeStrength: 18,
} as const;
/**
* KIS 실시간 구독/해제 웹소켓 메시지를 생성합니다.
*/
export function buildKisRealtimeMessage(
approvalKey: string,
symbol: string,
trId: string,
trType: "1" | "2",
) {
return {
header: {
approval_key: approvalKey,
custtype: "P",
tr_type: trType,
"content-type": "utf-8",
},
body: {
input: {
tr_id: trId,
tr_key: symbol,
},
},
};
}
/**
* 실시간 체결 스트림(raw)을 배열 단위로 파싱합니다.
* - 배치 전송(복수 틱)일 때도 모든 틱을 추출
* - 심볼 불일치/가격 0 이하 데이터는 제외
*/
export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
if (!/^([01])\|/.test(raw)) return [] as DashboardRealtimeTradeTick[];
const parts = raw.split("|");
if (parts.length < 4) return [] as DashboardRealtimeTradeTick[];
// TR ID Check: Allow H0STCNT0 (Real/Mock) or H0STOUP0 (Overtime)
const receivedTrId = parts[1];
if (receivedTrId !== "H0STCNT0" && receivedTrId !== "H0STOUP0") {
// console.warn("[KisRealtime] Unknown TR ID for Trade Tick:", receivedTrId);
return [] as DashboardRealtimeTradeTick[];
}
// if (parts[1] !== expectedTrId) return [] as DashboardRealtimeTradeTick[];
const tickCount = Number(parts[2] ?? "1");
const values = parts[3].split("^");
if (values.length === 0) return [] as DashboardRealtimeTradeTick[];
const parsedCount =
Number.isInteger(tickCount) && tickCount > 0 ? tickCount : 1;
const fieldsPerTick = Math.floor(values.length / parsedCount);
if (fieldsPerTick <= TICK_FIELD_INDEX.tradeStrength) {
return [] as DashboardRealtimeTradeTick[];
}
const ticks: DashboardRealtimeTradeTick[] = [];
for (let index = 0; index < parsedCount; index++) {
const base = index * fieldsPerTick;
const symbol = readString(values, base + TICK_FIELD_INDEX.symbol);
if (symbol !== expectedSymbol) {
if (symbol.trim() !== expectedSymbol.trim()) {
console.warn(
`[KisRealtime] Symbol mismatch: received '${symbol}', expected '${expectedSymbol}'`,
);
continue;
}
}
const price = readNumber(values, base + TICK_FIELD_INDEX.price);
if (!Number.isFinite(price) || price <= 0) continue;
const sign = readString(values, base + TICK_FIELD_INDEX.sign);
const rawChange = readNumber(values, base + TICK_FIELD_INDEX.change);
const rawChangeRate = readNumber(
values,
base + TICK_FIELD_INDEX.changeRate,
);
const change = REALTIME_SIGN_NEGATIVE.has(sign)
? -Math.abs(rawChange)
: rawChange;
const changeRate = REALTIME_SIGN_NEGATIVE.has(sign)
? -Math.abs(rawChangeRate)
: rawChangeRate;
ticks.push({
symbol,
tickTime: readString(values, base + TICK_FIELD_INDEX.tickTime),
price,
change,
changeRate,
tradeVolume: readNumber(values, base + TICK_FIELD_INDEX.tradeVolume),
accumulatedVolume: readNumber(
values,
base + TICK_FIELD_INDEX.accumulatedVolume,
),
tradeStrength: readNumber(values, base + TICK_FIELD_INDEX.tradeStrength),
askPrice1: readNumber(values, base + TICK_FIELD_INDEX.askPrice1),
bidPrice1: readNumber(values, base + TICK_FIELD_INDEX.bidPrice1),
sellExecutionCount: readNumber(
values,
base + TICK_FIELD_INDEX.sellExecutionCount,
),
buyExecutionCount: readNumber(
values,
base + TICK_FIELD_INDEX.buyExecutionCount,
),
netBuyExecutionCount: readNumber(
values,
base + TICK_FIELD_INDEX.netBuyExecutionCount,
),
open: readNumber(values, base + TICK_FIELD_INDEX.open),
high: readNumber(values, base + TICK_FIELD_INDEX.high),
low: readNumber(values, base + TICK_FIELD_INDEX.low),
});
}
return ticks;
}
export function formatRealtimeTickTime(hhmmss?: string) {
if (!hhmmss || hhmmss.length !== 6) return "실시간";
return `${hhmmss.slice(0, 2)}:${hhmmss.slice(2, 4)}:${hhmmss.slice(4, 6)}`;
}
export function appendRealtimeTick(
prev: StockCandlePoint[],
next: StockCandlePoint,
) {
if (prev.length === 0) return [next];
const last = prev[prev.length - 1];
if (last.time === next.time) {
return [...prev.slice(0, -1), next];
}
return [...prev, next].slice(-80);
}
export function toTickOrderValue(hhmmss?: string) {
if (!hhmmss || !/^\d{6}$/.test(hhmmss)) return -1;
return Number(hhmmss);
}
/**
* KIS 실시간 호가(H0STASP0/H0UNASP0/H0STOAA0)를 OrderBook 구조로 파싱합니다.
*/
export function parseKisRealtimeOrderbook(
raw: string,
expectedSymbol: string,
): DashboardStockOrderBookResponse | null {
if (!/^([01])\|/.test(raw)) return null;
const parts = raw.split("|");
if (parts.length < 4) return null;
const trId = parts[1];
if (trId !== "H0STASP0" && trId !== "H0UNASP0" && trId !== "H0STOAA0") {
return null;
}
const values = parts[3].split("^");
const levelCount = trId === "H0STOAA0" ? 9 : 10;
const symbol = values[0]?.trim() ?? "";
const normalizedSymbol = normalizeDomesticSymbol(symbol);
const normalizedExpected = normalizeDomesticSymbol(expectedSymbol);
if (normalizedSymbol !== normalizedExpected) return null;
const askPriceStart = 3;
const bidPriceStart = askPriceStart + levelCount;
const askSizeStart = bidPriceStart + levelCount;
const bidSizeStart = askSizeStart + levelCount;
const totalAskIndex = bidSizeStart + levelCount;
const totalBidIndex = totalAskIndex + 1;
const anticipatedPriceIndex = totalBidIndex + 3;
const anticipatedVolumeIndex = anticipatedPriceIndex + 1;
const anticipatedTotalVolumeIndex = anticipatedPriceIndex + 2;
const anticipatedChangeIndex = anticipatedPriceIndex + 3;
const anticipatedChangeSignIndex = anticipatedPriceIndex + 4;
const anticipatedChangeRateIndex = anticipatedPriceIndex + 5;
const accumulatedVolumeIndex = anticipatedPriceIndex + 6;
const totalAskDeltaIndex = anticipatedPriceIndex + 7;
const totalBidDeltaIndex = anticipatedPriceIndex + 8;
const minFieldLength = totalBidDeltaIndex + 1;
if (values.length < minFieldLength) return null;
const realtimeLevels = Array.from({ length: levelCount }, (_, i) => ({
askPrice: readNumber(values, askPriceStart + i),
bidPrice: readNumber(values, bidPriceStart + i),
askSize: readNumber(values, askSizeStart + i),
bidSize: readNumber(values, bidSizeStart + i),
}));
return {
symbol: normalizedExpected,
totalAskSize: readNumber(values, totalAskIndex),
totalBidSize: readNumber(values, totalBidIndex),
businessHour: readString(values, 1),
hourClassCode: readString(values, 2),
anticipatedPrice: readNumber(values, anticipatedPriceIndex),
anticipatedVolume: readNumber(values, anticipatedVolumeIndex),
anticipatedTotalVolume: readNumber(values, anticipatedTotalVolumeIndex),
anticipatedChange: readNumber(values, anticipatedChangeIndex),
anticipatedChangeSign: readString(values, anticipatedChangeSignIndex),
anticipatedChangeRate: readNumber(values, anticipatedChangeRateIndex),
accumulatedVolume: readNumber(values, accumulatedVolumeIndex),
totalAskSizeDelta: readNumber(values, totalAskDeltaIndex),
totalBidSizeDelta: readNumber(values, totalBidDeltaIndex),
levels: realtimeLevels,
source: "REALTIME",
tradingEnv: "real",
fetchedAt: new Date().toISOString(),
};
}
/**
* @description 국내 종목코드 비교를 위해 접두 문자를 제거하고 6자리 코드로 정규화합니다.
* @see features/dashboard/utils/kis-realtime.utils.ts parseKisRealtimeOrderbook 종목 매칭 비교
*/
function normalizeDomesticSymbol(value: string) {
const trimmed = value.trim();
const digits = trimmed.replace(/\D/g, "");
if (digits.length >= 6) {
return digits.slice(-6);
}
return trimmed;
}
function readString(values: string[], index: number) {
return (values[index] ?? "").trim();
}
function readNumber(values: string[], index: number) {
const raw = readString(values, index).replaceAll(",", "");
const value = Number(raw);
return Number.isFinite(value) ? value : 0;
}

View File

@@ -1,11 +1,6 @@
/** /**
* @file features/layout/components/header.tsx * @file features/layout/components/header.tsx
* @description 애플리케이션 상단 헤더 컴포넌트 (네비게이션, 테마, 유저 메뉴) * @description 애플리케이션 상단 헤더 컴포넌트
* @remarks
* - [레이어] Components/UI/Layout
* - [사용자 행동] 홈 이동, 테마 변경, 로그인/회원가입 이동, 대시보드 이동
* - [데이터 흐름] User Prop -> UI Conditional Rendering
* - [연관 파일] layout.tsx, session-timer.tsx, user-menu.tsx
*/ */
import Link from "next/link"; import Link from "next/link";
@@ -14,74 +9,139 @@ import { AUTH_ROUTES } from "@/features/auth/constants";
import { UserMenu } from "@/features/layout/components/user-menu"; import { UserMenu } from "@/features/layout/components/user-menu";
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { SessionTimer } from "@/features/auth/components/session-timer"; import { SessionTimer } from "@/features/auth/components/session-timer";
import { cn } from "@/lib/utils";
interface HeaderProps { interface HeaderProps {
/** 현재 로그인 사용자 정보 (없으면 null) */ /** 현재 로그인 사용자 정보(null 가능) */
user: User | null; user: User | null;
/** 대시보드 링크 표시 여부 */ /** 대시보드 링크 버튼 노출 여부 */
showDashboardLink?: boolean; showDashboardLink?: boolean;
/** 홈 랜딩에서 배경과 자연스럽게 섞이는 헤더 모드 */
blendWithBackground?: boolean;
} }
/** /**
* 글로벌 헤더 컴포넌트 * 글로벌 헤더 컴포넌트
* @param user Supabase User 객체 * @param user Supabase User 객체
* @param showDashboardLink 대시보드 바로가기 버튼 노출 여부 * @param showDashboardLink 대시보드 버튼 노출 여부
* @param blendWithBackground 홈 랜딩 전용 반투명 모드
* @returns Header JSX * @returns Header JSX
* @see layout.tsx - RootLayout에서 데이터 주입하여 호출 * @see app/(home)/page.tsx 홈 랜딩에서 blendWithBackground=true로 호출
*/ */
export function Header({ user, showDashboardLink = false }: HeaderProps) { export function Header({
user,
showDashboardLink = false,
blendWithBackground = false,
}: HeaderProps) {
return ( return (
<header className="fixed top-0 z-40 w-full border-b border-border/40 bg-background/80 backdrop-blur-xl supports-backdrop-filter:bg-background/60"> <header
<div className="flex h-16 w-full items-center justify-between px-4 md:px-6"> className={cn(
{/* ========== 좌측: 로고 영역 ========== */} "fixed inset-x-0 top-0 z-50 w-full",
<Link href={AUTH_ROUTES.HOME} className="flex items-center gap-2 group"> blendWithBackground
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/10 transition-transform duration-200 group-hover:scale-110"> ? "text-white"
: "border-b border-border/40 bg-background/80 backdrop-blur-xl supports-backdrop-filter:bg-background/60",
)}
>
{blendWithBackground && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-linear-to-b from-black/70 via-black/35 to-transparent"
/>
)}
<div
className={cn(
"relative z-10 flex h-16 w-full items-center justify-between px-4 md:px-6",
blendWithBackground
? "bg-black/30 backdrop-blur-xl supports-backdrop-filter:bg-black/20"
: "",
)}
>
{/* ========== LEFT: LOGO SECTION ========== */}
<Link
href={AUTH_ROUTES.HOME}
className={cn("group flex items-center gap-2", blendWithBackground ? "text-white" : "")}
>
<div
className={cn(
"flex h-9 w-9 items-center justify-center rounded-xl transition-transform duration-200 group-hover:scale-110",
blendWithBackground ? "bg-brand-500/45 ring-1 ring-white/55" : "bg-primary/10",
)}
>
<div className="h-5 w-5 rounded-lg bg-linear-to-br from-brand-500 to-brand-700" /> <div className="h-5 w-5 rounded-lg bg-linear-to-br from-brand-500 to-brand-700" />
</div> </div>
<span className="text-xl font-bold tracking-tight text-foreground transition-colors group-hover:text-primary"> <span
className={cn(
"text-xl font-bold tracking-tight transition-colors",
blendWithBackground
? "!text-white [text-shadow:0_2px_18px_rgba(0,0,0,0.75)] group-hover:text-brand-100"
: "text-foreground group-hover:text-primary",
)}
>
AutoTrade AutoTrade
</span> </span>
</Link> </Link>
{/* ========== 우측: 액션 버튼 영역 ========== */} {/* ========== RIGHT: ACTION SECTION ========== */}
<div className="flex items-center gap-4"> <div className={cn("flex items-center gap-2 sm:gap-3", blendWithBackground ? "text-white" : "")}
{/* 테마 토글 */} >
<ThemeToggle /> <ThemeToggle
className={cn(
blendWithBackground
? "rounded-full border border-white/40 bg-black/50 !text-white backdrop-blur-md hover:bg-black/65 focus-visible:ring-white/80"
: "",
)}
iconClassName={blendWithBackground ? "!text-white" : undefined}
/>
{user ? ( {user ? (
// [Case 1] 로그인 상태
<> <>
{/* 세션 타임아웃 타이머 */} <SessionTimer blendWithBackground={blendWithBackground} />
<SessionTimer />
{showDashboardLink && ( {showDashboardLink && (
<Button <Button
asChild asChild
variant="ghost" variant="ghost"
size="sm" size="sm"
className="hidden sm:inline-flex" className={cn(
"hidden font-medium sm:inline-flex",
blendWithBackground
? "rounded-full border border-white/40 bg-black/50 !text-white backdrop-blur-md [text-shadow:0_1px_8px_rgba(0,0,0,0.45)] hover:bg-black/65 hover:!text-white"
: "",
)}
> >
<Link href={AUTH_ROUTES.DASHBOARD}></Link> <Link href={AUTH_ROUTES.DASHBOARD}></Link>
</Button> </Button>
)} )}
{/* 사용자 드롭다운 메뉴 */} <UserMenu user={user} blendWithBackground={blendWithBackground} />
<UserMenu user={user} />
</> </>
) : ( ) : (
// [Case 2] 비로그인 상태
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
asChild asChild
variant="ghost" variant="ghost"
size="sm" size="sm"
className="hidden sm:inline-flex" className={cn(
"hidden sm:inline-flex",
blendWithBackground
? "rounded-full border border-white/40 bg-black/50 !text-white backdrop-blur-md hover:bg-black/65 hover:!text-white"
: "",
)}
> >
<Link href={AUTH_ROUTES.LOGIN}></Link> <Link href={AUTH_ROUTES.LOGIN}></Link>
</Button> </Button>
<Button asChild size="sm" className="rounded-full px-6"> <Button
asChild
size="sm"
className={cn(
"rounded-full px-6",
blendWithBackground
? "bg-brand-500/90 text-white shadow-lg shadow-brand-700/40 hover:bg-brand-400"
: "",
)}
>
<Link href={AUTH_ROUTES.SIGNUP}></Link> <Link href={AUTH_ROUTES.SIGNUP}></Link>
</Button> </Button>
</div> </div>

View File

@@ -44,7 +44,7 @@ export function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
return ( return (
<aside className="fixed left-0 top-14 z-30 hidden h-[calc(100vh-3.5rem)] w-full shrink-0 overflow-y-auto border-r border-zinc-200 bg-white py-6 pl-2 pr-6 dark:border-zinc-800 dark:bg-black md:sticky md:block md:w-64 lg:w-72"> <aside className="hidden h-[calc(100vh-4rem)] shrink-0 overflow-y-auto border-r border-zinc-200 bg-white py-6 pl-2 pr-6 dark:border-zinc-800 dark:bg-black md:sticky md:top-16 md:block md:w-64 lg:w-72">
<div className="flex flex-col space-y-1"> <div className="flex flex-col space-y-1">
{MENU_ITEMS.map((item) => { {MENU_ITEMS.map((item) => {
const isActive = item.matchExact const isActive = item.matchExact

View File

@@ -1,14 +1,13 @@
/** /**
* @file features/layout/components/user-menu.tsx * @file features/layout/components/user-menu.tsx
* @description 사용자 프로필 드롭다운 메뉴 컴포넌트 * @description 사용자 프로필 드롭다운 메뉴 컴포넌트
* @remarks
* - [레이어] Components/UI
* - [사용자 행동] Avatar 클릭 -> 드롭다운 오픈 -> 프로필/설정 이동 또는 로그아웃
* - [연관 파일] header.tsx, features/auth/actions.ts (로그아웃)
*/ */
"use client"; "use client";
import { User } from "@supabase/supabase-js";
import { LogOut, Settings, User as UserIcon } from "lucide-react";
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";
import { import {
@@ -19,21 +18,23 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { User } from "@supabase/supabase-js"; import { cn } from "@/lib/utils";
import { LogOut, Settings, User as UserIcon } from "lucide-react";
import { useRouter } from "next/navigation";
interface UserMenuProps { interface UserMenuProps {
/** Supabase User 객체 */ /** Supabase User 객체 */
user: User | null; user: User | null;
/** 홈 랜딩의 shader 배경 위에서 대비를 높이는 모드 */
blendWithBackground?: boolean;
} }
/** /**
* 사용자 메뉴/프로필 컴포넌트 (로그인 시 헤더 노출) * 사용자 메뉴/프로필 컴포넌트
* @param user 로그인한 사용자 정보 * @param user 로그인한 사용자 정보
* @returns Avatar 버튼 및 드롭다운 메뉴 * @param blendWithBackground shader 배경 위 가독성 모드
* @returns Avatar 버튼 + 드롭다운 메뉴
* @see features/layout/components/header.tsx 헤더 우측 액션 영역에서 호출
*/ */
export function UserMenu({ user }: UserMenuProps) { export function UserMenu({ user, blendWithBackground = false }: UserMenuProps) {
const router = useRouter(); const router = useRouter();
if (!user) return null; if (!user) return null;
@@ -41,38 +42,55 @@ export function UserMenu({ user }: UserMenuProps) {
return ( return (
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button className="flex items-center gap-2 outline-none"> <button
<Avatar className="h-8 w-8 transition-opacity hover:opacity-80"> className={cn(
"flex items-center gap-2 rounded-full outline-none transition-colors",
blendWithBackground
? "ring-1 ring-white/30 hover:bg-black/30 focus-visible:ring-2 focus-visible:ring-white/70"
: "",
)}
aria-label="사용자 메뉴 열기"
>
<Avatar className="h-8 w-8 transition-opacity hover:opacity-90">
<AvatarImage src={user.user_metadata?.avatar_url} /> <AvatarImage src={user.user_metadata?.avatar_url} />
<AvatarFallback className="bg-linear-to-br from-brand-500 to-brand-700 text-white text-xs font-bold"> <AvatarFallback
className={cn(
"text-xs font-bold text-white",
blendWithBackground
? "bg-brand-500/90 [text-shadow:0_1px_8px_rgba(0,0,0,0.45)]"
: "bg-linear-to-br from-brand-500 to-brand-700",
)}
>
{user.email?.charAt(0).toUpperCase()} {user.email?.charAt(0).toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56"> <DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal"> <DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1"> <div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none"> <p className="text-sm font-medium leading-none">
{user.user_metadata?.full_name || {user.user_metadata?.full_name || user.user_metadata?.name || "사용자"}
user.user_metadata?.name ||
"사용자"}
</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email}
</p> </p>
<p className="text-xs leading-none text-muted-foreground">{user.email}</p>
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push("/profile")}> <DropdownMenuItem onClick={() => router.push("/profile")}>
<UserIcon className="mr-2 h-4 w-4" /> <UserIcon className="mr-2 h-4 w-4" />
<span></span> <span></span>
</DropdownMenuItem> </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>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<form action={signout}> <form action={signout}>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<button className="w-full text-red-600 dark:text-red-400"> <button className="w-full text-red-600 dark:text-red-400">

142
lib/kis/approval.ts Normal file
View File

@@ -0,0 +1,142 @@
import { createHash } from "node:crypto";
import type { KisCredentialInput } from "@/lib/kis/config";
import { getKisConfig, getKisWebSocketUrl } from "@/lib/kis/config";
/**
* @file lib/kis/approval.ts
* @description KIS 웹소켓 approval key 발급/캐시 관리
*/
interface KisApprovalResponse {
approval_key?: string;
msg1?: string;
msg_cd?: string;
error?: string;
error_description?: string;
}
interface KisApprovalCache {
approvalKey: string;
expiresAt: number;
}
const approvalCacheMap = new Map<string, KisApprovalCache>();
const approvalIssueInFlightMap = new Map<string, Promise<KisApprovalCache>>();
const APPROVAL_REFRESH_BUFFER_MS = 60 * 1000;
function hashKey(value: string) {
return createHash("sha256").update(value).digest("hex");
}
function getApprovalCacheKey(credentials?: KisCredentialInput) {
const config = getKisConfig(credentials);
return `${config.tradingEnv}:${hashKey(config.appKey)}`;
}
/**
* KIS 웹소켓 approval key 발급
* @param credentials 사용자 입력 키(선택)
* @returns approval key + expiresAt
* @see app/api/kis/ws/approval/route.ts POST - 실시간 차트 연결 시 호출
*/
async function issueKisApprovalKey(credentials?: KisCredentialInput): Promise<KisApprovalCache> {
const config = getKisConfig(credentials);
const response = await fetch(`${config.baseUrl}/oauth2/Approval`, {
method: "POST",
headers: {
"content-type": "application/json; charset=utf-8",
},
body: JSON.stringify({
grant_type: "client_credentials",
appkey: config.appKey,
secretkey: config.appSecret,
}),
cache: "no-store",
});
const rawText = await response.text();
const payload = tryParseApprovalResponse(rawText);
if (!response.ok || !payload.approval_key) {
const detail = [payload.msg1, payload.error_description, payload.error, payload.msg_cd]
.filter(Boolean)
.join(" / ");
throw new Error(
detail
? `KIS 웹소켓 승인키 발급 실패 (${config.tradingEnv}, ${response.status}): ${detail}`
: `KIS 웹소켓 승인키 발급 실패 (${config.tradingEnv}, ${response.status})`,
);
}
// 공식 샘플은 1일 단위 재발급을 권장하므로 토큰과 동일하게 보수적으로 23시간 캐시합니다.
return {
approvalKey: payload.approval_key,
expiresAt: Date.now() + 23 * 60 * 60 * 1000,
};
}
/**
* approval 응답을 안전하게 JSON으로 파싱합니다.
* @param rawText fetch 응답 원문
* @returns KisApprovalResponse
*/
function tryParseApprovalResponse(rawText: string): KisApprovalResponse {
try {
return JSON.parse(rawText) as KisApprovalResponse;
} catch {
return {
msg1: rawText.slice(0, 200),
};
}
}
/**
* 웹소켓 승인키를 반환합니다.
* @param credentials 사용자 입력 키(선택)
* @returns approval key
*/
export async function getKisApprovalKey(credentials?: KisCredentialInput) {
const cacheKey = getApprovalCacheKey(credentials);
const cached = approvalCacheMap.get(cacheKey);
if (cached && cached.expiresAt - APPROVAL_REFRESH_BUFFER_MS > Date.now()) {
return cached.approvalKey;
}
const inFlight = approvalIssueInFlightMap.get(cacheKey);
if (inFlight) {
const shared = await inFlight;
return shared.approvalKey;
}
const nextPromise = issueKisApprovalKey(credentials);
approvalIssueInFlightMap.set(cacheKey, nextPromise);
const next = await nextPromise.finally(() => {
approvalIssueInFlightMap.delete(cacheKey);
});
approvalCacheMap.set(cacheKey, next);
return next.approvalKey;
}
/**
* 거래 모드에 맞는 KIS 웹소켓 URL을 반환합니다.
* @param credentials 사용자 입력 키(선택)
* @returns websocket url
*/
export function resolveKisWebSocketUrl(credentials?: KisCredentialInput) {
const config = getKisConfig(credentials);
return getKisWebSocketUrl(config.tradingEnv);
}
/**
* 승인키 캐시를 제거합니다.
* @param credentials 사용자 입력 키(선택)
*/
export function clearKisApprovalKeyCache(credentials?: KisCredentialInput) {
const cacheKey = getApprovalCacheKey(credentials);
approvalCacheMap.delete(cacheKey);
approvalIssueInFlightMap.delete(cacheKey);
}

152
lib/kis/client.ts Normal file
View File

@@ -0,0 +1,152 @@
import type { KisCredentialInput } from "@/lib/kis/config";
import { getKisConfig } from "@/lib/kis/config";
import { getKisAccessToken } from "@/lib/kis/token";
/**
* @file lib/kis/client.ts
* @description KIS REST 공통 클라이언트(실전/모의 공통)
*/
export interface KisApiEnvelope<TOutput> {
rt_cd?: string;
msg_cd?: string;
msg1?: string;
output?: TOutput;
output1?: unknown;
output2?: unknown;
}
/**
* KIS GET 호출
* @param apiPath REST 경로
* @param trId KIS TR ID
* @param params 쿼리 파라미터
* @param credentials 사용자 입력 키(선택)
* @returns KIS 원본 응답
* @see lib/kis/domestic.ts getDomesticQuote/getDomesticDailyPrice - 대시보드 시세 데이터 소스
*/
export async function kisGet<TOutput>(
apiPath: string,
trId: string,
params: Record<string, string>,
credentials?: KisCredentialInput,
): Promise<KisApiEnvelope<TOutput>> {
const config = getKisConfig(credentials);
const token = await getKisAccessToken(credentials);
const url = new URL(apiPath, config.baseUrl);
Object.entries(params).forEach(([key, value]) => {
if (value != null) url.searchParams.set(key, value);
});
const response = await fetch(url.toString(), {
method: "GET",
headers: {
"content-type": "application/json; charset=utf-8",
authorization: `Bearer ${token}`,
appkey: config.appKey,
appsecret: config.appSecret,
tr_id: trId,
tr_cont: "",
custtype: "P",
},
cache: "no-store",
});
const rawText = await response.text();
const payload = tryParseKisEnvelope<TOutput>(rawText);
if (!response.ok) {
const detail = payload.msg1 || rawText.slice(0, 200);
throw new Error(
detail
? `KIS API 요청 실패 (${response.status}): ${detail}`
: `KIS API 요청 실패 (${response.status})`,
);
}
if (payload.rt_cd && payload.rt_cd !== "0") {
const detail = [payload.msg1, payload.msg_cd].filter(Boolean).join(" / ");
throw new Error(detail || "KIS API 비즈니스 오류가 발생했습니다.");
}
return payload;
}
/**
* KIS POST 호출 (주문 등)
* @param apiPath REST 경로
* @param trId KIS TR ID
* @param body 요청 본문
* @param credentials 사용자 입력 키(선택)
* @returns KIS 원본 응답
*/
export async function kisPost<TOutput>(
apiPath: string,
trId: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
body: Record<string, any>,
credentials?: KisCredentialInput,
): Promise<KisApiEnvelope<TOutput>> {
const config = getKisConfig(credentials);
const token = await getKisAccessToken(credentials);
const url = new URL(apiPath, config.baseUrl);
const response = await fetch(url.toString(), {
method: "POST",
headers: {
"content-type": "application/json; charset=utf-8",
authorization: `Bearer ${token}`,
appkey: config.appKey,
appsecret: config.appSecret,
tr_id: trId,
tr_cont: "",
custtype: "P",
},
body: JSON.stringify(body),
cache: "no-store",
});
const rawText = await response.text();
const payload = tryParseKisEnvelope<TOutput>(rawText);
if (!response.ok) {
const detail = payload.msg1 || rawText.slice(0, 200);
throw new Error(
detail
? `KIS API 요청 실패 (${response.status}): ${detail}`
: `KIS API 요청 실패 (${response.status})`,
);
}
if (payload.rt_cd && payload.rt_cd !== "0") {
const detail = [payload.msg1, payload.msg_cd].filter(Boolean).join(" / ");
throw new Error(detail || "KIS API 비즈니스 오류가 발생했습니다.");
}
return payload;
}
/**
* KIS 응답을 안전하게 JSON으로 파싱합니다.
* @param rawText fetch 응답 원문
* @returns KisApiEnvelope
* @see lib/kis/token.ts tryParseTokenResponse - 토큰 발급 응답 파싱 방식과 동일 패턴
*/
function tryParseKisEnvelope<TOutput>(
rawText: string,
): KisApiEnvelope<TOutput> {
try {
return JSON.parse(rawText) as KisApiEnvelope<TOutput>;
} catch {
return {
msg1: rawText.slice(0, 200),
};
}
}
// 하위 호환(alias)
// 하위 호환(alias)
export const kisMockGet = kisGet;
export const kisMockPost = kisPost;

122
lib/kis/config.ts Normal file
View File

@@ -0,0 +1,122 @@
/**
* @file lib/kis/config.ts
* @description KIS 거래 환경(real/mock) 설정과 키/도메인 로딩
*/
export type KisTradingEnv = "real" | "mock";
export interface KisCredentialInput {
tradingEnv?: KisTradingEnv;
appKey?: string;
appSecret?: string;
baseUrl?: string;
}
export interface KisConfig {
tradingEnv: KisTradingEnv;
appKey: string;
appSecret: string;
baseUrl: string;
}
const DEFAULT_KIS_REAL_BASE_URL = "https://openapi.koreainvestment.com:9443";
const DEFAULT_KIS_MOCK_BASE_URL = "https://openapivts.koreainvestment.com:29443";
const DEFAULT_KIS_REAL_WS_URL = "ws://ops.koreainvestment.com:21000";
const DEFAULT_KIS_MOCK_WS_URL = "ws://ops.koreainvestment.com:31000";
/**
* 거래 환경 문자열을 정규화합니다.
* @param value 환경값
* @returns real | mock
*/
export function normalizeTradingEnv(value?: string): KisTradingEnv {
return (value ?? "mock").toLowerCase() === "real" ? "real" : "mock";
}
/**
* 현재 거래 환경을 반환합니다.
* @returns real | mock
*/
export function getKisTradingEnv() {
return normalizeTradingEnv(process.env.KIS_TRADING_ENV);
}
/**
* KIS 웹소켓 URL을 반환합니다.
* @param tradingEnvInput 거래 모드(real/mock)
* @returns websocket base url
*/
export function getKisWebSocketUrl(tradingEnvInput?: KisTradingEnv) {
const tradingEnv = normalizeTradingEnv(tradingEnvInput);
if (tradingEnv === "real") {
return process.env.KIS_WS_URL_REAL ?? DEFAULT_KIS_REAL_WS_URL;
}
return process.env.KIS_WS_URL_MOCK ?? DEFAULT_KIS_MOCK_WS_URL;
}
/**
* 설정 준비 여부를 확인합니다.
* @param input 외부(사용자 입력) 키가 있으면 우선 사용
* @returns 사용 가능 여부
*/
export function hasKisConfig(input?: KisCredentialInput) {
if (input?.appKey && input?.appSecret) return true;
const env = getKisTradingEnv();
if (env === "real") {
return Boolean(process.env.KIS_APP_KEY_REAL && process.env.KIS_APP_SECRET_REAL);
}
return Boolean(process.env.KIS_APP_KEY_MOCK && process.env.KIS_APP_SECRET_MOCK);
}
/**
* KIS 호출에 필요한 설정을 반환합니다.
* @param input 사용자 입력 키(선택)
* @returns tradingEnv/appKey/appSecret/baseUrl
*/
export function getKisConfig(input?: KisCredentialInput): KisConfig {
if (input?.appKey && input?.appSecret) {
const tradingEnv = normalizeTradingEnv(input.tradingEnv);
const baseUrl =
input.baseUrl ??
(tradingEnv === "real" ? DEFAULT_KIS_REAL_BASE_URL : DEFAULT_KIS_MOCK_BASE_URL);
return {
tradingEnv,
appKey: input.appKey,
appSecret: input.appSecret,
baseUrl,
};
}
const tradingEnv = getKisTradingEnv();
if (tradingEnv === "real") {
const appKey = process.env.KIS_APP_KEY_REAL;
const appSecret = process.env.KIS_APP_SECRET_REAL;
const baseUrl = process.env.KIS_BASE_URL_REAL ?? DEFAULT_KIS_REAL_BASE_URL;
if (!appKey || !appSecret) {
throw new Error(
"KIS 실전투자 키가 없습니다. KIS_APP_KEY_REAL, KIS_APP_SECRET_REAL 환경변수를 설정하세요.",
);
}
return { tradingEnv, appKey, appSecret, baseUrl };
}
const appKey = process.env.KIS_APP_KEY_MOCK;
const appSecret = process.env.KIS_APP_SECRET_MOCK;
const baseUrl = process.env.KIS_BASE_URL_MOCK ?? DEFAULT_KIS_MOCK_BASE_URL;
if (!appKey || !appSecret) {
throw new Error(
"KIS 모의투자 키가 없습니다. KIS_APP_KEY_MOCK, KIS_APP_SECRET_MOCK 환경변수를 설정하세요.",
);
}
return { tradingEnv, appKey, appSecret, baseUrl };
}

777
lib/kis/domestic.ts Normal file
View File

@@ -0,0 +1,777 @@
import type {
DashboardChartTimeframe,
DashboardStockItem,
StockCandlePoint,
} from "@/features/dashboard/types/dashboard.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { kisGet } from "@/lib/kis/client";
/**
* @file lib/kis/domestic.ts
* @description KIS 국내주식(현재가/일봉) 조회와 대시보드 모델 변환
*/
interface KisDomesticQuoteOutput {
hts_kor_isnm?: string;
rprs_mrkt_kor_name?: string;
bstp_kor_isnm?: string;
stck_prpr?: string;
prdy_vrss?: string;
prdy_vrss_sign?: string;
prdy_ctrt?: string;
stck_oprc?: string;
stck_hgpr?: string;
stck_lwpr?: string;
stck_sdpr?: string;
stck_prdy_clpr?: string;
acml_vol?: string;
}
interface KisDomesticCcnlOutput {
stck_prpr?: string;
prdy_vrss?: string;
prdy_vrss_sign?: string;
prdy_ctrt?: string;
cntg_vol?: string;
}
interface KisDomesticOvertimePriceOutput {
ovtm_untp_prpr?: string;
ovtm_untp_prdy_vrss?: string;
ovtm_untp_prdy_vrss_sign?: string;
ovtm_untp_prdy_ctrt?: string;
ovtm_untp_vol?: string;
ovtm_untp_oprc?: string;
ovtm_untp_hgpr?: string;
ovtm_untp_lwpr?: string;
}
interface KisDomesticDailyPriceOutput {
stck_bsop_date?: string;
stck_oprc?: string;
stck_hgpr?: string;
stck_lwpr?: string;
stck_clpr?: 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 {
stck_prpr?: string;
total_askp_rsqn?: string;
total_bidp_rsqn?: string;
askp1?: string;
askp2?: string;
askp3?: string;
askp4?: string;
askp5?: string;
askp6?: string;
askp7?: string;
askp8?: string;
askp9?: string;
askp10?: string;
bidp1?: string;
bidp2?: string;
bidp3?: string;
bidp4?: string;
bidp5?: string;
bidp6?: string;
bidp7?: string;
bidp8?: string;
bidp9?: string;
bidp10?: string;
askp_rsqn1?: string;
askp_rsqn2?: string;
askp_rsqn3?: string;
askp_rsqn4?: string;
askp_rsqn5?: string;
askp_rsqn6?: string;
askp_rsqn7?: string;
askp_rsqn8?: string;
askp_rsqn9?: string;
askp_rsqn10?: string;
bidp_rsqn1?: string;
bidp_rsqn2?: string;
bidp_rsqn3?: string;
bidp_rsqn4?: string;
bidp_rsqn5?: string;
bidp_rsqn6?: string;
bidp_rsqn7?: string;
bidp_rsqn8?: string;
bidp_rsqn9?: string;
bidp_rsqn10?: string;
}
interface DashboardStockFallbackMeta {
name?: string;
market?: "KOSPI" | "KOSDAQ";
}
export type DomesticMarketPhase = "regular" | "afterHours";
export type DomesticPriceSource =
| "inquire-price"
| "inquire-ccnl"
| "inquire-overtime-price";
interface DomesticOverviewResult {
stock: DashboardStockItem;
priceSource: DomesticPriceSource;
marketPhase: DomesticMarketPhase;
}
/**
* 국내주식 현재가 조회
* @param symbol 6자리 종목코드
* @param credentials 사용자 입력 키
* @returns KIS 현재가 output
*/
export async function getDomesticQuote(
symbol: string,
credentials?: KisCredentialInput,
) {
const response = await kisGet<KisDomesticQuoteOutput>(
"/uapi/domestic-stock/v1/quotations/inquire-price",
"FHKST01010100",
{
FID_COND_MRKT_DIV_CODE: resolvePriceMarketDivCode(),
FID_INPUT_ISCD: symbol,
},
credentials,
);
return response.output ?? {};
}
/**
* 국내주식 일자별 시세 조회
* @param symbol 6자리 종목코드
* @param credentials 사용자 입력 키
* @returns KIS 일봉 output 배열
*/
export async function getDomesticDailyPrice(
symbol: string,
credentials?: KisCredentialInput,
) {
const response = await kisGet<KisDomesticDailyPriceOutput[]>(
"/uapi/domestic-stock/v1/quotations/inquire-daily-price",
"FHKST01010400",
{
FID_COND_MRKT_DIV_CODE: "J",
FID_INPUT_ISCD: symbol,
FID_PERIOD_DIV_CODE: "D",
FID_ORG_ADJ_PRC: "1",
},
credentials,
);
return Array.isArray(response.output) ? response.output : [];
}
/**
* 국내주식 현재가 체결 조회
* @param symbol 6자리 종목코드
* @param credentials 사용자 입력 키
* @returns KIS 체결 output
* @see app/api/kis/domestic/overview/route.ts GET - 대시보드 상세 응답에서 장중 가격 우선 소스
*/
export async function getDomesticConclusion(
symbol: string,
credentials?: KisCredentialInput,
) {
const response = await kisGet<
KisDomesticCcnlOutput | KisDomesticCcnlOutput[]
>(
"/uapi/domestic-stock/v1/quotations/inquire-ccnl",
"FHKST01010300",
{
FID_COND_MRKT_DIV_CODE: resolvePriceMarketDivCode(),
FID_INPUT_ISCD: symbol,
},
credentials,
);
const output = response.output;
if (Array.isArray(output)) return output[0] ?? {};
return output ?? {};
}
/**
* 국내주식 시간외 현재가 조회
* @param symbol 6자리 종목코드
* @param credentials 사용자 입력 키
* @returns KIS 시간외 현재가 output
* @see app/api/kis/domestic/overview/route.ts GET - 대시보드 상세 응답에서 장외 가격 우선 소스
*/
export async function getDomesticOvertimePrice(
symbol: string,
credentials?: KisCredentialInput,
) {
const response = await kisGet<KisDomesticOvertimePriceOutput>(
"/uapi/domestic-stock/v1/quotations/inquire-overtime-price",
"FHPST02300000",
{
FID_COND_MRKT_DIV_CODE: "J",
FID_INPUT_ISCD: symbol,
},
credentials,
);
return response.output ?? {};
}
/**
* 국내주식 호가(10단계) 조회
* @param symbol 6자리 종목코드
* @param credentials 사용자 입력 키
* @returns KIS 호가 output
*/
export async function getDomesticOrderBook(
symbol: string,
credentials?: KisCredentialInput,
) {
const response = await kisGet<KisDomesticOrderBookOutput>(
"/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn",
"FHKST01010200",
{
FID_COND_MRKT_DIV_CODE: resolvePriceMarketDivCode(),
FID_INPUT_ISCD: symbol,
},
credentials,
);
if (response.output && typeof response.output === "object") {
return response.output;
}
if (response.output1 && typeof response.output1 === "object") {
return response.output1 as KisDomesticOrderBookOutput;
}
return {};
}
/**
* 현재가 + 일봉을 대시보드 모델로 변환
* @param symbol 6자리 종목코드
* @param fallbackMeta 보정 메타(종목명/시장)
* @param credentials 사용자 입력 키
* @returns DashboardStockItem
*/
export async function getDomesticOverview(
symbol: string,
fallbackMeta?: DashboardStockFallbackMeta,
credentials?: KisCredentialInput,
): Promise<DomesticOverviewResult> {
const marketPhase = getDomesticMarketPhaseInKst();
const emptyQuote: KisDomesticQuoteOutput = {};
const emptyDaily: KisDomesticDailyPriceOutput[] = [];
const emptyCcnl: KisDomesticCcnlOutput = {};
const emptyOvertime: KisDomesticOvertimePriceOutput = {};
const [quote, daily, ccnl, overtime] = await Promise.all([
getDomesticQuote(symbol, credentials).catch(() => emptyQuote),
getDomesticDailyPrice(symbol, credentials).catch(() => emptyDaily),
getDomesticConclusion(symbol, credentials).catch(() => emptyCcnl),
marketPhase === "afterHours"
? getDomesticOvertimePrice(symbol, credentials).catch(() => emptyOvertime)
: Promise.resolve(emptyOvertime),
]);
const currentPrice =
firstDefinedNumber(
toOptionalNumber(ccnl.stck_prpr),
toOptionalNumber(overtime.ovtm_untp_prpr),
toOptionalNumber(quote.stck_prpr),
) ?? 0;
const currentPriceSource = resolveCurrentPriceSource(
marketPhase,
overtime,
ccnl,
quote,
);
const rawChange =
firstDefinedNumber(
toOptionalNumber(ccnl.prdy_vrss),
toOptionalNumber(overtime.ovtm_untp_prdy_vrss),
toOptionalNumber(quote.prdy_vrss),
) ?? 0;
const signCode = firstDefinedString(
ccnl.prdy_vrss_sign,
overtime.ovtm_untp_prdy_vrss_sign,
quote.prdy_vrss_sign,
);
const change = normalizeSignedValue(rawChange, signCode);
const rawChangeRate =
firstDefinedNumber(
toOptionalNumber(ccnl.prdy_ctrt),
toOptionalNumber(overtime.ovtm_untp_prdy_ctrt),
toOptionalNumber(quote.prdy_ctrt),
) ?? 0;
const changeRate = normalizeSignedValue(rawChangeRate, signCode);
const prevClose = firstPositive(
toNumber(quote.stck_sdpr),
toNumber(quote.stck_prdy_clpr),
Math.max(currentPrice - change, 0),
);
const candles = toCandles(daily, currentPrice);
return {
stock: {
symbol,
name: quote.hts_kor_isnm?.trim() || fallbackMeta?.name || symbol,
market: resolveMarket(
quote.rprs_mrkt_kor_name,
quote.bstp_kor_isnm,
fallbackMeta?.market,
),
currentPrice,
change,
changeRate,
open: firstPositive(
toNumber(overtime.ovtm_untp_oprc),
toNumber(quote.stck_oprc),
currentPrice,
),
high: firstPositive(
toNumber(overtime.ovtm_untp_hgpr),
toNumber(quote.stck_hgpr),
currentPrice,
),
low: firstPositive(
toNumber(overtime.ovtm_untp_lwpr),
toNumber(quote.stck_lwpr),
currentPrice,
),
prevClose,
volume: firstPositive(
toNumber(overtime.ovtm_untp_vol),
toNumber(quote.acml_vol),
toNumber(ccnl.cntg_vol),
),
candles,
},
priceSource: currentPriceSource,
marketPhase,
};
}
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;
}
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;
}
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(now = new Date()): DomesticMarketPhase {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Asia/Seoul",
weekday: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).formatToParts(now);
const partMap = new Map(parts.map((part) => [part.type, part.value]));
const weekday = partMap.get("weekday");
const hour = Number(partMap.get("hour") ?? "0");
const minute = Number(partMap.get("minute") ?? "0");
const totalMinutes = hour * 60 + minute;
if (weekday === "Sat" || weekday === "Sun") return "afterHours";
if (totalMinutes >= 9 * 60 && totalMinutes < 15 * 60 + 30) return "regular";
return "afterHours";
}
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() {
return "J";
}
function firstPositive(...values: number[]) {
return values.find((value) => value > 0) ?? 0;
}
export interface DomesticChartResult {
candles: StockCandlePoint[];
nextCursor: string | null;
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 [...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;
}
// ─── 차트 데이터 조회 메인 ─────────────────────────────────
/**
* 종목 차트 데이터 조회 (일봉/주봉/분봉)
* - 일봉/주봉: inquire-daily-itemchartprice (FHKST03010100), 과거 페이징 지원
* - 분봉: inquire-time-itemchartprice (FHKST03010200), **당일 데이터만** 제공
*/
export async function getDomesticChart(
symbol: string,
timeframe: DashboardChartTimeframe,
credentials?: KisCredentialInput,
cursor?: string,
): Promise<DomesticChartResult> {
// ── 일봉 / 주봉 ──
if (timeframe === "1d" || timeframe === "1w") {
const endDate = cursor && /^\d{8}$/.test(cursor) ? cursor : nowYmdInKst();
const startDate = shiftYmd(endDate, timeframe === "1w" ? -700 : -365);
const response = await kisGet<unknown>(
"/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice",
"FHKST03010100",
{
FID_COND_MRKT_DIV_CODE: "J",
FID_INPUT_ISCD: symbol,
FID_INPUT_DATE_1: startDate,
FID_INPUT_DATE_2: endDate,
FID_PERIOD_DIV_CODE: timeframe === "1w" ? "W" : "D",
FID_ORG_ADJ_PRC: "1",
},
credentials,
);
const parsed = parseOutput2Rows(response)
.map(parseDayCandleRow)
.filter((c): c is StockCandlePoint => Boolean(c))
.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
const oldest = parsed[0];
const nextCursor =
parsed.length >= 95 && oldest?.timestamp
? shiftYmd(
new Date(oldest.timestamp * 1000)
.toISOString()
.slice(0, 10)
.replaceAll("-", ""),
-1,
)
: null;
return { candles: parsed, hasMore: Boolean(nextCursor), nextCursor };
}
// ── 분봉 (1m / 30m / 1h) — 당일 데이터만 제공 ──
const response = await kisGet<unknown>(
"/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice",
"FHKST03010200",
{
FID_COND_MRKT_DIV_CODE: "J",
FID_INPUT_ISCD: symbol,
FID_INPUT_HOUR_1: nowHmsInKst(),
FID_PW_DATA_INCU_YN: "Y",
FID_ETC_CLS_CODE: "",
},
credentials,
);
const minuteBucket = minutesForTimeframe(timeframe);
const candles = mergeCandlesByTimestamp(
parseOutput2Rows(response)
.map((row) => parseMinuteCandleRow(row, minuteBucket))
.filter((c): c is StockCandlePoint => Boolean(c)),
);
// 당일 분봉만 제공되므로 과거 페이징 불필요
return { candles, hasMore: false, nextCursor: null };
}

305
lib/kis/token.ts Normal file
View File

@@ -0,0 +1,305 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { KisCredentialInput } from "@/lib/kis/config";
import { clearKisApprovalKeyCache } from "@/lib/kis/approval";
import { getKisConfig } from "@/lib/kis/config";
/**
* @file lib/kis/token.ts
* @description KIS access token 발급/캐시 관리(실전/모의 공통)
*/
interface KisTokenResponse {
access_token?: string;
access_token_token_expired?: string;
expires_in?: number;
msg1?: string;
msg_cd?: string;
error?: string;
error_description?: string;
}
interface KisTokenCache {
token: string;
expiresAt: number;
}
interface KisRevokeResponse {
code?: number | string;
message?: string;
msg1?: string;
}
const tokenCacheMap = new Map<string, KisTokenCache>();
const tokenIssueInFlightMap = new Map<string, Promise<KisTokenCache>>();
const TOKEN_REFRESH_BUFFER_MS = 60 * 1000;
const TOKEN_CACHE_FILE_PATH = join(process.cwd(), ".tmp", "kis-token-cache.json");
function hashKey(value: string) {
return createHash("sha256").update(value).digest("hex");
}
function getTokenCacheKey(credentials?: KisCredentialInput) {
const config = getKisConfig(credentials);
return `${config.tradingEnv}:${hashKey(config.appKey)}`;
}
interface PersistedTokenCache {
[cacheKey: string]: KisTokenCache;
}
async function readPersistedTokenCache() {
try {
const raw = await readFile(TOKEN_CACHE_FILE_PATH, "utf8");
return JSON.parse(raw) as PersistedTokenCache;
} catch {
return {};
}
}
async function writePersistedTokenCache(next: PersistedTokenCache) {
await mkdir(join(process.cwd(), ".tmp"), { recursive: true });
await writeFile(TOKEN_CACHE_FILE_PATH, JSON.stringify(next), "utf8");
}
async function getPersistedToken(cacheKey: string) {
const cache = await readPersistedTokenCache();
const token = cache[cacheKey];
if (!token) return null;
if (token.expiresAt - TOKEN_REFRESH_BUFFER_MS <= Date.now()) {
delete cache[cacheKey];
await writePersistedTokenCache(cache);
return null;
}
return token;
}
async function setPersistedToken(cacheKey: string, token: KisTokenCache) {
const cache = await readPersistedTokenCache();
cache[cacheKey] = token;
await writePersistedTokenCache(cache);
}
async function clearPersistedToken(cacheKey: string) {
const cache = await readPersistedTokenCache();
if (!(cacheKey in cache)) return;
delete cache[cacheKey];
if (Object.keys(cache).length === 0) {
try {
await unlink(TOKEN_CACHE_FILE_PATH);
} catch {
// ignore
}
return;
}
await writePersistedTokenCache(cache);
}
/**
* KIS access token 발급
* @param credentials 사용자 입력 키(선택)
* @returns token + expiresAt
* @see app/api/kis/validate/route.ts POST - 사용자 키 검증 시 토큰 발급 경로
*/
async function issueKisToken(credentials?: KisCredentialInput): Promise<KisTokenCache> {
const config = getKisConfig(credentials);
const tokenUrl = `${config.baseUrl}/oauth2/tokenP`;
const response = await fetch(tokenUrl, {
method: "POST",
headers: {
"content-type": "application/json; charset=utf-8",
},
body: JSON.stringify({
grant_type: "client_credentials",
appkey: config.appKey,
appsecret: config.appSecret,
}),
cache: "no-store",
});
const rawText = await response.text();
const payload = tryParseTokenResponse(rawText);
if (!response.ok || !payload.access_token) {
const detail = [payload.msg1, payload.error_description, payload.error, payload.msg_cd]
.filter(Boolean)
.join(" / ");
const hint = buildTokenIssueHint(detail, config.tradingEnv);
throw new Error(
detail
? `KIS 토큰 발급 실패 (${config.tradingEnv}, ${response.status}): ${detail}${hint}`
: `KIS 토큰 발급 실패 (${config.tradingEnv}, ${response.status})${hint}`,
);
}
return {
token: payload.access_token,
expiresAt: resolveTokenExpiry(payload),
};
}
/**
* 토큰 발급 실패 시 점검 안내를 생성합니다.
* @param detail KIS 응답 메시지
* @param tradingEnv 거래 모드(real/mock)
* @returns 점검 안내 문자열
* @see https://apiportal.koreainvestment.com/apiservice-apiservice
*/
function buildTokenIssueHint(detail: string, tradingEnv: "real" | "mock") {
const lower = detail.toLowerCase();
const keyError =
lower.includes("appkey") ||
lower.includes("appsecret") ||
lower.includes("secret") ||
lower.includes("invalid") ||
lower.includes("인증");
if (keyError) {
return ` | 점검: ${tradingEnv === "real" ? "실전" : "모의"} 앱키/시크릿 쌍이 맞는지 확인하세요.`;
}
return " | 점검: KIS API 포털에서 앱 상태(사용 가능/차단)와 실전·모의 구분을 다시 확인하세요.";
}
/**
* 토큰 응답 문자열을 안전하게 JSON으로 변환합니다.
* @param rawText fetch 응답 원문
* @returns KisTokenResponse
*/
function tryParseTokenResponse(rawText: string): KisTokenResponse {
try {
return JSON.parse(rawText) as KisTokenResponse;
} catch {
// JSON 파싱 실패 시에도 호출부에서 상태코드 기반 에러를 만들 수 있게 기본 객체를 반환합니다.
return {
msg1: rawText.slice(0, 200),
};
}
}
/**
* 토큰 만료시각 계산
* @param payload 토큰 응답
* @returns epoch ms
*/
function resolveTokenExpiry(payload: KisTokenResponse) {
if (payload.access_token_token_expired) {
const parsed = Date.parse(payload.access_token_token_expired.replace(" ", "T"));
if (!Number.isNaN(parsed)) return parsed;
}
if (typeof payload.expires_in === "number" && payload.expires_in > 0) {
return Date.now() + payload.expires_in * 1000;
}
return Date.now() + 23 * 60 * 60 * 1000;
}
/**
* access token 반환(환경/키 단위 메모리 캐시)
* @param credentials 사용자 입력 키(선택)
* @returns access token
* @see lib/kis/domestic.ts getDomesticOverview - 현재가/일봉 병렬 조회 시 공용 토큰 사용
*/
export async function getKisAccessToken(credentials?: KisCredentialInput) {
const cacheKey = getTokenCacheKey(credentials);
const cached = tokenCacheMap.get(cacheKey);
if (cached && cached.expiresAt - TOKEN_REFRESH_BUFFER_MS > Date.now()) {
return cached.token;
}
const persisted = await getPersistedToken(cacheKey);
if (persisted) {
tokenCacheMap.set(cacheKey, persisted);
return persisted.token;
}
// 같은 키로 동시에 요청이 들어오면 토큰 발급을 1회로 합칩니다.
const inFlight = tokenIssueInFlightMap.get(cacheKey);
if (inFlight) {
const shared = await inFlight;
return shared.token;
}
const nextPromise = issueKisToken(credentials);
tokenIssueInFlightMap.set(cacheKey, nextPromise);
const next = await nextPromise.finally(() => {
tokenIssueInFlightMap.delete(cacheKey);
});
tokenCacheMap.set(cacheKey, next);
await setPersistedToken(cacheKey, next);
return next.token;
}
/**
* KIS access token 폐기 요청
* @param credentials 사용자 입력 키(선택)
* @returns 폐기 응답 메시지
* @see app/api/kis/revoke/route.ts POST - 대시보드 접근 폐기 버튼 처리
*/
export async function revokeKisAccessToken(credentials?: KisCredentialInput) {
const config = getKisConfig(credentials);
const cacheKey = getTokenCacheKey(credentials);
const token = await getKisAccessToken(credentials);
const response = await fetch(`${config.baseUrl}/oauth2/revokeP`, {
method: "POST",
headers: {
"content-type": "application/json; charset=utf-8",
},
body: JSON.stringify({
appkey: config.appKey,
appsecret: config.appSecret,
token,
}),
cache: "no-store",
});
const rawText = await response.text();
const payload = tryParseRevokeResponse(rawText);
const code = payload.code != null ? String(payload.code) : "";
const isSuccessCode = code === "" || code === "200";
if (!response.ok || !isSuccessCode) {
const detail = [payload.message, payload.msg1].filter(Boolean).join(" / ");
throw new Error(
detail
? `KIS 토큰 폐기 실패 (${config.tradingEnv}, ${response.status}): ${detail}`
: `KIS 토큰 폐기 실패 (${config.tradingEnv}, ${response.status})`,
);
}
tokenCacheMap.delete(cacheKey);
tokenIssueInFlightMap.delete(cacheKey);
await clearPersistedToken(cacheKey);
clearKisApprovalKeyCache(credentials);
return payload.message ?? "접근토큰 폐기에 성공하였습니다.";
}
/**
* 토큰 폐기 응답 문자열을 안전하게 JSON으로 변환합니다.
* @param rawText fetch 응답 원문
* @returns KisRevokeResponse
* @see https://apiportal.koreainvestment.com/apiservice-apiservice?/oauth2/revokeP
*/
function tryParseRevokeResponse(rawText: string): KisRevokeResponse {
try {
return JSON.parse(rawText) as KisRevokeResponse;
} catch {
return {
message: rawText.slice(0, 200),
};
}
}

80
lib/kis/trade.ts Normal file
View File

@@ -0,0 +1,80 @@
import { kisPost } from "@/lib/kis/client";
import { KisCredentialInput } from "@/lib/kis/config";
import {
DashboardOrderSide,
DashboardOrderType,
} from "@/features/dashboard/types/dashboard.types";
/**
* @file lib/kis/trade.ts
* @description KIS 주식 주문/잔고 관련 API
*/
export interface KisOrderCashOutput {
KRX_FWDG_ORD_ORGNO?: string; // 한국거래소전송주문조직번호
ODNO?: string; // 주문번호
ORD_TMD?: string; // 주문시각
}
interface KisOrderCashBody {
CANO: string; // 종합계좌번호(8자리)
ACNT_PRDT_CD: string; // 계좌상품코드(2자리)
PDNO: string; // 종목코드
ORD_DVSN: string; // 주문구분(00:지정가, 01:시장가...)
ORD_QTY: string; // 주문수량
ORD_UNPR: string; // 주문단가
}
/**
* 현금 주문(매수/매도) 실행
*/
export async function executeOrderCash(
params: {
symbol: string;
side: DashboardOrderSide;
orderType: DashboardOrderType;
quantity: number;
price: number;
accountNo: string;
accountProductCode: string;
},
credentials?: KisCredentialInput,
) {
const trId = resolveOrderTrId(params.side, credentials?.tradingEnv);
const ordDvsn = resolveOrderDivision(params.orderType);
const body: KisOrderCashBody = {
CANO: params.accountNo,
ACNT_PRDT_CD: params.accountProductCode,
PDNO: params.symbol,
ORD_DVSN: ordDvsn,
ORD_QTY: String(params.quantity),
ORD_UNPR: String(params.price),
};
const response = await kisPost<KisOrderCashOutput>(
"/uapi/domestic-stock/v1/trading/order-cash",
trId,
body,
credentials,
);
return response.output ?? {};
}
function resolveOrderTrId(side: DashboardOrderSide, env?: "real" | "mock") {
const isMock = env === "mock";
if (side === "buy") {
// 매수
return isMock ? "VTTC0802U" : "TTTC0802U";
} else {
// 매도
return isMock ? "VTTC0801U" : "TTTC0801U";
}
}
function resolveOrderDivision(type: DashboardOrderType) {
// 00: 지정가, 01: 시장가
if (type === "market") return "01";
return "00";
}

16
package-lock.json generated
View File

@@ -24,6 +24,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.31.0", "framer-motion": "^12.31.0",
"lightweight-charts": "^5.1.0",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"next": "16.1.6", "next": "16.1.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@@ -6863,6 +6864,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/fancy-canvas": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz",
"integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==",
"license": "MIT"
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -8266,6 +8273,15 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/lightweight-charts": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.1.0.tgz",
"integrity": "sha512-jEAYR4ODYeyNZcWUigsoLTl52rbPmgXnvd5FLIv/ZoA/2sSDw63YKnef8n4yhzum7W926yHeFwlm7ididKb7YQ==",
"license": "Apache-2.0",
"dependencies": {
"fancy-canvas": "2.1.0"
}
},
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",

View File

@@ -25,6 +25,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.31.0", "framer-motion": "^12.31.0",
"lightweight-charts": "^5.1.0",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"next": "16.1.6", "next": "16.1.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",

799
temp-kis-auth.py Normal file
View File

@@ -0,0 +1,799 @@
# -*- coding: utf-8 -*-
# ====| (REST) 접근 토큰 / (Websocket) 웹소켓 접속키 발급 에 필요한 API 호출 샘플 아래 참고하시기 바랍니다. |=====================
# ====| API 호출 공통 함수 포함 |=====================
import asyncio
import copy
import json
import logging
import os
import time
from base64 import b64decode
from collections import namedtuple
from collections.abc import Callable
from datetime import datetime
from io import StringIO
import pandas as pd
# pip install requests (패키지설치)
import requests
# 웹 소켓 모듈을 선언한다.
import websockets
# pip install PyYAML (패키지설치)
import yaml
from Crypto.Cipher import AES
# pip install pycryptodome
from Crypto.Util.Padding import unpad
clearConsole = lambda: os.system("cls" if os.name in ("nt", "dos") else "clear")
key_bytes = 32
config_root = os.path.join(os.path.expanduser("~"), "KIS", "config")
# config_root = "$HOME/KIS/config/" # 토큰 파일이 저장될 폴더, 제3자가 찾기 어렵도록 경로 설정하시기 바랍니다.
# token_tmp = config_root + 'KIS000000' # 토큰 로컬저장시 파일 이름 지정, 파일이름을 토큰값이 유추가능한 파일명은 삼가바랍니다.
# token_tmp = config_root + 'KIS' + datetime.today().strftime("%Y%m%d%H%M%S") # 토큰 로컬저장시 파일명 년월일시분초
token_tmp = os.path.join(
config_root, f"KIS{datetime.today().strftime("%Y%m%d")}"
) # 토큰 로컬저장시 파일명 년월일
# 접근토큰 관리하는 파일 존재여부 체크, 없으면 생성
if os.path.exists(token_tmp) == False:
f = open(token_tmp, "w+")
# 앱키, 앱시크리트, 토큰, 계좌번호 등 저장관리, 자신만의 경로와 파일명으로 설정하시기 바랍니다.
# pip install PyYAML (패키지설치)
with open(os.path.join(config_root, "kis_devlp.yaml"), encoding="UTF-8") as f:
_cfg = yaml.load(f, Loader=yaml.FullLoader)
_TRENV = tuple()
_last_auth_time = datetime.now()
_autoReAuth = False
_DEBUG = False
_isPaper = False
_smartSleep = 0.1
# 기본 헤더값 정의
_base_headers = {
"Content-Type": "application/json",
"Accept": "text/plain",
"charset": "UTF-8",
"User-Agent": _cfg["my_agent"],
}
# 토큰 발급 받아 저장 (토큰값, 토큰 유효시간,1일, 6시간 이내 발급신청시는 기존 토큰값과 동일, 발급시 알림톡 발송)
def save_token(my_token, my_expired):
# print(type(my_expired), my_expired)
valid_date = datetime.strptime(my_expired, "%Y-%m-%d %H:%M:%S")
# print('Save token date: ', valid_date)
with open(token_tmp, "w", encoding="utf-8") as f:
f.write(f"token: {my_token}\n")
f.write(f"valid-date: {valid_date}\n")
# 토큰 확인 (토큰값, 토큰 유효시간_1일, 6시간 이내 발급신청시는 기존 토큰값과 동일, 발급시 알림톡 발송)
def read_token():
try:
# 토큰이 저장된 파일 읽기
with open(token_tmp, encoding="UTF-8") as f:
tkg_tmp = yaml.load(f, Loader=yaml.FullLoader)
# 토큰 만료 일,시간
exp_dt = datetime.strftime(tkg_tmp["valid-date"], "%Y-%m-%d %H:%M:%S")
# 현재일자,시간
now_dt = datetime.today().strftime("%Y-%m-%d %H:%M:%S")
# print('expire dt: ', exp_dt, ' vs now dt:', now_dt)
# 저장된 토큰 만료일자 체크 (만료일시 > 현재일시 인경우 보관 토큰 리턴)
if exp_dt > now_dt:
return tkg_tmp["token"]
else:
# print('Need new token: ', tkg_tmp['valid-date'])
return None
except Exception:
# print('read token error: ', e)
return None
# 토큰 유효시간 체크해서 만료된 토큰이면 재발급처리
def _getBaseHeader():
if _autoReAuth:
reAuth()
return copy.deepcopy(_base_headers)
# 가져오기 : 앱키, 앱시크리트, 종합계좌번호(계좌번호 중 숫자8자리), 계좌상품코드(계좌번호 중 숫자2자리), 토큰, 도메인
def _setTRENV(cfg):
nt1 = namedtuple(
"KISEnv",
["my_app", "my_sec", "my_acct", "my_prod", "my_htsid", "my_token", "my_url", "my_url_ws"],
)
d = {
"my_app": cfg["my_app"], # 앱키
"my_sec": cfg["my_sec"], # 앱시크리트
"my_acct": cfg["my_acct"], # 종합계좌번호(8자리)
"my_prod": cfg["my_prod"], # 계좌상품코드(2자리)
"my_htsid": cfg["my_htsid"], # HTS ID
"my_token": cfg["my_token"], # 토큰
"my_url": cfg[
"my_url"
], # 실전 도메인 (https://openapi.koreainvestment.com:9443)
"my_url_ws": cfg["my_url_ws"],
} # 모의 도메인 (https://openapivts.koreainvestment.com:29443)
# print(cfg['my_app'])
global _TRENV
_TRENV = nt1(**d)
def isPaperTrading(): # 모의투자 매매
return _isPaper
# 실전투자면 'prod', 모의투자면 'vps'를 셋팅 하시기 바랍니다.
def changeTREnv(token_key, svr="prod", product=_cfg["my_prod"]):
cfg = dict()
global _isPaper
if svr == "prod": # 실전투자
ak1 = "my_app" # 실전투자용 앱키
ak2 = "my_sec" # 실전투자용 앱시크리트
_isPaper = False
_smartSleep = 0.05
elif svr == "vps": # 모의투자
ak1 = "paper_app" # 모의투자용 앱키
ak2 = "paper_sec" # 모의투자용 앱시크리트
_isPaper = True
_smartSleep = 0.5
cfg["my_app"] = _cfg[ak1]
cfg["my_sec"] = _cfg[ak2]
if svr == "prod" and product == "01": # 실전투자 주식투자, 위탁계좌, 투자계좌
cfg["my_acct"] = _cfg["my_acct_stock"]
elif svr == "prod" and product == "03": # 실전투자 선물옵션(파생)
cfg["my_acct"] = _cfg["my_acct_future"]
elif svr == "prod" and product == "08": # 실전투자 해외선물옵션(파생)
cfg["my_acct"] = _cfg["my_acct_future"]
elif svr == "prod" and product == "22": # 실전투자 개인연금저축계좌
cfg["my_acct"] = _cfg["my_acct_stock"]
elif svr == "prod" and product == "29": # 실전투자 퇴직연금계좌
cfg["my_acct"] = _cfg["my_acct_stock"]
elif svr == "vps" and product == "01": # 모의투자 주식투자, 위탁계좌, 투자계좌
cfg["my_acct"] = _cfg["my_paper_stock"]
elif svr == "vps" and product == "03": # 모의투자 선물옵션(파생)
cfg["my_acct"] = _cfg["my_paper_future"]
cfg["my_prod"] = product
cfg["my_htsid"] = _cfg["my_htsid"]
cfg["my_url"] = _cfg[svr]
try:
my_token = _TRENV.my_token
except AttributeError:
my_token = ""
cfg["my_token"] = my_token if token_key else token_key
cfg["my_url_ws"] = _cfg["ops" if svr == "prod" else "vops"]
# print(cfg)
_setTRENV(cfg)
def _getResultObject(json_data):
_tc_ = namedtuple("res", json_data.keys())
return _tc_(**json_data)
# Token 발급, 유효기간 1일, 6시간 이내 발급시 기존 token값 유지, 발급시 알림톡 무조건 발송
# 모의투자인 경우 svr='vps', 투자계좌(01)이 아닌경우 product='XX' 변경하세요 (계좌번호 뒤 2자리)
def auth(svr="prod", product=_cfg["my_prod"], url=None):
p = {
"grant_type": "client_credentials",
}
# 개인 환경파일 "kis_devlp.yaml" 파일을 참조하여 앱키, 앱시크리트 정보 가져오기
# 개인 환경파일명과 위치는 고객님만 아는 위치로 설정 바랍니다.
if svr == "prod": # 실전투자
ak1 = "my_app" # 앱키 (실전투자용)
ak2 = "my_sec" # 앱시크리트 (실전투자용)
elif svr == "vps": # 모의투자
ak1 = "paper_app" # 앱키 (모의투자용)
ak2 = "paper_sec" # 앱시크리트 (모의투자용)
# 앱키, 앱시크리트 가져오기
p["appkey"] = _cfg[ak1]
p["appsecret"] = _cfg[ak2]
# 기존 발급된 토큰이 있는지 확인
saved_token = read_token() # 기존 발급 토큰 확인
# print("saved_token: ", saved_token)
if saved_token is None: # 기존 발급 토큰 확인이 안되면 발급처리
url = f"{_cfg[svr]}/oauth2/tokenP"
res = requests.post(
url, data=json.dumps(p), headers=_getBaseHeader()
) # 토큰 발급
rescode = res.status_code
if rescode == 200: # 토큰 정상 발급
my_token = _getResultObject(res.json()).access_token # 토큰값 가져오기
my_expired = _getResultObject(
res.json()
).access_token_token_expired # 토큰값 만료일시 가져오기
save_token(my_token, my_expired) # 새로 발급 받은 토큰 저장
else:
print("Get Authentification token fail!\nYou have to restart your app!!!")
return
else:
my_token = saved_token # 기존 발급 토큰 확인되어 기존 토큰 사용
# 발급토큰 정보 포함해서 헤더값 저장 관리, API 호출시 필요
changeTREnv(my_token, svr, product)
_base_headers["authorization"] = f"Bearer {my_token}"
_base_headers["appkey"] = _TRENV.my_app
_base_headers["appsecret"] = _TRENV.my_sec
global _last_auth_time
_last_auth_time = datetime.now()
if _DEBUG:
print(f"[{_last_auth_time}] => get AUTH Key completed!")
# end of initialize, 토큰 재발급, 토큰 발급시 유효시간 1일
# 프로그램 실행시 _last_auth_time에 저장하여 유효시간 체크, 유효시간 만료시 토큰 발급 처리
def reAuth(svr="prod", product=_cfg["my_prod"]):
n2 = datetime.now()
if (n2 - _last_auth_time).seconds >= 86400: # 유효시간 1일
auth(svr, product)
def getEnv():
return _cfg
def smart_sleep():
if _DEBUG:
print(f"[RateLimit] Sleeping {_smartSleep}s ")
time.sleep(_smartSleep)
def getTREnv():
return _TRENV
# 주문 API에서 사용할 hash key값을 받아 header에 설정해 주는 함수
# 현재는 hash key 필수 사항아님, 생략가능, API 호출과정에서 변조 우려를 하는 경우 사용
# Input: HTTP Header, HTTP post param
# Output: None
def set_order_hash_key(h, p):
url = f"{getTREnv().my_url}/uapi/hashkey" # hashkey 발급 API URL
res = requests.post(url, data=json.dumps(p), headers=h)
rescode = res.status_code
if rescode == 200:
h["hashkey"] = _getResultObject(res.json()).HASH
else:
print("Error:", rescode)
# API 호출 응답에 필요한 처리 공통 함수
class APIResp:
def __init__(self, resp):
self._rescode = resp.status_code
self._resp = resp
self._header = self._setHeader()
self._body = self._setBody()
self._err_code = self._body.msg_cd
self._err_message = self._body.msg1
def getResCode(self):
return self._rescode
def _setHeader(self):
fld = dict()
for x in self._resp.headers.keys():
if x.islower():
fld[x] = self._resp.headers.get(x)
_th_ = namedtuple("header", fld.keys())
return _th_(**fld)
def _setBody(self):
_tb_ = namedtuple("body", self._resp.json().keys())
return _tb_(**self._resp.json())
def getHeader(self):
return self._header
def getBody(self):
return self._body
def getResponse(self):
return self._resp
def isOK(self):
try:
if self.getBody().rt_cd == "0":
return True
else:
return False
except:
return False
def getErrorCode(self):
return self._err_code
def getErrorMessage(self):
return self._err_message
def printAll(self):
print("<Header>")
for x in self.getHeader()._fields:
print(f"\t-{x}: {getattr(self.getHeader(), x)}")
print("<Body>")
for x in self.getBody()._fields:
print(f"\t-{x}: {getattr(self.getBody(), x)}")
def printError(self, url):
print(
"-------------------------------\nError in response: ",
self.getResCode(),
" url=",
url,
)
print(
"rt_cd : ",
self.getBody().rt_cd,
"/ msg_cd : ",
self.getErrorCode(),
"/ msg1 : ",
self.getErrorMessage(),
)
print("-------------------------------")
# end of class APIResp
class APIRespError(APIResp):
def __init__(self, status_code, error_text):
# 부모 생성자 호출하지 않고 직접 초기화
self.status_code = status_code
self.error_text = error_text
self._error_code = str(status_code)
self._error_message = error_text
def isOK(self):
return False
def getErrorCode(self):
return self._error_code
def getErrorMessage(self):
return self._error_message
def getBody(self):
# 빈 객체 리턴 (속성 접근 시 AttributeError 방지)
class EmptyBody:
def __getattr__(self, name):
return None
return EmptyBody()
def getHeader(self):
# 빈 객체 리턴
class EmptyHeader:
tr_cont = ""
def __getattr__(self, name):
return ""
return EmptyHeader()
def printAll(self):
print(f"=== ERROR RESPONSE ===")
print(f"Status Code: {self.status_code}")
print(f"Error Message: {self.error_text}")
print(f"======================")
def printError(self, url=""):
print(f"Error Code : {self.status_code} | {self.error_text}")
if url:
print(f"URL: {url}")
########### API call wrapping : API 호출 공통
def _url_fetch(
api_url, ptr_id, tr_cont, params, appendHeaders=None, postFlag=False, hashFlag=True
):
url = f"{getTREnv().my_url}{api_url}"
headers = _getBaseHeader() # 기본 header 값 정리
# 추가 Header 설정
tr_id = ptr_id
if ptr_id[0] in ("T", "J", "C"): # 실전투자용 TR id 체크
if isPaperTrading(): # 모의투자용 TR id 식별
tr_id = "V" + ptr_id[1:]
headers["tr_id"] = tr_id # 트랜젝션 TR id
headers["custtype"] = "P" # 일반(개인고객,법인고객) "P", 제휴사 "B"
headers["tr_cont"] = tr_cont # 트랜젝션 TR id
if appendHeaders is not None:
if len(appendHeaders) > 0:
for x in appendHeaders.keys():
headers[x] = appendHeaders.get(x)
if _DEBUG:
print("< Sending Info >")
print(f"URL: {url}, TR: {tr_id}")
print(f"<header>\n{headers}")
print(f"<body>\n{params}")
if postFlag:
# if (hashFlag): set_order_hash_key(headers, params)
res = requests.post(url, headers=headers, data=json.dumps(params))
else:
res = requests.get(url, headers=headers, params=params)
if res.status_code == 200:
ar = APIResp(res)
if _DEBUG:
ar.printAll()
return ar
else:
print("Error Code : " + str(res.status_code) + " | " + res.text)
return APIRespError(res.status_code, res.text)
# auth()
# print("Pass through the end of the line")
########### New - websocket 대응
_base_headers_ws = {
"content-type": "utf-8",
}
def _getBaseHeader_ws():
if _autoReAuth:
reAuth_ws()
return copy.deepcopy(_base_headers_ws)
def auth_ws(svr="prod", product=_cfg["my_prod"]):
p = {"grant_type": "client_credentials"}
if svr == "prod":
ak1 = "my_app"
ak2 = "my_sec"
elif svr == "vps":
ak1 = "paper_app"
ak2 = "paper_sec"
p["appkey"] = _cfg[ak1]
p["secretkey"] = _cfg[ak2]
url = f"{_cfg[svr]}/oauth2/Approval"
res = requests.post(url, data=json.dumps(p), headers=_getBaseHeader()) # 토큰 발급
rescode = res.status_code
if rescode == 200: # 토큰 정상 발급
approval_key = _getResultObject(res.json()).approval_key
else:
print("Get Approval token fail!\nYou have to restart your app!!!")
return
changeTREnv(None, svr, product)
_base_headers_ws["approval_key"] = approval_key
global _last_auth_time
_last_auth_time = datetime.now()
if _DEBUG:
print(f"[{_last_auth_time}] => get AUTH Key completed!")
def reAuth_ws(svr="prod", product=_cfg["my_prod"]):
n2 = datetime.now()
if (n2 - _last_auth_time).seconds >= 86400:
auth_ws(svr, product)
def data_fetch(tr_id, tr_type, params, appendHeaders=None) -> dict:
headers = _getBaseHeader_ws() # 기본 header 값 정리
headers["tr_type"] = tr_type
headers["custtype"] = "P"
if appendHeaders is not None:
if len(appendHeaders) > 0:
for x in appendHeaders.keys():
headers[x] = appendHeaders.get(x)
if _DEBUG:
print("< Sending Info >")
print(f"TR: {tr_id}")
print(f"<header>\n{headers}")
inp = {
"tr_id": tr_id,
}
inp.update(params)
return {"header": headers, "body": {"input": inp}}
# iv, ekey, encrypt 는 각 기능 메소드 파일에 저장할 수 있도록 dict에서 return 하도록
def system_resp(data):
isPingPong = False
isUnSub = False
isOk = False
tr_msg = None
tr_key = None
encrypt, iv, ekey = None, None, None
rdic = json.loads(data)
tr_id = rdic["header"]["tr_id"]
if tr_id != "PINGPONG":
tr_key = rdic["header"]["tr_key"]
encrypt = rdic["header"]["encrypt"]
if rdic.get("body", None) is not None:
isOk = True if rdic["body"]["rt_cd"] == "0" else False
tr_msg = rdic["body"]["msg1"]
# 복호화를 위한 key 를 추출
if "output" in rdic["body"]:
iv = rdic["body"]["output"]["iv"]
ekey = rdic["body"]["output"]["key"]
isUnSub = True if tr_msg[:5] == "UNSUB" else False
else:
isPingPong = True if tr_id == "PINGPONG" else False
nt2 = namedtuple(
"SysMsg",
[
"isOk",
"tr_id",
"tr_key",
"isUnSub",
"isPingPong",
"tr_msg",
"iv",
"ekey",
"encrypt",
],
)
d = {
"isOk": isOk,
"tr_id": tr_id,
"tr_key": tr_key,
"tr_msg": tr_msg,
"isUnSub": isUnSub,
"isPingPong": isPingPong,
"iv": iv,
"ekey": ekey,
"encrypt": encrypt,
}
return nt2(**d)
def aes_cbc_base64_dec(key, iv, cipher_text):
if key is None or iv is None:
raise AttributeError("key and iv cannot be None")
cipher = AES.new(key.encode("utf-8"), AES.MODE_CBC, iv.encode("utf-8"))
return bytes.decode(unpad(cipher.decrypt(b64decode(cipher_text)), AES.block_size))
#####
open_map: dict = {}
def add_open_map(
name: str,
request: Callable[[str, str, ...], (dict, list[str])],
data: str | list[str],
kwargs: dict = None,
):
if open_map.get(name, None) is None:
open_map[name] = {
"func": request,
"items": [],
"kwargs": kwargs,
}
if type(data) is list:
open_map[name]["items"] += data
elif type(data) is str:
open_map[name]["items"].append(data)
data_map: dict = {}
def add_data_map(
tr_id: str,
columns: list = None,
encrypt: str = None,
key: str = None,
iv: str = None,
):
if data_map.get(tr_id, None) is None:
data_map[tr_id] = {"columns": [], "encrypt": False, "key": None, "iv": None}
if columns is not None:
data_map[tr_id]["columns"] = columns
if encrypt is not None:
data_map[tr_id]["encrypt"] = encrypt
if key is not None:
data_map[tr_id]["key"] = key
if iv is not None:
data_map[tr_id]["iv"] = iv
class KISWebSocket:
api_url: str = ""
on_result: Callable[
[websockets.ClientConnection, str, pd.DataFrame, dict], None
] = None
result_all_data: bool = False
retry_count: int = 0
amx_retries: int = 0
# init
def __init__(self, api_url: str, max_retries: int = 3):
self.api_url = api_url
self.max_retries = max_retries
# private
async def __subscriber(self, ws: websockets.ClientConnection):
async for raw in ws:
logging.info("received message >> %s" % raw)
show_result = False
df = pd.DataFrame()
if raw[0] in ["0", "1"]:
d1 = raw.split("|")
if len(d1) < 4:
raise ValueError("data not found...")
tr_id = d1[1]
dm = data_map[tr_id]
d = d1[3]
if dm.get("encrypt", None) == "Y":
d = aes_cbc_base64_dec(dm["key"], dm["iv"], d)
df = pd.read_csv(
StringIO(d), header=None, sep="^", names=dm["columns"], dtype=object
)
show_result = True
else:
rsp = system_resp(raw)
tr_id = rsp.tr_id
add_data_map(
tr_id=rsp.tr_id, encrypt=rsp.encrypt, key=rsp.ekey, iv=rsp.iv
)
if rsp.isPingPong:
print(f"### RECV [PINGPONG] [{raw}]")
await ws.pong(raw)
print(f"### SEND [PINGPONG] [{raw}]")
if self.result_all_data:
show_result = True
if show_result is True and self.on_result is not None:
self.on_result(ws, tr_id, df, data_map[tr_id])
async def __runner(self):
if len(open_map.keys()) > 40:
raise ValueError("Subscription's max is 40")
url = f"{getTREnv().my_url_ws}{self.api_url}"
while self.retry_count < self.max_retries:
try:
async with websockets.connect(url) as ws:
# request subscribe
for name, obj in open_map.items():
await self.send_multiple(
ws, obj["func"], "1", obj["items"], obj["kwargs"]
)
# subscriber
await asyncio.gather(
self.__subscriber(ws),
)
except Exception as e:
print("Connection exception >> ", e)
self.retry_count += 1
await asyncio.sleep(1)
# func
@classmethod
async def send(
cls,
ws: websockets.ClientConnection,
request: Callable[[str, str, ...], (dict, list[str])],
tr_type: str,
data: str,
kwargs: dict = None,
):
k = {} if kwargs is None else kwargs
msg, columns = request(tr_type, data, **k)
add_data_map(tr_id=msg["body"]["input"]["tr_id"], columns=columns)
logging.info("send message >> %s" % json.dumps(msg))
await ws.send(json.dumps(msg))
smart_sleep()
async def send_multiple(
self,
ws: websockets.ClientConnection,
request: Callable[[str, str, ...], (dict, list[str])],
tr_type: str,
data: list | str,
kwargs: dict = None,
):
if type(data) is str:
await self.send(ws, request, tr_type, data, kwargs)
elif type(data) is list:
for d in data:
await self.send(ws, request, tr_type, d, kwargs)
else:
raise ValueError("data must be str or list")
@classmethod
def subscribe(
cls,
request: Callable[[str, str, ...], (dict, list[str])],
data: list | str,
kwargs: dict = None,
):
add_open_map(request.__name__, request, data, kwargs)
def unsubscribe(
self,
ws: websockets.ClientConnection,
request: Callable[[str, str, ...], (dict, list[str])],
data: list | str,
):
self.send_multiple(ws, request, "2", data)
# start
def start(
self,
on_result: Callable[
[websockets.ClientConnection, str, pd.DataFrame, dict], None
],
result_all_data: bool = False,
):
self.on_result = on_result
self.result_all_data = result_all_data
try:
asyncio.run(self.__runner())
except KeyboardInterrupt:
print("Closing by KeyboardInterrupt")

View File

@@ -0,0 +1,182 @@
import sys
import logging
import pandas as pd
sys.path.extend(['..', '.'])
import kis_auth as ka
from domestic_stock_functions_ws import *
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# 인증
ka.auth()
ka.auth_ws()
trenv = ka.getTREnv()
# 웹소켓 선언
kws = ka.KISWebSocket(api_url="/tryitout")
##############################################################################################
# [국내주식] 실시간시세 > 국내주식 실시간호가 (KRX) [실시간-004]
##############################################################################################
kws.subscribe(request=asking_price_krx, data=["005930", "000660"])
##############################################################################################
# [국내주식] 실시간시세 > 국내주식 실시간호가 (NXT)
##############################################################################################
kws.subscribe(request=asking_price_nxt, data=["005930", "000660"])
##############################################################################################
# [국내주식] 실시간시세 > 국내주식 실시간호가 (통합)
##############################################################################################
kws.subscribe(request=asking_price_total, data=["005930", "000660"])
##############################################################################################
# [국내주식] 실시간시세 > 국내주식 실시간체결가(KRX) [실시간-003]
##############################################################################################
kws.subscribe(request=ccnl_krx, data=["005930", "000660"])
##############################################################################################
# [국내주식] 실시간시세 > 국내주식 주식체결통보 [실시간-005]
##############################################################################################
kws.subscribe(request=ccnl_notice, data=[trenv.my_htsid])
##############################################################################################
# [국내주식] 실시간시세 > 국내주식 실시간체결가 (NXT)
##############################################################################################
kws.subscribe(request=ccnl_nxt, data=["005930", "000660"])
##############################################################################################
# [국내주식] 실시간시세 > 국내주식 실시간체결가 (통합)
##############################################################################################
kws.subscribe(request=ccnl_total, data=["005930", "000660"])
##############################################################################################
# [국내주식] 실시간시세 > 국내주식 실시간예상체결 (KRX) [실시간-041]
##############################################################################################
kws.subscribe(request=exp_ccnl_krx, data=["005930", "000660"])
##############################################################################################
# [국내주식] 실시간시세 > 국내주식 실시간예상체결 (NXT)
##############################################################################################
kws.subscribe(
request=exp_ccnl_nxt,
data=["005930", "000660", "005380"]
)
##############################################################################################
# [국내주식] 실시간시세 > 국내주식 실시간예상체결(통합)
##############################################################################################
kws.subscribe(request=exp_ccnl_total, data=["005930", "000660"])
##############################################################################################
# [국내주식] 실시간시세 > 국내지수 실시간체결 [실시간-026]
##############################################################################################
kws.subscribe(request=index_ccnl, data=["0001", "0128"])
##############################################################################################
# [국내주식] 실시간시세 > 국내지수 실시간예상체결 [실시간-027]
##############################################################################################
kws.subscribe(request=index_exp_ccnl, data=["0001"])
##############################################################################################
# [국내주식] 실시간시세 > 국내지수 실시간프로그램매매 [실시간-028]
##############################################################################################
kws.subscribe(request=index_program_trade, data=["0001", "0128"])
##############################################################################################
# [국내주식] 실시간시세 > 국내주식 장운영정보 (KRX) [실시간-049]
##############################################################################################
kws.subscribe(request=market_status_krx, data=["417450", "308100"])
##############################################################################################
# [국내주식] 실시간시세 > 국내주식 장운영정보(NXT)
##############################################################################################
kws.subscribe(request=market_status_nxt, data=["006220"])
##############################################################################################
# [국내주식] 실시간시세 > 국내주식 장운영정보(통합)
##############################################################################################
kws.subscribe(request=market_status_total, data=["158430"])
##############################################################################################
# [국내주식] 실시간시세 > 국내주식 실시간회원사 (KRX) [실시간-047]
##############################################################################################
kws.subscribe(request=member_krx, data=["005930", "000660"])
##############################################################################################
# [국내주식] 실시간시세 > 국내주식 실시간회원사 (NXT)
##############################################################################################
kws.subscribe(request=member_nxt, data=["005930", "000660"])
##############################################################################################
# [국내주식] 실시간시세 > 국내주식 실시간회원사 (통합)
##############################################################################################
kws.subscribe(request=member_total, data=["005930", "000660"])
##############################################################################################
# [국내주식] 실시간시세 > 국내주식 시간외 실시간호가 (KRX) [실시간-025]
##############################################################################################
kws.subscribe(request=overtime_asking_price_krx, data=["023460"])
##############################################################################################
# [국내주식] 실시간시세 > 국내주식 시간외 실시간체결가 (KRX) [실시간-042]
##############################################################################################
kws.subscribe(request=overtime_ccnl_krx, data=["023460", "199480", "462860", "440790", "000660"])
##############################################################################################
# [국내주식] 실시간시세 > 국내주식 시간외 실시간예상체결 (KRX) [실시간-024]
##############################################################################################
kws.subscribe(request=overtime_exp_ccnl_krx, data=["023460"])
##############################################################################################
# [국내주식] 실시간시세 > 국내주식 실시간프로그램매매 (KRX) [실시간-048]
##############################################################################################
kws.subscribe(request=program_trade_krx, data=["005930", "000660"])
##############################################################################################
# [국내주식] 실시간시세 > 국내주식 실시간프로그램매매 (NXT)
##############################################################################################
kws.subscribe(request=program_trade_nxt, data=["032640", "010950"])
##############################################################################################
# [국내주식] 실시간시세 > 국내주식 실시간프로그램매매 (통합)
##############################################################################################
kws.subscribe(request=program_trade_total, data=["005930", "000660"])
# 시작
def on_result(ws, tr_id, result, data_info):
print(result)
kws.start(on_result=on_result)

File diff suppressed because it is too large Load Diff

13463
temp-kis-domestic-functions.py Normal file

File diff suppressed because it is too large Load Diff

78
temp-kis-inquire-price.py Normal file
View File

@@ -0,0 +1,78 @@
"""
Created on 20250112
"""
import sys
import logging
import pandas as pd
sys.path.extend(['../..', '.'])
import kis_auth as ka
# 로깅 설정
logging.basicConfig(level=logging.INFO)
##############################################################################################
# [국내주식] 기본시세 > 주식현재가 시세[v1_국내주식-008]
##############################################################################################
# 상수 정의
API_URL = "/uapi/domestic-stock/v1/quotations/inquire-price"
def inquire_price(
env_dv: str, # [필수] 실전모의구분 (ex. real:실전, demo:모의)
fid_cond_mrkt_div_code: str, # [필수] 조건 시장 분류 코드 (ex. J:KRX, NX:NXT, UN:통합)
fid_input_iscd: str # [필수] 입력 종목코드 (ex. 종목코드 (ex 005930 삼성전자), ETN은 종목코드 6자리 앞에 Q 입력 필수)
) -> pd.DataFrame:
"""
주식 현재가 시세 API입니다. 실시간 시세를 원하신다면 웹소켓 API를 활용하세요.
※ 종목코드 마스터파일 파이썬 정제코드는 한국투자증권 Github 참고 부탁드립니다.
https://github.com/koreainvestment/open-trading-api/tree/main/stocks_info
Args:
env_dv (str): [필수] 실전모의구분 (ex. real:실전, demo:모의)
fid_cond_mrkt_div_code (str): [필수] 조건 시장 분류 코드 (ex. J:KRX, NX:NXT, UN:통합)
fid_input_iscd (str): [필수] 입력 종목코드 (ex. 종목코드 (ex 005930 삼성전자), ETN은 종목코드 6자리 앞에 Q 입력 필수)
Returns:
pd.DataFrame: 주식 현재가 시세 데이터
Example:
>>> df = inquire_price("real", "J", "005930")
>>> print(df)
"""
# 필수 파라미터 검증
if env_dv == "" or env_dv is None:
raise ValueError("env_dv is required (e.g. 'real:실전, demo:모의')")
if fid_cond_mrkt_div_code == "" or fid_cond_mrkt_div_code is None:
raise ValueError("fid_cond_mrkt_div_code is required (e.g. 'J:KRX, NX:NXT, UN:통합')")
if fid_input_iscd == "" or fid_input_iscd is None:
raise ValueError("fid_input_iscd is required (e.g. '종목코드 (ex 005930 삼성전자), ETN은 종목코드 6자리 앞에 Q 입력 필수')")
# tr_id 설정
if env_dv == "real":
tr_id = "FHKST01010100"
elif env_dv == "demo":
tr_id = "FHKST01010100"
else:
raise ValueError("env_dv can only be 'real' or 'demo'")
params = {
"FID_COND_MRKT_DIV_CODE": fid_cond_mrkt_div_code,
"FID_INPUT_ISCD": fid_input_iscd
}
res = ka._url_fetch(API_URL, tr_id, "", params)
if res.isOK():
current_data = pd.DataFrame(res.getBody().output, index=[0])
return current_data
else:
res.printError(url=API_URL)
return pd.DataFrame()

104
temp-kis-kosdaq-code-mst.py Normal file
View File

@@ -0,0 +1,104 @@
'''코스닥주식종목코드(kosdaq_code.mst) 정제 파이썬 파일'''
import pandas as pd
import urllib.request
import ssl
import zipfile
import os
base_dir = os.getcwd()
def kosdaq_master_download(base_dir, verbose=False):
cwd = os.getcwd()
if (verbose): print(f"current directory is {cwd}")
ssl._create_default_https_context = ssl._create_unverified_context
urllib.request.urlretrieve("https://new.real.download.dws.co.kr/common/master/kosdaq_code.mst.zip",
base_dir + "\\kosdaq_code.zip")
os.chdir(base_dir)
if (verbose): print(f"change directory to {base_dir}")
kosdaq_zip = zipfile.ZipFile('kosdaq_code.zip')
kosdaq_zip.extractall()
kosdaq_zip.close()
if os.path.exists("kosdaq_code.zip"):
os.remove("kosdaq_code.zip")
def get_kosdaq_master_dataframe(base_dir):
file_name = base_dir + "\\kosdaq_code.mst"
tmp_fil1 = base_dir + "\\kosdaq_code_part1.tmp"
tmp_fil2 = base_dir + "\\kosdaq_code_part2.tmp"
wf1 = open(tmp_fil1, mode="w")
wf2 = open(tmp_fil2, mode="w")
with open(file_name, mode="r", encoding="cp949") as f:
for row in f:
rf1 = row[0:len(row) - 222]
rf1_1 = rf1[0:9].rstrip()
rf1_2 = rf1[9:21].rstrip()
rf1_3 = rf1[21:].strip()
wf1.write(rf1_1 + ',' + rf1_2 + ',' + rf1_3 + '\n')
rf2 = row[-222:]
wf2.write(rf2)
wf1.close()
wf2.close()
part1_columns = ['단축코드','표준코드','한글종목명']
df1 = pd.read_csv(tmp_fil1, header=None, names=part1_columns, encoding='cp949')
field_specs = [2, 1,
4, 4, 4, 1, 1,
1, 1, 1, 1, 1,
1, 1, 1, 1, 1,
1, 1, 1, 1, 1,
1, 1, 1, 1, 9,
5, 5, 1, 1, 1,
2, 1, 1, 1, 2,
2, 2, 3, 1, 3,
12, 12, 8, 15, 21,
2, 7, 1, 1, 1,
1, 9, 9, 9, 5,
9, 8, 9, 3, 1,
1, 1
]
part2_columns = ['증권그룹구분코드','시가총액 규모 구분 코드 유가',
'지수업종 대분류 코드','지수 업종 중분류 코드','지수업종 소분류 코드','벤처기업 여부 (Y/N)',
'저유동성종목 여부','KRX 종목 여부','ETP 상품구분코드','KRX100 종목 여부 (Y/N)',
'KRX 자동차 여부','KRX 반도체 여부','KRX 바이오 여부','KRX 은행 여부','기업인수목적회사여부',
'KRX 에너지 화학 여부','KRX 철강 여부','단기과열종목구분코드','KRX 미디어 통신 여부',
'KRX 건설 여부','(코스닥)투자주의환기종목여부','KRX 증권 구분','KRX 선박 구분',
'KRX섹터지수 보험여부','KRX섹터지수 운송여부','KOSDAQ150지수여부 (Y,N)','주식 기준가',
'정규 시장 매매 수량 단위','시간외 시장 매매 수량 단위','거래정지 여부','정리매매 여부',
'관리 종목 여부','시장 경고 구분 코드','시장 경고위험 예고 여부','불성실 공시 여부',
'우회 상장 여부','락구분 코드','액면가 변경 구분 코드','증자 구분 코드','증거금 비율',
'신용주문 가능 여부','신용기간','전일 거래량','주식 액면가','주식 상장 일자','상장 주수(천)',
'자본금','결산 월','공모 가격','우선주 구분 코드','공매도과열종목여부','이상급등종목여부',
'KRX300 종목 여부 (Y/N)','매출액','영업이익','경상이익','단기순이익','ROE(자기자본이익률)',
'기준년월','전일기준 시가총액 (억)','그룹사 코드','회사신용한도초과여부','담보대출가능여부','대주가능여부'
]
df2 = pd.read_fwf(tmp_fil2, widths=field_specs, names=part2_columns)
df = pd.merge(df1, df2, how='outer', left_index=True, right_index=True)
# clean temporary file and dataframe
del (df1)
del (df2)
os.remove(tmp_fil1)
os.remove(tmp_fil2)
print("Done")
return df
kosdaq_master_download(base_dir)
df = get_kosdaq_master_dataframe(base_dir)
df.to_excel('kosdaq_code.xlsx',index=False) # 현재 위치에 엑셀파일로 저장
df

108
temp-kis-kospi-code-mst.py Normal file
View File

@@ -0,0 +1,108 @@
'''코스피주식종목코드(kospi_code.mst) 정제 파이썬 파일'''
import urllib.request
import ssl
import zipfile
import os
import pandas as pd
base_dir = os.getcwd()
def kospi_master_download(base_dir, verbose=False):
cwd = os.getcwd()
if (verbose): print(f"current directory is {cwd}")
ssl._create_default_https_context = ssl._create_unverified_context
urllib.request.urlretrieve("https://new.real.download.dws.co.kr/common/master/kospi_code.mst.zip",
base_dir + "\\kospi_code.zip")
os.chdir(base_dir)
if (verbose): print(f"change directory to {base_dir}")
kospi_zip = zipfile.ZipFile('kospi_code.zip')
kospi_zip.extractall()
kospi_zip.close()
if os.path.exists("kospi_code.zip"):
os.remove("kospi_code.zip")
def get_kospi_master_dataframe(base_dir):
file_name = base_dir + "\\kospi_code.mst"
tmp_fil1 = base_dir + "\\kospi_code_part1.tmp"
tmp_fil2 = base_dir + "\\kospi_code_part2.tmp"
wf1 = open(tmp_fil1, mode="w")
wf2 = open(tmp_fil2, mode="w")
with open(file_name, mode="r", encoding="cp949") as f:
for row in f:
rf1 = row[0:len(row) - 228]
rf1_1 = rf1[0:9].rstrip()
rf1_2 = rf1[9:21].rstrip()
rf1_3 = rf1[21:].strip()
wf1.write(rf1_1 + ',' + rf1_2 + ',' + rf1_3 + '\n')
rf2 = row[-228:]
wf2.write(rf2)
wf1.close()
wf2.close()
part1_columns = ['단축코드', '표준코드', '한글명']
df1 = pd.read_csv(tmp_fil1, header=None, names=part1_columns, encoding='cp949')
field_specs = [2, 1, 4, 4, 4,
1, 1, 1, 1, 1,
1, 1, 1, 1, 1,
1, 1, 1, 1, 1,
1, 1, 1, 1, 1,
1, 1, 1, 1, 1,
1, 9, 5, 5, 1,
1, 1, 2, 1, 1,
1, 2, 2, 2, 3,
1, 3, 12, 12, 8,
15, 21, 2, 7, 1,
1, 1, 1, 1, 9,
9, 9, 5, 9, 8,
9, 3, 1, 1, 1
]
part2_columns = ['그룹코드', '시가총액규모', '지수업종대분류', '지수업종중분류', '지수업종소분류',
'제조업', '저유동성', '지배구조지수종목', 'KOSPI200섹터업종', 'KOSPI100',
'KOSPI50', 'KRX', 'ETP', 'ELW발행', 'KRX100',
'KRX자동차', 'KRX반도체', 'KRX바이오', 'KRX은행', 'SPAC',
'KRX에너지화학', 'KRX철강', '단기과열', 'KRX미디어통신', 'KRX건설',
'Non1', 'KRX증권', 'KRX선박', 'KRX섹터_보험', 'KRX섹터_운송',
'SRI', '기준가', '매매수량단위', '시간외수량단위', '거래정지',
'정리매매', '관리종목', '시장경고', '경고예고', '불성실공시',
'우회상장', '락구분', '액면변경', '증자구분', '증거금비율',
'신용가능', '신용기간', '전일거래량', '액면가', '상장일자',
'상장주수', '자본금', '결산월', '공모가', '우선주',
'공매도과열', '이상급등', 'KRX300', 'KOSPI', '매출액',
'영업이익', '경상이익', '당기순이익', 'ROE', '기준년월',
'시가총액', '그룹사코드', '회사신용한도초과', '담보대출가능', '대주가능'
]
df2 = pd.read_fwf(tmp_fil2, widths=field_specs, names=part2_columns)
df = pd.merge(df1, df2, how='outer', left_index=True, right_index=True)
# clean temporary file and dataframe
del (df1)
del (df2)
os.remove(tmp_fil1)
os.remove(tmp_fil2)
print("Done")
return df
kospi_master_download(base_dir)
df = get_kospi_master_dataframe(base_dir)
#df3 = df[df['KRX증권'] == 'Y']
df3 = df
# print(df3[['단축코드', '한글명', 'KRX', 'KRX증권', '기준가', '증거금비율', '상장일자', 'ROE']])
df3.to_excel('kospi_code.xlsx',index=False) # 현재 위치에 엑셀파일로 저장
df3

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

36
temp-kis_devlp.yaml Normal file
View File

@@ -0,0 +1,36 @@
#홈페이지에서 API서비스 신청시 받은 Appkey, Appsecret 값 설정
#실전투자
my_app: "앱키"
my_sec: "앱키 시크릿"
#모의투자
paper_app: "모의투자 앱키"
paper_sec: "모의투자 앱키 시크릿"
# HTS ID
my_htsid: "사용자 HTS ID"
#계좌번호 앞 8자리
my_acct_stock: "증권계좌 8자리"
my_acct_future: "선물옵션계좌 8자리"
my_paper_stock: "모의투자 증권계좌 8자리"
my_paper_future: "모의투자 선물옵션계좌 8자리"
#계좌번호 뒤 2자리
my_prod: "01" # 종합계좌
# my_prod: "03" # 국내선물옵션계좌
# my_prod: "08" # 해외선물옵션 계좌
# my_prod: "22" # 개인연금
# my_prod: "29" # 퇴직연금
#domain infos
prod: "https://openapi.koreainvestment.com:9443" # 서비스
ops: "ws://ops.koreainvestment.com:21000" # 웹소켓
vps: "https://openapivts.koreainvestment.com:29443" # 모의투자 서비스
vops: "ws://ops.koreainvestment.com:31000" # 모의투자 웹소켓
my_token: ""
# User-Agent; Chrome > F12 개발자 모드 > Console > navigator.userAgent > 자신의 userAgent 확인가능
my_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"

99
temp-kospi-master.h Normal file
View File

@@ -0,0 +1,99 @@
/*****************************************************************************
* 코스피 종목 코드 파일 구조
****************************************************************************/
typedef struct
{
char mksc_shrn_iscd[SZ_SHRNCODE]; /* 단축코드 */
char stnd_iscd[SZ_STNDCODE]; /* 표준코드 */
char hts_kor_isnm[SZ_KORNAME]; /* 한글종목명 */
char scrt_grp_cls_code[2]; /* 증권그룹구분코드 */
/* ST:주권 MF:증권투자회사 RT:부동산투자회사 */
/* SC:선박투자회사 IF:사회간접자본투융자회사 */
/* DR:주식예탁증서 EW:ELW EF:ETF */
/* SW:신주인수권증권 SR:신주인수권증서 */
/* BC:수익증권 FE:해외ETF FS:외국주권 */
char avls_scal_cls_code[1]; /* 시가총액 규모 구분 코드 유가 */
/* (0:제외 1:대 2:중 3:소) */
char bstp_larg_div_code[4]; /* 지수 업종 대분류 코드 */
char bstp_medm_div_code[4]; /* 지수 업종 중분류 코드 */
char bstp_smal_div_code[4]; /* 지수 업종 소분류 코드 */
char mnin_cls_code_yn[1]; /* 제조업 구분 코드 (Y/N) */
char low_current_yn[1]; /* 저유동성종목 여부 */
char sprn_strr_nmix_issu_yn[1]; /* 지배 구조 지수 종목 여부 (Y/N) */
char kospi200_apnt_cls_code[1]; /* KOSPI200 섹터업종(20110401 변경됨) */
/* 0:미분류 1:건설기계 2:조선운송 3:철강소재 */
/* 4:에너지화학 5:정보통신 6:금융 7:필수소비재 */
/* 8: 자유소비재 */
char kospi100_issu_yn[1]; /* KOSPI100여부 */
char kospi50_issu_yn[1]; /* KOSPI50 종목 여부 */
char krx_issu_yn[1]; /* KRX 종목 여부 */
char etp_prod_cls_code[1]; /* ETP 상품구분코드 */
/* 0:해당없음 1:투자회사형 2:수익증권형 */
/* 3:ETN 4:손실제한ETN */
char elw_pblc_yn[1]; /* ELW 발행여부 (Y/N) */
char krx100_issu_yn[1]; /* KRX100 종목 여부 (Y/N) */
char krx_car_yn[1]; /* KRX 자동차 여부 */
char krx_smcn_yn[1]; /* KRX 반도체 여부 */
char krx_bio_yn[1]; /* KRX 바이오 여부 */
char krx_bank_yn[1]; /* KRX 은행 여부 */
char etpr_undt_objt_co_yn[1]; /* 기업인수목적회사여부 */
char krx_enrg_chms_yn[1]; /* KRX 에너지 화학 여부 */
char krx_stel_yn[1]; /* KRX 철강 여부 */
char short_over_cls_code[1]; /* 단기과열종목구분코드 0:해당없음 */
/* 1:지정예고 2:지정 3:지정연장(해제연기) */
char krx_medi_cmnc_yn[1]; /* KRX 미디어 통신 여부 */
char krx_cnst_yn[1]; /* KRX 건설 여부 */
char krx_fnnc_svc_yn[1]; /* 삭제됨(20151218) */
char krx_scrt_yn [1]; /* KRX 증권 구분 */
char krx_ship_yn [1]; /* KRX 선박 구분 */
char krx_insu_yn[1]; /* KRX섹터지수 보험여부 */
char krx_trnp_yn[1]; /* KRX섹터지수 운송여부 */
char sri_nmix_yn[1]; /* SRI 지수여부 (Y,N) */
char stck_sdpr[9]; /* 주식 기준가 */
char frml_mrkt_deal_qty_unit[5]; /* 정규 시장 매매 수량 단위 */
char ovtm_mrkt_deal_qty_unit[5]; /* 시간외 시장 매매 수량 단위 */
char trht_yn[1]; /* 거래정지 여부 */
char sltr_yn[1]; /* 정리매매 여부 */
char mang_issu_yn[1]; /* 관리 종목 여부 */
char mrkt_alrm_cls_code[2]; /* 시장 경고 구분 코드 (00:해당없음 01:투자주의 */
/* 02:투자경고 03:투자위험 */
char mrkt_alrm_risk_adnt_yn[1]; /* 시장 경고위험 예고 여부 */
char insn_pbnt_yn[1]; /* 불성실 공시 여부 */
char byps_lstn_yn[1]; /* 우회 상장 여부 */
char flng_cls_code[2]; /* 락구분 코드 (00:해당사항없음 01:권리락 */
/* 02:배당락 03:분배락 04:권배락 05:중간배당락 */
/* 06:권리중간배당락 99:기타 */
/* S?W,SR,EW는 미해당(SPACE) */
char fcam_mod_cls_code[2]; /* 액면가 변경 구분 코드 (00:해당없음 */
/* 01:액면분할 02:액면병합 99:기타 */
char icic_cls_code[2]; /* 증자 구분 코드 (00:해당없음 01:유상증자 */
/* 02:무상증자 03:유무상증자 99:기타) */
char marg_rate[3]; /* 증거금 비율 */
char crdt_able[1]; /* 신용주문 가능 여부 */
char crdt_days[3]; /* 신용기간 */
char prdy_vol[12]; /* 전일 거래량 */
char stck_fcam[12]; /* 주식 액면가 */
char stck_lstn_date[8]; /* 주식 상장 일자 */
char lstn_stcn[15]; /* 상장 주수(천) */
char cpfn[21]; /* 자본금 */
char stac_month[2]; /* 결산 월 */
char po_prc[7]; /* 공모 가격 */
char prst_cls_code[1]; /* 우선주 구분 코드 (0:해당없음(보통주) */
/* 1:구형우선주 2:신형우선주 */
char ssts_hot_yn[1]; /* 공매도과열종목여부 */
char stange_runup_yn[1]; /* 이상급등종목여부 */
char krx300_issu_yn[1]; /* KRX300 종목 여부 (Y/N) */
char kospi_issu_yn[1]; /* KOSPI여부 */
char sale_account[9]; /* 매출액 */
char bsop_prfi[9]; /* 영업이익 */
char op_prfi[9]; /* 경상이익 */
char thtr_ntin[5]; /* 당기순이익 */
char roe[9]; /* ROE(자기자본이익률) */
char base_date[8]; /* 기준년월 */
char prdy_avls_scal[9]; /* 전일기준 시가총액 (억) */
char grp_code[3]; /* 그룹사 코드 */
char co_crdt_limt_over_yn[1]; /* 회사신용한도초과여부 */
char secu_lend_able_yn[1]; /* 담보대출가능여부 */
char stln_able_yn[1]; /* 대주가능여부 */
} ST_KSP_CODE;