Compare commits
2 Commits
v0.1.0-bas
...
871f864dce
| Author | SHA1 | Date | |
|---|---|---|---|
| 871f864dce | |||
| 851a2acd69 |
175
.agent/rules/builder-rule.md
Normal file
175
.agent/rules/builder-rule.md
Normal 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` 타입 사용 금지
|
||||
313
.agent/rules/code-analysis-rule.md
Normal file
313
.agent/rules/code-analysis-rule.md
Normal 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/ # 공통 스토어
|
||||
```
|
||||
341
.agent/rules/master-integration.md
Normal file
341
.agent/rules/master-integration.md
Normal 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개 룰
|
||||
94
.agent/rules/refactoring-rule.md
Normal file
94
.agent/rules/refactoring-rule.md
Normal 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)보다는 명확한 상대/절대 경로를 사용한다.
|
||||
20
.env.example
20
.env.example
@@ -1,9 +1,23 @@
|
||||
# Supabase 환경 설정 예제 파일
|
||||
# 이 파일의 이름을 .env.local 로 변경한 뒤, 실제 값을 채워넣으세요.
|
||||
# Supabase 환경 설정 예제 파일
|
||||
# 이 파일을 .env.local로 복사한 뒤 실제 값을 채워 주세요.
|
||||
# 값 확인: https://supabase.com/dashboard/project/_/settings/api
|
||||
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
|
||||
# 세션 타임아웃 (분 단위)
|
||||
# 세션 타임아웃(분 단위)
|
||||
NEXT_PUBLIC_SESSION_TIMEOUT_MINUTES=30
|
||||
|
||||
# KIS 거래 모드: real(실전) | mock(모의)
|
||||
KIS_TRADING_ENV=real
|
||||
|
||||
# 서버 기본 키를 쓰고 싶은 경우(선택)
|
||||
KIS_APP_KEY_REAL=
|
||||
KIS_APP_SECRET_REAL=
|
||||
KIS_BASE_URL_REAL=https://openapi.koreainvestment.com:9443
|
||||
KIS_WS_URL_REAL=ws://ops.koreainvestment.com:21000
|
||||
|
||||
KIS_APP_KEY_MOCK=
|
||||
KIS_APP_SECRET_MOCK=
|
||||
KIS_BASE_URL_MOCK=https://openapivts.koreainvestment.com:29443
|
||||
KIS_WS_URL_MOCK=ws://ops.koreainvestment.com:31000
|
||||
|
||||
1
.tmp/kis-token-cache.json
Normal file
1
.tmp/kis-token-cache.json
Normal file
@@ -0,0 +1 @@
|
||||
{"real:7777b1c958636e65539aadf6c4273a0dfa33c17e55d1beb7dbfd033d49457f6e":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIsImF1ZCI6ImFkMGFjN2Y3LWY5YTYtNDRlNy1iOGRjLWU0ZjRhY2Q0YmQ2NyIsInByZHRfY2QiOiIiLCJpc3MiOiJ1bm9ndyIsImV4cCI6MTc3MDcwNzkzMiwiaWF0IjoxNzcwNjIxNTMyLCJqdGkiOiJQUzA2TG12SndjRHl1MDZLVXpPRjVsSHJacWR6ZWJKTXhCVWIifQ.P1_T4XoIxPDyNIxS3rx73IqlNLpZRmKKXOtan74A7D19On9JlsIQIpTY8bVGPFfRxFkC0vfCZ1qDX-xpxGi6SA","expiresAt":1770707932000}}
|
||||
1
.tmp/open-trading-api
Submodule
1
.tmp/open-trading-api
Submodule
Submodule .tmp/open-trading-api added at aea5e779da
@@ -1,222 +1,163 @@
|
||||
/**
|
||||
/**
|
||||
* @file app/(home)/page.tsx
|
||||
* @description 서비스 메인 랜딩 페이지
|
||||
* @remarks
|
||||
* - [레이어] Pages (Server Component)
|
||||
* - [역할] 비로그인 유저 유입, 브랜드 소개, 로그인/대시보드 유도
|
||||
* - [구성요소] Header, Hero Section (Spline 3D), Feature Section (Bento Grid)
|
||||
* - [데이터 흐름] Server Auth Check -> Client Component Props
|
||||
* @description 서비스 메인 랜딩 페이지(Server Component)
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { ArrowRight, BarChart3, ShieldCheck, Sparkles, Zap } from "lucide-react";
|
||||
import { Header } from "@/features/layout/components/header";
|
||||
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
|
||||
* @see layout.tsx - RootLayout 내에서 렌더링
|
||||
* @see spline-scene.tsx - 3D 인터랙션
|
||||
* 홈 메인 랜딩 페이지
|
||||
* @returns 랜딩 UI
|
||||
* @see features/layout/components/header.tsx blendWithBackground 모드 헤더를 함께 사용
|
||||
*/
|
||||
export default async function HomePage() {
|
||||
// [Step 1] 서버 사이드 인증 상태 확인
|
||||
// [로그인 상태 조회] 사용자 유무에 따라 CTA 링크를 분기합니다.
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col overflow-x-hidden">
|
||||
<Header user={user} showDashboardLink={true} />
|
||||
<div className="flex min-h-screen flex-col overflow-x-hidden bg-transparent">
|
||||
<Header user={user} showDashboardLink={true} blendWithBackground={true} />
|
||||
|
||||
<main className="flex-1 bg-background pt-16">
|
||||
{/* Background Pattern */}
|
||||
<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%)]" />
|
||||
<main className="relative isolate flex-1 pt-16">
|
||||
{/* ========== SHADER BACKGROUND SECTION ========== */}
|
||||
<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">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
{/* Badge */}
|
||||
<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">
|
||||
<span className="mr-2 flex h-2 w-2 animate-pulse rounded-full bg-brand-500"></span>
|
||||
The Future of Trading
|
||||
</div>
|
||||
|
||||
<h1 className="font-heading text-4xl font-black tracking-tight text-foreground sm:text-6xl md:text-7xl lg:text-8xl">
|
||||
투자의 미래를 <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">
|
||||
자동화하세요
|
||||
{/* ========== HERO SECTION ========== */}
|
||||
<section className="container mx-auto max-w-7xl px-4 pb-10 pt-16 md:pt-24">
|
||||
<div className="p-2 md:p-6">
|
||||
<div className="mx-auto max-w-4xl text-center">
|
||||
<span className="inline-flex items-center gap-2 rounded-full px-4 py-1.5 text-xs font-semibold text-brand-200 [text-shadow:0_2px_24px_rgba(0,0,0,0.65)]">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Shader Background Landing
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="mt-6 max-w-2xl text-lg text-muted-foreground sm:text-xl leading-relaxed break-keep">
|
||||
AutoTrade는 최첨단 알고리즘을 통해 24시간 암호화폐 시장을
|
||||
분석합니다.
|
||||
<br className="hidden md:block" />
|
||||
감정 없는 데이터 기반 투자로 안정적인 수익을 경험해보세요.
|
||||
</p>
|
||||
<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 />
|
||||
<span className="bg-linear-to-r from-brand-300 via-brand-400 to-brand-500 bg-clip-text text-transparent">
|
||||
자동으로 실행합니다
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<div className="mt-8 flex flex-col items-center gap-4 sm:flex-row">
|
||||
{user ? (
|
||||
<Button
|
||||
asChild
|
||||
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"
|
||||
>
|
||||
<Link href={AUTH_ROUTES.DASHBOARD}>대시보드 바로가기</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
asChild
|
||||
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"
|
||||
>
|
||||
<Link href={AUTH_ROUTES.LOGIN}>무료로 시작하기</Link>
|
||||
</Button>
|
||||
)}
|
||||
{!user && (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="h-14 rounded-full border-border bg-background/50 px-10 text-lg hover:bg-accent/50 backdrop-blur-sm"
|
||||
>
|
||||
<Link href={AUTH_ROUTES.LOGIN}>데모 체험하기</Link>
|
||||
</Button>
|
||||
)}
|
||||
<p className="mx-auto mt-5 max-w-2xl text-sm leading-relaxed text-white/80 [text-shadow:0_2px_16px_rgba(0,0,0,0.5)] md:text-lg">
|
||||
실시간 시세 확인, 전략 점검, 주문 연결까지 한 화면에서 이어지는 자동매매 환경을
|
||||
제공합니다. 복잡한 설정은 줄이고 실행 속도는 높였습니다.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
||||
{/* [분기 렌더] 로그인 사용자는 대시보드, 비로그인 사용자는 가입/로그인 동선을 노출합니다. */}
|
||||
{user ? (
|
||||
<Button asChild size="lg" className="h-12 rounded-full bg-primary px-8 text-base hover:bg-primary/90">
|
||||
<Link href={AUTH_ROUTES.DASHBOARD}>
|
||||
대시보드 바로가기
|
||||
<ArrowRight className="ml-1.5 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button asChild size="lg" className="h-12 rounded-full bg-primary px-8 text-base hover:bg-primary/90">
|
||||
<Link href={AUTH_ROUTES.SIGNUP}>
|
||||
무료로 시작하기
|
||||
<ArrowRight className="ml-1.5 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="h-12 rounded-full border-white/40 bg-transparent px-8 text-base text-white hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<Link href={AUTH_ROUTES.LOGIN}>로그인</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spline Scene - Centered & Wide */}
|
||||
<div className="relative mt-16 w-full max-w-5xl">
|
||||
<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">
|
||||
{/* Glow Effect */}
|
||||
<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" />
|
||||
|
||||
<SplineScene
|
||||
sceneUrl="https://prod.spline.design/6Wq1Q7YGyM-iab9i/scene.splinecode"
|
||||
className="relative z-10 h-full w-full rounded-2xl"
|
||||
/>
|
||||
<div className="mt-8 grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-2xl p-4 text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.35)]">
|
||||
<p className="text-xs text-white/70">지연 시간 기준</p>
|
||||
<p className="mt-1 text-lg font-bold">Low Latency</p>
|
||||
</div>
|
||||
<div className="rounded-2xl p-4 text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.35)]">
|
||||
<p className="text-xs text-white/70">모니터링</p>
|
||||
<p className="mt-1 text-lg font-bold">실시간 시세 반영</p>
|
||||
</div>
|
||||
<div className="rounded-2xl p-4 text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.35)]">
|
||||
<p className="text-xs text-white/70">실행 환경</p>
|
||||
<p className="mt-1 text-lg font-bold">웹 기반 자동매매</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section - Bento Grid */}
|
||||
<section className="container mx-auto max-w-7xl px-4 py-24 sm:py-32">
|
||||
<div className="mb-16 text-center">
|
||||
<h2 className="font-heading text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">
|
||||
강력한 기능,{" "}
|
||||
<span className="text-brand-500">직관적인 경험</span>
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
성공적인 투자를 위해 필요한 모든 도구가 준비되어 있습니다.
|
||||
</p>
|
||||
{/* ========== FEATURE SECTION ========== */}
|
||||
<section className="container mx-auto max-w-7xl px-4 pb-16 md:pb-24">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="border-0 bg-transparent text-white shadow-none">
|
||||
<CardHeader>
|
||||
<div className="mb-2 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-brand-400/20 text-brand-200">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
</div>
|
||||
<CardTitle className="text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.4)]">실시간 데이터 가시화</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm leading-relaxed text-white/75">
|
||||
시세 변화와 거래 흐름을 빠르게 확인할 수 있게 핵심 정보만 선별해 보여줍니다.
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 bg-transparent text-white shadow-none">
|
||||
<CardHeader>
|
||||
<div className="mb-2 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-brand-400/20 text-brand-200">
|
||||
<Zap className="h-5 w-5" />
|
||||
</div>
|
||||
<CardTitle className="text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.4)]">전략 실행 속도 최적화</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm leading-relaxed text-white/75">
|
||||
필요한 단계만 남긴 단순한 흐름으로 전략 테스트와 실행 전환 시간을 줄였습니다.
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 bg-transparent text-white shadow-none">
|
||||
<CardHeader>
|
||||
<div className="mb-2 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-brand-400/20 text-brand-200">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
</div>
|
||||
<CardTitle className="text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.4)]">명확한 리스크 관리</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm leading-relaxed text-white/75">
|
||||
자동매매에서 중요한 손실 한도와 조건을 먼저 정의하고 일관되게 적용할 수 있습니다.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 md:gap-8">
|
||||
{/* Feature 1 */}
|
||||
<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="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>
|
||||
{/* ========== CTA SECTION ========== */}
|
||||
<section className="container mx-auto max-w-7xl px-4 pb-20">
|
||||
<div className="p-2 md:p-4">
|
||||
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-brand-200 [text-shadow:0_2px_18px_rgba(0,0,0,0.45)]">준비되면 바로 시작하세요</p>
|
||||
<h2 className="mt-1 text-2xl font-bold tracking-tight text-white [text-shadow:0_2px_18px_rgba(0,0,0,0.45)] md:text-3xl">
|
||||
AutoTrade에서 전략을 실행해 보세요
|
||||
</h2>
|
||||
</div>
|
||||
<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" />
|
||||
<Button asChild className="h-11 rounded-full bg-primary px-7 hover:bg-primary/90">
|
||||
<Link href={user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.SIGNUP}>
|
||||
{user ? "대시보드 열기" : "회원가입 시작"}
|
||||
<ArrowRight className="ml-1.5 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,115 +1,25 @@
|
||||
/**
|
||||
* @file app/(main)/dashboard/page.tsx
|
||||
* @description 사용자 대시보드 메인 페이지 (보호된 라우트)
|
||||
* @remarks
|
||||
* - [레이어] Pages (Server Component)
|
||||
* - [역할] 사용자 자산 현황, 최근 활동, 통계 요약을 보여줌
|
||||
* - [권한] 로그인한 사용자만 접근 가능 (Middleware에 의해 보호됨)
|
||||
* @description 로그인 사용자 전용 대시보드 페이지(Server Component)
|
||||
*/
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Activity, CreditCard, DollarSign, Users } from "lucide-react";
|
||||
import { DashboardContainer } from "@/features/dashboard/components/DashboardContainer";
|
||||
|
||||
/**
|
||||
* 대시보드 페이지 (비동기 서버 컴포넌트)
|
||||
* @returns Dashboard Grid Layout
|
||||
* 대시보드 페이지
|
||||
* @returns DashboardContainer UI
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 클라이언트 상호작용(검색/시세/차트)은 해당 컴포넌트가 담당합니다.
|
||||
*/
|
||||
export default async function DashboardPage() {
|
||||
// [Step 1] 세션 확인 (Middleware가 1차 방어하지만, 데이터 접근 시 2차 확인)
|
||||
// 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
|
||||
const supabase = await createClient();
|
||||
await supabase.auth.getUser();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<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>
|
||||
);
|
||||
if (!user) redirect("/login");
|
||||
|
||||
return <DashboardContainer />;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export default async function MainLayout({
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-zinc-50 dark:bg-black">
|
||||
<Header user={user} />
|
||||
<div className="flex flex-1">
|
||||
<div className="flex flex-1 pt-16">
|
||||
<Sidebar />
|
||||
<main className="flex-1 w-full p-6 md:p-8 lg:p-10">{children}</main>
|
||||
</div>
|
||||
|
||||
98
app/api/kis/domestic/chart/route.ts
Normal file
98
app/api/kis/domestic/chart/route.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
104
app/api/kis/domestic/order-cash/route.ts
Normal file
104
app/api/kis/domestic/order-cash/route.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
106
app/api/kis/domestic/orderbook/route.ts
Normal file
106
app/api/kis/domestic/orderbook/route.ts
Normal 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;
|
||||
}
|
||||
78
app/api/kis/domestic/overview/route.ts
Normal file
78
app/api/kis/domestic/overview/route.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
108
app/api/kis/domestic/search/route.ts
Normal file
108
app/api/kis/domestic/search/route.ts
Normal 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;
|
||||
}
|
||||
64
app/api/kis/revoke/route.ts
Normal file
64
app/api/kis/revoke/route.ts
Normal 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";
|
||||
}
|
||||
65
app/api/kis/validate/route.ts
Normal file
65
app/api/kis/validate/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { DashboardKisValidateResponse } from "@/features/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";
|
||||
}
|
||||
67
app/api/kis/ws/approval/route.ts
Normal file
67
app/api/kis/ws/approval/route.ts
Normal 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";
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { Geist, Geist_Mono, Outfit } from "next/font/google";
|
||||
import { QueryProvider } from "@/providers/query-provider";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { SessionManager } from "@/features/auth/components/session-manager";
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -61,6 +62,13 @@ export default function RootLayout({
|
||||
>
|
||||
<SessionManager />
|
||||
<QueryProvider>{children}</QueryProvider>
|
||||
<Toaster
|
||||
richColors
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
}}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import * as React from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -21,23 +22,38 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface ThemeToggleProps {
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테마 토글 컴포넌트
|
||||
* @remarks next-themes의 useTheme 훅 사용
|
||||
* @returns Dropdown 메뉴 형태의 테마 선택기
|
||||
*/
|
||||
export function ThemeToggle() {
|
||||
export function ThemeToggle({ className, iconClassName }: ThemeToggleProps) {
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
{/* ========== 트리거 버튼 ========== */}
|
||||
<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>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
48
components/ui/badge.tsx
Normal file
48
components/ui/badge.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
58
components/ui/scroll-area.tsx
Normal file
58
components/ui/scroll-area.tsx
Normal 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 }
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
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"
|
||||
|
||||
|
||||
244
components/ui/shader-background.tsx
Normal file
244
components/ui/shader-background.tsx
Normal 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;
|
||||
13
components/ui/skeleton.tsx
Normal file
13
components/ui/skeleton.tsx
Normal 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
91
components/ui/tabs.tsx
Normal 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 }
|
||||
@@ -13,6 +13,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
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초마다 리렌더링 발생
|
||||
* @see header.tsx - 로그인 상태일 때 헤더에 표시
|
||||
*/
|
||||
export function SessionTimer() {
|
||||
interface SessionTimerProps {
|
||||
/** 셰이더 배경 위에서 가독성을 높이는 투명 모드 */
|
||||
blendWithBackground?: boolean;
|
||||
}
|
||||
|
||||
export function SessionTimer({ blendWithBackground = false }: SessionTimerProps) {
|
||||
const lastActive = useSessionStore((state) => state.lastActive);
|
||||
|
||||
// [State] 남은 시간 (밀리초)
|
||||
@@ -54,11 +60,14 @@ export function SessionTimer() {
|
||||
|
||||
return (
|
||||
<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
|
||||
? "text-red-500 border-red-200 bg-red-50/50 dark:bg-red-900/20 dark:border-red-800"
|
||||
: "text-muted-foreground border-border/40"
|
||||
}`}
|
||||
? "border-red-200 bg-red-50/50 text-red-500 dark:border-red-800 dark:bg-red-900/20"
|
||||
: 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>
|
||||
|
||||
82
features/dashboard/apis/kis-auth.api.ts
Normal file
82
features/dashboard/apis/kis-auth.api.ts
Normal 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;
|
||||
}
|
||||
179
features/dashboard/apis/kis-stock.api.ts
Normal file
179
features/dashboard/apis/kis-stock.api.ts
Normal 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;
|
||||
}
|
||||
279
features/dashboard/components/DashboardContainer.tsx
Normal file
279
features/dashboard/components/DashboardContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
230
features/dashboard/components/auth/KisAuthForm.tsx
Normal file
230
features/dashboard/components/auth/KisAuthForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
484
features/dashboard/components/chart/StockLineChart.tsx
Normal file
484
features/dashboard/components/chart/StockLineChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
205
features/dashboard/components/chart/chart-utils.ts
Normal file
205
features/dashboard/components/chart/chart-utils.ts
Normal 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";
|
||||
}
|
||||
144
features/dashboard/components/details/StockOverviewCard.tsx
Normal file
144
features/dashboard/components/details/StockOverviewCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
features/dashboard/components/details/StockPriceBadge.tsx
Normal file
48
features/dashboard/components/details/StockPriceBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
features/dashboard/components/header/StockHeader.tsx
Normal file
71
features/dashboard/components/header/StockHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
features/dashboard/components/layout/DashboardLayout.tsx
Normal file
54
features/dashboard/components/layout/DashboardLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
249
features/dashboard/components/order/OrderForm.tsx
Normal file
249
features/dashboard/components/order/OrderForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
features/dashboard/components/orderbook/AnimatedQuantity.tsx
Normal file
90
features/dashboard/components/orderbook/AnimatedQuantity.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
567
features/dashboard/components/orderbook/OrderBook.tsx
Normal file
567
features/dashboard/components/orderbook/OrderBook.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
features/dashboard/components/search/StockSearchForm.tsx
Normal file
37
features/dashboard/components/search/StockSearchForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
features/dashboard/components/search/StockSearchResults.tsx
Normal file
47
features/dashboard/components/search/StockSearchResults.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21578
features/dashboard/data/korean-stocks.json
Normal file
21578
features/dashboard/data/korean-stocks.json
Normal file
File diff suppressed because it is too large
Load Diff
10
features/dashboard/data/korean-stocks.ts
Normal file
10
features/dashboard/data/korean-stocks.ts
Normal 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[];
|
||||
126
features/dashboard/data/mock-stocks.ts
Normal file
126
features/dashboard/data/mock-stocks.ts
Normal 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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
291
features/dashboard/hooks/useKisTradeWebSocket.ts
Normal file
291
features/dashboard/hooks/useKisTradeWebSocket.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
61
features/dashboard/hooks/useOrder.ts
Normal file
61
features/dashboard/hooks/useOrder.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
91
features/dashboard/hooks/useOrderBook.ts
Normal file
91
features/dashboard/hooks/useOrderBook.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
118
features/dashboard/hooks/useStockOverview.ts
Normal file
118
features/dashboard/hooks/useStockOverview.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
91
features/dashboard/hooks/useStockSearch.ts
Normal file
91
features/dashboard/hooks/useStockSearch.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
229
features/dashboard/store/use-kis-runtime-store.ts
Normal file
229
features/dashboard/store/use-kis-runtime-store.ts
Normal 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에서 제외)
|
||||
// -> 코드를 보니 여기 포함시키지 않으면 저장이 안 됨.
|
||||
// 유효기간 처리가 없으니 승인키는 메모리에만 유지하도록 함 (새로고침 시 재발급)
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
212
features/dashboard/types/dashboard.types.ts
Normal file
212
features/dashboard/types/dashboard.types.ts
Normal 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;
|
||||
}
|
||||
269
features/dashboard/utils/kis-realtime.utils.ts
Normal file
269
features/dashboard/utils/kis-realtime.utils.ts
Normal 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;
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
/**
|
||||
/**
|
||||
* @file features/layout/components/header.tsx
|
||||
* @description 애플리케이션 최상단 헤더 컴포넌트 (네비게이션, 테마, 유저 메뉴)
|
||||
* @remarks
|
||||
* - [레이어] Components/UI/Layout
|
||||
* - [사용자 행동] 홈 이동, 테마 변경, 로그인/회원가입 이동, 대시보드 이동
|
||||
* - [데이터 흐름] User Prop -> UI Conditional Rendering
|
||||
* - [연관 파일] layout.tsx, session-timer.tsx, user-menu.tsx
|
||||
* @description 애플리케이션 상단 헤더 컴포넌트
|
||||
*/
|
||||
|
||||
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 { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { SessionTimer } from "@/features/auth/components/session-timer";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface HeaderProps {
|
||||
/** 현재 로그인한 사용자 정보 (없으면 null) */
|
||||
/** 현재 로그인 사용자 정보(null 가능) */
|
||||
user: User | null;
|
||||
/** 대시보드 링크 표시 여부 */
|
||||
/** 대시보드 링크 버튼 노출 여부 */
|
||||
showDashboardLink?: boolean;
|
||||
/** 홈 랜딩에서 배경과 자연스럽게 섞이는 헤더 모드 */
|
||||
blendWithBackground?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 헤더 컴포넌트
|
||||
* @param user Supabase User 객체
|
||||
* @param showDashboardLink 대시보드 바로가기 버튼 노출 여부
|
||||
* @param showDashboardLink 대시보드 버튼 노출 여부
|
||||
* @param blendWithBackground 홈 랜딩 전용 반투명 모드
|
||||
* @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 (
|
||||
<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">
|
||||
<div className="flex h-16 w-full items-center justify-between px-4 md:px-6">
|
||||
{/* ========== 좌측: 로고 영역 ========== */}
|
||||
<Link href={AUTH_ROUTES.HOME} className="flex items-center gap-2 group">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/10 transition-transform duration-200 group-hover:scale-110">
|
||||
<header
|
||||
className={cn(
|
||||
"fixed inset-x-0 top-0 z-50 w-full",
|
||||
blendWithBackground
|
||||
? "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>
|
||||
<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
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* ========== 우측: 액션 버튼 영역 ========== */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 테마 토글 */}
|
||||
<ThemeToggle />
|
||||
{/* ========== RIGHT: ACTION SECTION ========== */}
|
||||
<div className={cn("flex items-center gap-2 sm:gap-3", blendWithBackground ? "text-white" : "")}
|
||||
>
|
||||
<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 ? (
|
||||
// [Case 1] 로그인 상태
|
||||
<>
|
||||
{/* 세션 타임아웃 타이머 */}
|
||||
<SessionTimer />
|
||||
<SessionTimer blendWithBackground={blendWithBackground} />
|
||||
|
||||
{showDashboardLink && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
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>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 사용자 드롭다운 메뉴 */}
|
||||
<UserMenu user={user} />
|
||||
<UserMenu user={user} blendWithBackground={blendWithBackground} />
|
||||
</>
|
||||
) : (
|
||||
// [Case 2] 비로그인 상태
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
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>
|
||||
</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>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@ export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
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">
|
||||
{MENU_ITEMS.map((item) => {
|
||||
const isActive = item.matchExact
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
/**
|
||||
/**
|
||||
* @file features/layout/components/user-menu.tsx
|
||||
* @description 사용자 프로필 드롭다운 메뉴 컴포넌트
|
||||
* @remarks
|
||||
* - [레이어] Components/UI
|
||||
* - [사용자 행동] Avatar 클릭 -> 드롭다운 오픈 -> 프로필/설정 이동 또는 로그아웃
|
||||
* - [연관 파일] header.tsx, features/auth/actions.ts (로그아웃)
|
||||
*/
|
||||
|
||||
"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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
@@ -19,21 +18,23 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { User } from "@supabase/supabase-js";
|
||||
import { LogOut, Settings, User as UserIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface UserMenuProps {
|
||||
/** Supabase User 객체 */
|
||||
user: User | null;
|
||||
/** 홈 랜딩의 shader 배경 위에서 대비를 높이는 모드 */
|
||||
blendWithBackground?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 메뉴/프로필 컴포넌트 (로그인 시 헤더 노출)
|
||||
* 사용자 메뉴/프로필 컴포넌트
|
||||
* @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();
|
||||
|
||||
if (!user) return null;
|
||||
@@ -41,38 +42,55 @@ export function UserMenu({ user }: UserMenuProps) {
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-2 outline-none">
|
||||
<Avatar className="h-8 w-8 transition-opacity hover:opacity-80">
|
||||
<button
|
||||
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} />
|
||||
<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()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{user.user_metadata?.full_name ||
|
||||
user.user_metadata?.name ||
|
||||
"사용자"}
|
||||
</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{user.email}
|
||||
{user.user_metadata?.full_name || user.user_metadata?.name || "사용자"}
|
||||
</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={() => router.push("/profile")}>
|
||||
<UserIcon className="mr-2 h-4 w-4" />
|
||||
<span>프로필</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>설정</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<form action={signout}>
|
||||
<DropdownMenuItem asChild>
|
||||
<button className="w-full text-red-600 dark:text-red-400">
|
||||
|
||||
142
lib/kis/approval.ts
Normal file
142
lib/kis/approval.ts
Normal 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
152
lib/kis/client.ts
Normal 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
122
lib/kis/config.ts
Normal 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
777
lib/kis/domestic.ts
Normal 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
305
lib/kis/token.ts
Normal 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
80
lib/kis/trade.ts
Normal 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
16
package-lock.json
generated
@@ -24,6 +24,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.31.0",
|
||||
"lightweight-charts": "^5.1.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -6863,6 +6864,12 @@
|
||||
"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": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -8266,6 +8273,15 @@
|
||||
"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": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.31.0",
|
||||
"lightweight-charts": "^5.1.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
799
temp-kis-auth.py
Normal file
799
temp-kis-auth.py
Normal 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")
|
||||
182
temp-kis-domestic-examples-ws.py
Normal file
182
temp-kis-domestic-examples-ws.py
Normal 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)
|
||||
|
||||
2130
temp-kis-domestic-functions-ws.py
Normal file
2130
temp-kis-domestic-functions-ws.py
Normal file
File diff suppressed because it is too large
Load Diff
13463
temp-kis-domestic-functions.py
Normal file
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
78
temp-kis-inquire-price.py
Normal 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
104
temp-kis-kosdaq-code-mst.py
Normal 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
108
temp-kis-kospi-code-mst.py
Normal 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
|
||||
1823
temp-kis-master/kosdaq_code.mst
Normal file
1823
temp-kis-master/kosdaq_code.mst
Normal file
File diff suppressed because it is too large
Load Diff
BIN
temp-kis-master/kosdaq_code.zip
Normal file
BIN
temp-kis-master/kosdaq_code.zip
Normal file
Binary file not shown.
2486
temp-kis-master/kospi_code.mst
Normal file
2486
temp-kis-master/kospi_code.mst
Normal file
File diff suppressed because it is too large
Load Diff
BIN
temp-kis-master/kospi_code.zip
Normal file
BIN
temp-kis-master/kospi_code.zip
Normal file
Binary file not shown.
36
temp-kis_devlp.yaml
Normal file
36
temp-kis_devlp.yaml
Normal 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
99
temp-kospi-master.h
Normal 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;
|
||||
Reference in New Issue
Block a user