2 Commits

Author SHA1 Message Date
8f1d75b4d5 정리 2026-02-12 10:24:03 +09:00
3cea3e66d0 임시커밋 2026-02-11 16:31:28 +09:00
80 changed files with 23530 additions and 44244 deletions

1
.gitignore vendored
View File

@@ -119,6 +119,7 @@ storybook-static/
*.local
.cache/
node_modules
.tmp/
# ========================================
# Custom

View File

@@ -1 +0,0 @@
{"mock:7777b1c958636e65539aadf6c4273a0dfa33c17e55d1beb7dbfd033d49457f6e":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIsImF1ZCI6ImRhNWMyYjU5LTA3YTUtNGVhNy1hY2UyLWZlNTMwZTBjM2ZjNCIsInByZHRfY2QiOiIiLCJpc3MiOiJ1bm9ndyIsImV4cCI6MTc3MDc5MzIzOCwiaWF0IjoxNzcwNzA2ODM4LCJqdGkiOiJQUzA2TG12SndjRHl1MDZLVXpPRjVsSHJacWR6ZWJKTXhCVWIifQ.vDnx1Vx-LnzaRETwkR5aQUnl7vV3-KlXG5AVlMRrchjdovJzd5n1vL7YfG_146Swrao2Drw8TwnpdK44-aTrhg","expiresAt":1770793238000},"real:7777b1c958636e65539aadf6c4273a0dfa33c17e55d1beb7dbfd033d49457f6e":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIsImF1ZCI6IjQ1ZTBmYTczLWI3ZmEtNDg5Mi1iYmZkLTJkYzdlNWQ2YTFhOCIsInByZHRfY2QiOiIiLCJpc3MiOiJ1bm9ndyIsImV4cCI6MTc3MDg3NDg1NywiaWF0IjoxNzcwNzg4NDU3LCJqdGkiOiJQUzA2TG12SndjRHl1MDZLVXpPRjVsSHJacWR6ZWJKTXhCVWIifQ.f4XsiK4WgzzBNbGEP5bNnJ9r4yAfGBb8SOwEZ-D0knygsFqSOGsj1QfjjVIBo7lG5AxAwyrIUdoC-rjqIVCc3A","expiresAt":1770874857000}}

View File

@@ -1,8 +1,8 @@
# AGENTS.md (auto-trade)
# AGENTS.md (auto-trade)
## 기본 원칙
- 모든 응답과 설명은 한국어로 작성.
- 쉬운 말로 설명. 어려운 용어는 괄호로 짧게 뜻을 덧붙임.
- 쉬운 말로 설명하고, 어려운 용어는 괄호로 짧게 뜻을 덧붙임.
- 요청이 모호하면 먼저 질문 1~3개로 범위를 확인.
## 프로젝트 요약
@@ -10,36 +10,43 @@
- 상태 관리: zustand
- 데이터: Supabase
- 폼 및 검증: react-hook-form, zod
- UI: Tailwind CSS v4, Radix UI (components.json 사용)
- UI: Tailwind CSS v4, Radix UI (`components.json` 사용)
## 명령어
- 개발 서버: (포트 3001번이야)
pm run dev
- 린트:
pm run lint
- 빌드:
pm run build
- 실행:
pm run start
- 개발 서버(포트 3001): `npm run dev`
- 린트: `npm run lint`
- 빌드: `npm run build`
- 실행: `npm run start`
## 코드 및 문서 규칙
- JSX 섹션 주석 형식: {/* ========== SECTION NAME ========== */}
- 함수 및 컴포넌트 JSDoc에 @see 필수 (호출 파일, 함수 또는 이벤트 이름, 목적 포함)
- JSX 섹션 주석 형식: `{/* ========== SECTION NAME ========== */}`
- 함수 및 컴포넌트 JSDoc에 `@see` 필수
- `@see`에는 호출 파일, 함수/이벤트 이름, 목적을 함께 작성
- 상태 정의, 이벤트 핸들러, 복잡한 JSX 로직에는 인라인 주석을 충분히 작성
- UI 흐름 설명 필수: `어느 UI -> A 함수 호출 -> B 함수 호출 -> 리턴값 반영` 형태로 작성
## 인코딩/편집 규칙
- 텍스트 파일 수정은 원칙적으로 `apply_patch`만 사용
- `shell_command``Set-Content`, `Out-File`, 리다이렉션(`>`)으로 코드 파일 저장 금지
- 파일 읽기는 반드시 인코딩 명시: `Get-Content -Encoding UTF8`
- 부득이하게 셸로 저장해야 하면 BOM 없는 UTF-8만 사용:
`[System.IO.File]::WriteAllText($path, $text, [System.Text.UTF8Encoding]::new($false))`
## 브랜드 색상 규칙
- 메인 컬러는 헤더 로고/프로필 기준의 보라 계열 `brand` 팔레트 사용.
- 새 UI 작성 시 `indigo/purple/pink` 하드코딩 대신 `brand-*` 토큰 사용. 예: `bg-brand-500`, `text-brand-600`, `from-brand-500 to-brand-700`.
- 기본 액션 색(버튼/포커스)은 `primary`를 사용하고, `primary` `app/globals.css``brand` 팔레트와 같은 톤으로 유지.
- 색상 변경이 필요하면 컴포넌트 개별 수정보다 먼저 `app/globals.css` 토큰을 수정.
- 메인 컬러는 헤더 로고/프로필 기준의 보라 계열 `brand` 팔레트 사용
- 새 UI 작성 시 `indigo/purple/pink` 하드코딩 대신 `brand-*` 토큰 사용
- 예시: `bg-brand-500`, `text-brand-600`, `from-brand-500 to-brand-700`
- 기본 액션 색(버튼/포커스)은 `primary` 사용
- `primary``app/globals.css``brand` 팔레트와 같은 톤으로 유지
- 색상 변경이 필요하면 컴포넌트 개별 수정보다 먼저 `app/globals.css` 토큰 수정
## 설명 방식
- 단계별로 짧게, 예시는 1개만.
- 사용자가 요청한 변경과 이유를 함께 설명.
- 파일 경로는 pp/...처럼 코드 형식으로 표기.
## 개발 도구 활용
## 여러 도구를 함께 쓸 때 (쉬운 설명)
- 기준 설명을 한 군데에 모아두고, 그 파일만 계속 업데이트하는 것이 핵심.
- 예를 들어 PROJECT_CONTEXT.md에 스택, 폴더 구조, 규칙을 적어둔다.
- 그리고 각 도구의 규칙 파일에 PROJECT_CONTEXT.md를 참고라고 써 둔다.
- 이렇게 하면 어떤 도구를 쓰든 같은 파일을 읽게 되어, 매번 다시 설명할 일이 줄어든다.
- **Skills**: 프로젝트에 적합한 스킬을 적극 활용하여 베스트 프랙티스 적용
- **MCP 서버**:
- `sequential-thinking`: 복잡한 문제 해결 시 단계별 사고 과정 정리
- `tavily-remote`: 최신 기술 트렌드 및 문서 검색
- `playwright` / `playwriter`: 브라우저 자동화 테스트
- `next-devtools`: Next.js 프로젝트 개발 및 디버깅
- `context7`: 라이브러리/프레임워크 공식 문서 참조
- `supabase-mcp-server`: Supabase 프로젝트 관리 및 쿼리

View File

@@ -1,48 +0,0 @@
# PROJECT_CONTEXT.md
이 파일은 프로젝트 설명의 기준(원본)입니다.
여기만 업데이트하고, 다른 도구 규칙에서는 이 파일을 참고하도록 연결하세요.
## 한 줄 요약
- 자동매매(오토 트레이드) 웹 앱
## 기술 스택
- Next.js 16 (App Router)
- React 19, TypeScript
- 상태 관리: zustand
- 데이터: Supabase
- 폼/검증: react-hook-form, zod
- UI: Tailwind CSS v4, Radix UI
## 폴더 구조(핵심만)
- pp/ 라우팅 및 페이지
- eatures/ 도메인별 기능
- components/ 공용 UI
- lib/ 유틸/클라이언트
- utils/ 헬퍼
## 주요 규칙(요약)
- JSX 섹션 주석 형식: {/* ========== SECTION NAME ========== */}
- 함수/컴포넌트 JSDoc에 @see 필수
- 파일 상단에 @author jihoon87.lee
- 상태/이벤트/복잡 로직에 인라인 주석 충분히 작성
## 작업 흐름
- 개발 서버:
pm run dev
- 린트:
pm run lint
- 빌드:
pm run build
- 실행:
pm run start
## 자주 하는 설명 템플릿
- 변경 이유: (왜 바꾸는지)
- 변경 내용: (무엇을 바꾸는지)
- 영향 범위: (어디에 영향이 있는지)
## 업데이트 가이드
- 새 규칙/패턴이 생기면 여기에 먼저 추가
- 문장이 길어지면 더 짧게 요약
- 도구별 규칙 파일에는 PROJECT_CONTEXT.md 참고만 적기

165
README.md
View File

@@ -1,36 +1,161 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# auto-trade
## Getting Started
한국투자증권(KIS) Open API와 Supabase 인증을 연결한 국내주식 트레이딩 대시보드입니다.
사용자는 로그인 후 API 키를 검증하고, 종목 검색/상세/차트/호가/체결/주문 화면을 한 곳에서 사용할 수 있습니다.
First, run the development server:
## 1) 핵심 기능
- 인증: Supabase 이메일/소셜 로그인, 비밀번호 재설정, 세션 타임아웃 자동 로그아웃
- KIS 연결: 실전/모의 모드 선택, API 키 검증, 웹소켓 승인키 발급
- 트레이드 화면: 종목 검색, 종목 개요, 캔들 차트, 실시간 호가/체결, 현금 주문
- 종목 검색 인덱스: `korean-stocks.json` 기반 고속 검색 + 자동 갱신 스크립트
## 2) 기술 스택
- 프레임워크: Next.js 16 (App Router), React 19, TypeScript
- 상태관리: Zustand
- 서버 상태: TanStack Query (React Query)
- 인증/백엔드: Supabase (`@supabase/ssr`, `@supabase/supabase-js`)
- UI: Tailwind CSS v4, Radix UI, Sonner
- 차트: `lightweight-charts`
## 3) 화면/라우트
- `/`: 서비스 랜딩 페이지
- `/login`, `/signup`, `/forgot-password`, `/reset-password`: 인증 페이지
- `/dashboard`: 로그인 전용(현재 플레이스홀더)
- `/settings`: KIS API 키 연결/해제
- `/trade`: 실제 트레이딩 대시보드
## 4) UI 흐름 (중요)
- 인증 흐름: 로그인 UI -> `features/auth/actions.ts` -> Supabase Auth -> 세션 쿠키 반영 -> 보호 라우트 접근 허용
- KIS 연결 흐름: 설정 UI -> `/api/kis/validate` -> 토큰 발급 성공 확인 -> `use-kis-runtime-store`에 검증 상태 저장
- 실시간 흐름: 트레이드 UI -> `/api/kis/ws/approval` -> `useKisTradeWebSocket` 구독 -> 체결/호가 파싱 -> 차트/호가창 반영
- 검색 흐름: 검색 UI -> `/api/kis/domestic/search` -> `korean-stocks.json` 메모리 검색 -> 결과 목록 반영
## 5) 빠른 시작
### 5-1. 요구 사항
- Node.js 20 이상
- npm 10 이상 권장
### 5-2. 설치
```bash
npm install
```
### 5-3. 환경변수 설정
`.env.example`을 복사해서 `.env.local`을 만듭니다.
```bash
cp .env.example .env.local
```
Windows PowerShell:
```powershell
Copy-Item .env.example .env.local
```
필수 값은 아래를 먼저 채우면 됩니다.
- `NEXT_PUBLIC_SUPABASE_URL`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
KIS는 선택입니다(직접 입력 방식이면 서버 기본 키 없이도 동작 가능).
- `KIS_TRADING_ENV=real|mock`
- `KIS_APP_KEY_REAL`, `KIS_APP_SECRET_REAL` (선택)
- `KIS_APP_KEY_MOCK`, `KIS_APP_SECRET_MOCK` (선택)
- `NEXT_PUBLIC_BASE_URL` (선택, 배포 도메인 사용 시 권장)
### 5-4. 로컬 실행
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
- 개발 서버: `http://localhost:3001`
- Turbopack 적용: `package.json``dev` 스크립트에 `--turbopack` 포함
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
### 5-5. 점검 명령
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
```bash
npm run lint
npm run build
npm run start
```
## Learn More
## 6) 종목 인덱스 동기화
To learn more about Next.js, take a look at the following resources:
`features/trade/data/korean-stocks.json`은 수동 편집용 파일이 아닙니다.
KIS 마스터 파일(KOSPI/KOSDAQ)에서 자동 생성합니다.
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
```bash
npm run sync:stocks
```
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
검증만 하고 싶으면:
## Deploy on Vercel
```bash
npm run sync:stocks:check
```
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
상세 문서: `docs/trade-stock-sync.md`
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
## 7) API 엔드포인트 요약
- 인증/연결
- `POST /api/kis/validate`: API 키 검증
- `POST /api/kis/revoke`: 토큰 폐기
- `POST /api/kis/ws/approval`: 웹소켓 승인키 발급
- 국내주식
- `GET /api/kis/domestic/search?q=...`: 종목 검색(로컬 인덱스)
- `GET /api/kis/domestic/overview?symbol=...`: 종목 개요
- `GET /api/kis/domestic/orderbook?symbol=...`: 호가
- `GET /api/kis/domestic/chart?symbol=...&timeframe=...`: 차트
- `POST /api/kis/domestic/order-cash`: 현금 주문
## 8) 프로젝트 구조
```text
app/
(home)/ 랜딩
(auth)/ 로그인/회원가입/비밀번호 재설정
(main)/ 로그인 후 화면(dashboard/trade/settings)
api/kis/ KIS 연동 API 라우트
features/
auth/ 인증 UI/액션/상수
settings/ KIS 키 설정 UI + 런타임 스토어
trade/ 검색/차트/호가/주문/웹소켓
lib/kis/ KIS REST/WS 공통 로직
scripts/
sync-korean-stocks.mjs
utils/supabase/ 서버/클라이언트/미들웨어 클라이언트
```
## 9) 트러블슈팅
- KIS 검증 실패
- 입력한 앱키/시크릿, 실전/모의 모드가 맞는지 확인
- KIS Open API 앱 권한과 IP 허용 설정 확인
- 실시간 체결/호가가 안 들어옴
- `/settings`에서 검증 상태가 유지되는지 확인
- 장 구간(장중/동시호가/시간외)에 따라 데이터가 달라질 수 있음
- 브라우저 콘솔 디버그가 필요하면 `localStorage.KIS_WS_DEBUG = "1"` 설정
- 검색 결과가 기대와 다름
- 검색은 KIS 실시간 검색 API가 아니라 로컬 인덱스(`korean-stocks.json`) 기반
- 최신 종목 반영이 필요하면 `npm run sync:stocks` 실행
## 10) 운영 주의사항
- 현재 KIS 입력값과 검증 상태 일부는 브라우저 로컬 스토리지(localStorage, 브라우저 저장소)에 저장됩니다.
- 실전 운영 시에는 민감정보 보관 정책(암호화, 만료, 서버 보관 방식)을 반드시 따로 설계하세요.
- 주문은 계좌번호가 필요합니다. 계좌번호 입력/검증 UX는 운영 환경에 맞게 보완이 필요합니다.

View File

@@ -5,12 +5,12 @@
import { redirect } from "next/navigation";
import { createClient } from "@/utils/supabase/server";
import { DashboardContainer } from "@/features/dashboard/components/DashboardContainer";
/**
* 대시보드 페이지
* @returns DashboardContainer UI
* @see features/dashboard/components/DashboardContainer.tsx 클라이언트 상호작용(검색/시세/차트)은 해당 컴포넌트가 담당합니다.
* 대시보드 페이지 (향후 확장용)
* @returns 빈 대시보드 안내 UI
* @see app/(main)/trade/page.tsx 트레이딩 기능은 `/trade` 경로에서 제공합니다.
* @see app/(main)/settings/page.tsx KIS 인증 설정은 `/settings` 경로에서 제공합니다.
*/
export default async function DashboardPage() {
// 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
@@ -21,5 +21,17 @@ export default async function DashboardPage() {
if (!user) redirect("/login");
return <DashboardContainer />;
return (
<section className="mx-auto flex h-full w-full max-w-5xl flex-col justify-center p-6">
{/* ========== DASHBOARD PLACEHOLDER ========== */}
<div className="rounded-2xl border border-brand-200 bg-background p-8 shadow-sm dark:border-brand-800/45 dark:bg-brand-900/14">
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
</h1>
<p className="mt-2 text-sm text-muted-foreground">
.
</p>
</div>
</section>
);
}

View File

@@ -0,0 +1,26 @@
/**
* @file app/(main)/settings/page.tsx
* @description 로그인 사용자 전용 설정 페이지(Server Component)
*/
import { redirect } from "next/navigation";
import { SettingsContainer } from "@/features/settings/components/SettingsContainer";
import { createClient } from "@/utils/supabase/server";
/**
* 설정 페이지
* @returns SettingsContainer UI
* @see features/settings/components/SettingsContainer.tsx KIS 인증 설정 UI를 제공합니다.
*/
export default async function SettingsPage() {
// 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect("/login");
return <SettingsContainer />;
}

26
app/(main)/trade/page.tsx Normal file
View File

@@ -0,0 +1,26 @@
/**
* @file app/(main)/trade/page.tsx
* @description 로그인 사용자 전용 트레이딩 페이지(Server Component)
*/
import { redirect } from "next/navigation";
import { TradeContainer } from "@/features/trade/components/TradeContainer";
import { createClient } from "@/utils/supabase/server";
/**
* 트레이딩 페이지
* @returns TradeContainer UI
* @see features/trade/components/TradeContainer.tsx 종목 검색/차트/호가/주문 기능을 제공합니다.
*/
export default async function TradePage() {
// 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect("/login");
return <TradeContainer />;
}

View File

@@ -1,7 +1,7 @@
import type {
DashboardChartTimeframe,
DashboardStockChartResponse,
} from "@/features/dashboard/types/dashboard.types";
} from "@/features/trade/types/trade.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticChart } from "@/lib/kis/domestic";

View File

@@ -3,7 +3,7 @@ import { executeOrderCash } from "@/lib/kis/trade";
import {
DashboardStockCashOrderRequest,
DashboardStockCashOrderResponse,
} from "@/features/dashboard/types/dashboard.types";
} from "@/features/trade/types/trade.types";
import {
KisCredentialInput,
hasKisConfig,

View File

@@ -3,7 +3,7 @@ import {
getDomesticOrderBook,
KisDomesticOrderBookOutput,
} from "@/lib/kis/domestic";
import { DashboardStockOrderBookResponse } from "@/features/dashboard/types/dashboard.types";
import { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types";
import {
KisCredentialInput,
hasKisConfig,

View File

@@ -1,5 +1,5 @@
import { KOREAN_STOCK_INDEX } from "@/features/dashboard/data/korean-stocks";
import type { DashboardStockOverviewResponse } from "@/features/dashboard/types/dashboard.types";
import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks";
import type { DashboardStockOverviewResponse } from "@/features/trade/types/trade.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticOverview } from "@/lib/kis/domestic";

View File

@@ -1,9 +1,9 @@
import { KOREAN_STOCK_INDEX } from "@/features/dashboard/data/korean-stocks";
import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks";
import type {
DashboardStockSearchItem,
DashboardStockSearchResponse,
KoreanStockIndexItem,
} from "@/features/dashboard/types/dashboard.types";
} from "@/features/trade/types/trade.types";
import { NextRequest, NextResponse } from "next/server";
const SEARCH_LIMIT = 10;
@@ -15,7 +15,7 @@ const SEARCH_LIMIT = 10;
* - [레이어] API Route
* - [사용자 행동] 대시보드 검색창 엔터/검색 버튼 클릭 시 호출
* - [데이터 흐름] dashboard-main.tsx -> /api/kis/domestic/search -> KOREAN_STOCK_INDEX 필터/정렬 -> JSON 응답
* - [연관 파일] features/dashboard/data/korean-stocks.ts, features/dashboard/components/dashboard-main.tsx
* - [연관 파일] features/trade/data/korean-stocks.ts, features/trade/components/dashboard-main.tsx
* @author jihoon87.lee
*/
@@ -23,7 +23,7 @@ const SEARCH_LIMIT = 10;
* 국내주식 검색 API
* @param request query string의 q(검색어) 사용
* @returns 종목 검색 결과 목록
* @see features/dashboard/components/dashboard-main.tsx 검색 폼에서 호출합니다.
* @see features/trade/components/dashboard-main.tsx 검색 폼에서 호출합니다.
*/
export async function GET(request: NextRequest) {
// [Step 1] query string에서 검색어(q)를 읽고 공백을 제거합니다.

View File

@@ -1,4 +1,4 @@
import type { DashboardKisRevokeResponse } from "@/features/dashboard/types/dashboard.types";
import type { DashboardKisRevokeResponse } from "@/features/trade/types/trade.types";
import { normalizeTradingEnv } from "@/lib/kis/config";
import {
parseKisCredentialRequest,
@@ -14,7 +14,7 @@ import { NextRequest, NextResponse } from "next/server";
/**
* @description KIS 액세스 토큰 폐기
* @see features/dashboard/components/auth/KisAuthForm.tsx
* @see features/settings/components/KisAuthForm.tsx
*/
export async function POST(request: NextRequest) {
const credentials = await parseKisCredentialRequest(request);

View File

@@ -1,4 +1,4 @@
import type { DashboardKisValidateResponse } from "@/features/dashboard/types/dashboard.types";
import type { DashboardKisValidateResponse } from "@/features/trade/types/trade.types";
import { normalizeTradingEnv } from "@/lib/kis/config";
import {
parseKisCredentialRequest,
@@ -14,7 +14,7 @@ import { NextRequest, NextResponse } from "next/server";
/**
* @description 액세스 토큰 발급 성공 여부로 API 키를 검증합니다.
* @see features/dashboard/components/auth/KisAuthForm.tsx
* @see features/settings/components/KisAuthForm.tsx
*/
export async function POST(request: NextRequest) {
const credentials = await parseKisCredentialRequest(request);

View File

@@ -1,4 +1,4 @@
import type { DashboardKisWsApprovalResponse } from "@/features/dashboard/types/dashboard.types";
import type { DashboardKisWsApprovalResponse } from "@/features/trade/types/trade.types";
import { getKisApprovalKey, resolveKisWebSocketUrl } from "@/lib/kis/approval";
import { normalizeTradingEnv } from "@/lib/kis/config";
import {
@@ -14,7 +14,7 @@ import { NextRequest, NextResponse } from "next/server";
/**
* @description 실시간 웹소켓 연결 정보를 발급합니다.
* @see features/dashboard/hooks/useKisTradeWebSocket.ts
* @see features/trade/hooks/useKisTradeWebSocket.ts
*/
export async function POST(request: NextRequest) {
const credentials = await parseKisCredentialRequest(request);

View File

@@ -1,32 +0,0 @@
# Antigravity Rules
This document defines the coding and behavior rules for the Antigravity agent.
## General Rules
- **Language**: All output, explanations, and commit messages MUST be in **Korean (한국어)**.
- **Tone**: Professional, helpful, and concise.
## Documentation Rules
### JSX Comments
- Mandatory use of section comments in JSX to delineate logical blocks.
- Format: `{/* ========== SECTION NAME ========== */}`
### JSDoc Tags
- **@see**: Mandatory for function/component documentation. Must include calling file, function/event name, and purpose.
- **@author**: Mandatory file-level tag. Use `@author jihoon87.lee`.
### Inline Comments
- High density of inline comments required for:
- State definitions
- Event handlers
- Complex logic in JSX
- Balance conciseness with clarity.
## Code Style
- Follow Project-specific linting and formatting rules.

View File

@@ -1,423 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { useShallow } from "zustand/react/shallow";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
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 { StockSearchHistory } from "@/features/dashboard/components/search/StockSearchHistory";
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 { useCurrentPrice } from "@/features/dashboard/hooks/useCurrentPrice";
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 {
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 hasInitializedAuthPanelRef = useRef(false);
const searchShellRef = useRef<HTMLDivElement | null>(null);
// 모바일에서는 초기 진입 시 API 패널을 접어 본문(차트/호가)을 먼저 보이게 합니다.
const [isMobileViewport, setIsMobileViewport] = useState(false);
const [isAuthPanelExpanded, setIsAuthPanelExpanded] = useState(true);
const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false);
const { verifiedCredentials, isKisVerified } = useKisRuntimeStore(
useShallow((state) => ({
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
})),
);
const {
keyword,
setKeyword,
searchResults,
setSearchError,
isSearching,
search,
clearSearch,
searchHistory,
appendSearchHistory,
removeSearchHistory,
clearSearchHistory,
} = 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, 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,
},
);
// 3. Price Calculation Logic (Hook)
const {
currentPrice,
change,
changeRate,
prevClose: referencePrice,
} = useCurrentPrice({
stock: selectedStock,
latestTick,
orderBook,
});
const canSearch = isKisVerified && !!verifiedCredentials;
/**
* @description 검색 전 API 인증 여부를 확인합니다.
* @see features/dashboard/components/search/StockSearchForm.tsx 검색 제출 전 공통 가드로 사용합니다.
*/
const ensureSearchReady = useCallback(() => {
if (canSearch) return true;
setSearchError("API 키 검증을 먼저 완료해 주세요.");
return false;
}, [canSearch, setSearchError]);
const closeSearchPanel = useCallback(() => {
setIsSearchPanelOpen(false);
}, []);
const openSearchPanel = useCallback(() => {
if (!canSearch) return;
setIsSearchPanelOpen(true);
}, [canSearch]);
/**
* @description 검색 영역 포커스가 완전히 빠지면 드롭다운(검색결과/히스토리)을 닫습니다.
* @see features/dashboard/components/search/StockSearchForm.tsx 입력 포커스 이벤트에서 열림 제어를 함께 사용합니다.
*/
const handleSearchShellBlur = useCallback(
(event: React.FocusEvent<HTMLDivElement>) => {
const nextTarget = event.relatedTarget as Node | null;
if (nextTarget && searchShellRef.current?.contains(nextTarget)) return;
closeSearchPanel();
},
[closeSearchPanel],
);
const handleSearchShellKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== "Escape") return;
closeSearchPanel();
(event.target as HTMLElement | null)?.blur?.();
},
[closeSearchPanel],
);
useEffect(() => {
const mediaQuery = window.matchMedia("(max-width: 767px)");
const applyViewportMode = (matches: boolean) => {
setIsMobileViewport(matches);
// 최초 1회: 모바일이면 접힘, 데스크탑이면 펼침.
if (!hasInitializedAuthPanelRef.current) {
setIsAuthPanelExpanded(!matches);
hasInitializedAuthPanelRef.current = true;
return;
}
// 데스크탑으로 돌아오면 항상 펼쳐 사용성을 유지합니다.
if (!matches) {
setIsAuthPanelExpanded(true);
}
};
applyViewportMode(mediaQuery.matches);
const onViewportChange = (event: MediaQueryListEvent) => {
applyViewportMode(event.matches);
};
mediaQuery.addEventListener("change", onViewportChange);
return () => mediaQuery.removeEventListener("change", onViewportChange);
}, []);
useEffect(() => {
if (skipNextAutoSearchRef.current) {
skipNextAutoSearchRef.current = false;
return;
}
if (!canSearch) {
clearSearch();
return;
}
const trimmed = keyword.trim();
if (!trimmed) {
clearSearch();
return;
}
const timer = window.setTimeout(() => {
search(trimmed, verifiedCredentials);
}, 220);
return () => window.clearTimeout(timer);
}, [canSearch, keyword, verifiedCredentials, search, clearSearch]);
/**
* @description 수동 검색 버튼(엔터 포함) 제출 이벤트를 처리합니다.
* @see features/dashboard/components/search/StockSearchForm.tsx onSubmit 이벤트로 호출됩니다.
*/
const handleSearchSubmit = useCallback(
(event: React.FormEvent) => {
event.preventDefault();
if (!ensureSearchReady() || !verifiedCredentials) return;
search(keyword, verifiedCredentials);
},
[ensureSearchReady, keyword, search, verifiedCredentials],
);
/**
* @description 검색 결과/히스토리에서 종목을 선택하면 종목 상세를 로드하고 히스토리를 갱신합니다.
* @see features/dashboard/components/search/StockSearchResults.tsx onSelect 이벤트
* @see features/dashboard/components/search/StockSearchHistory.tsx onSelect 이벤트
*/
const handleSelectStock = useCallback(
(item: DashboardStockSearchItem) => {
if (!ensureSearchReady() || !verifiedCredentials) return;
// 같은 종목 재선택 시 중복 overview 요청을 막고 검색 목록만 닫습니다.
if (selectedStock?.symbol === item.symbol) {
clearSearch();
closeSearchPanel();
return;
}
// 카드 선택으로 keyword가 바뀔 때 자동 검색이 다시 실행되지 않게 한 번 건너뜁니다.
skipNextAutoSearchRef.current = true;
setKeyword(item.name);
clearSearch();
closeSearchPanel();
appendSearchHistory(item);
loadOverview(item.symbol, verifiedCredentials, item.market);
},
[
ensureSearchReady,
verifiedCredentials,
selectedStock?.symbol,
clearSearch,
closeSearchPanel,
setKeyword,
appendSearchHistory,
loadOverview,
],
);
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 dark:border-brand-800/45 dark:bg-brand-900/28">
<div className="flex items-center justify-between gap-2 px-3 py-2 text-xs sm:px-4">
<div className="flex items-center gap-2">
<span className="font-semibold">KIS API :</span>
{isKisVerified ? (
<span className="flex items-center font-medium text-brand-700 dark:text-brand-200">
<span className="mr-1.5 h-2 w-2 rounded-full bg-brand-500 ring-2 ring-brand-100 dark:ring-brand-900" />
({verifiedCredentials?.tradingEnv === "real" ? "실전" : "모의"})
</span>
) : (
<span className="text-muted-foreground flex items-center">
<span className="mr-1.5 h-2 w-2 rounded-full bg-brand-200 dark:bg-brand-500/60" />
</span>
)}
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setIsAuthPanelExpanded((prev) => !prev)}
className={cn(
"h-8 shrink-0 gap-1.5 px-2.5 text-[11px] font-semibold",
"border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 dark:border-brand-700/60 dark:bg-brand-900/30 dark:text-brand-200 dark:hover:bg-brand-900/45",
!isAuthPanelExpanded &&
isMobileViewport &&
"ring-2 ring-brand-200 dark:ring-brand-600/60",
)}
>
{isAuthPanelExpanded ? (
<>
<ChevronUp className="h-3.5 w-3.5" />
API
</>
) : (
<>
<ChevronDown className="h-3.5 w-3.5" />
API
</>
)}
</Button>
</div>
<div
className={cn(
"overflow-hidden transition-[max-height,opacity] duration-300 ease-in-out",
isAuthPanelExpanded ? "max-h-[560px] opacity-100" : "max-h-0 opacity-0",
)}
>
<div className="border-t bg-background p-4 dark:border-brand-800/45 dark:bg-brand-900/14">
<KisAuthForm />
</div>
</div>
</div>
{/* ========== SEARCH ========== */}
<div className="z-30 flex-none border-b bg-background/95 p-4 backdrop-blur-sm dark:border-brand-800/45 dark:bg-brand-900/22">
<div
ref={searchShellRef}
onBlurCapture={handleSearchShellBlur}
onKeyDownCapture={handleSearchShellKeyDown}
className="relative mx-auto max-w-2xl"
>
<StockSearchForm
keyword={keyword}
onKeywordChange={setKeyword}
onSubmit={handleSearchSubmit}
onInputFocus={openSearchPanel}
disabled={!canSearch}
isLoading={isSearching}
/>
{isSearchPanelOpen && canSearch && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-80 overflow-x-hidden overflow-y-auto rounded-md border bg-background shadow-lg dark:border-brand-800/45 dark:bg-brand-950/95">
{searchResults.length > 0 ? (
<StockSearchResults
items={searchResults}
onSelect={handleSelectStock}
selectedSymbol={selectedStock?.symbol}
/>
) : keyword.trim() ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
{isSearching ? "검색 중..." : "검색 결과가 없습니다."}
</div>
) : searchHistory.length > 0 ? (
<StockSearchHistory
items={searchHistory}
onSelect={handleSelectStock}
onRemove={removeSearchHistory}
onClear={clearSearchHistory}
selectedSymbol={selectedStock?.symbol}
/>
) : (
<div className="px-3 py-2 text-sm text-muted-foreground">
.
</div>
)}
</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}
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={selectedStock.candles}
credentials={verifiedCredentials}
latestTick={latestTick}
/>
</div>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
</div>
)
}
orderBook={
<OrderBook
symbol={selectedStock?.symbol}
referencePrice={referencePrice}
currentPrice={currentPrice}
latestTick={latestTick}
recentTicks={recentTradeTicks}
orderBook={orderBook}
isLoading={isOrderBookLoading}
/>
}
orderForm={<OrderForm stock={selectedStock ?? undefined} />}
/>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,295 +0,0 @@
import { useEffect, useRef, useState } from "react";
import {
type KisRuntimeCredentials,
useKisRuntimeStore,
} from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardRealtimeTradeTick,
DashboardStockOrderBookResponse,
} from "@/features/dashboard/types/dashboard.types";
import {
buildKisRealtimeMessage,
parseKisRealtimeOrderbook,
parseKisRealtimeTickBatch,
} from "@/features/dashboard/utils/kis-realtime.utils";
import {
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
resolveDomesticKisSession,
shouldUseAfterHoursSinglePriceTr,
shouldUseExpectedExecutionTr,
type DomesticKisSession,
} from "@/lib/kis/domestic-market-session";
const TRADE_TR_ID = "H0STCNT0";
const TRADE_TR_ID_EXPECTED = "H0STANC0";
const TRADE_TR_ID_OVERTIME = "H0STOUP0";
const ORDERBOOK_TR_ID = "H0STASP0";
const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0";
const MAX_TRADE_TICKS = 10;
function resolveTradeTrId(
env: KisRuntimeCredentials["tradingEnv"],
session: DomesticKisSession,
) {
if (env === "mock") return TRADE_TR_ID;
if (shouldUseAfterHoursSinglePriceTr(session)) return TRADE_TR_ID_OVERTIME;
if (shouldUseExpectedExecutionTr(session)) return TRADE_TR_ID_EXPECTED;
return TRADE_TR_ID;
}
function resolveOrderBookTrId(
env: KisRuntimeCredentials["tradingEnv"],
session: DomesticKisSession,
) {
if (env === "mock") return ORDERBOOK_TR_ID;
if (shouldUseAfterHoursSinglePriceTr(session)) return ORDERBOOK_TR_ID_OVERTIME;
return ORDERBOOK_TR_ID;
}
/**
* @description Subscribes trade ticks and orderbook over one websocket.
* @see features/dashboard/components/DashboardContainer.tsx
* @see lib/kis/domestic-market-session.ts
*/
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 [recentTradeTicks, setRecentTradeTicks] = useState<
DashboardRealtimeTradeTick[]
>([]);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastTickAt, setLastTickAt] = useState<number | null>(null);
const [marketSession, setMarketSession] = useState<DomesticKisSession>(() =>
resolveSessionInClient(),
);
const socketRef = useRef<WebSocket | null>(null);
const approvalKeyRef = useRef<string | null>(null);
const seenTickRef = useRef<Set<string>>(new Set());
const obSymbol = options?.orderBookSymbol;
const onOrderBookMsg = options?.onOrderBookMessage;
const realtimeTrId = credentials
? resolveTradeTrId(credentials.tradingEnv, marketSession)
: TRADE_TR_ID;
useEffect(() => {
const timerId = window.setInterval(() => {
const nextSession = resolveSessionInClient();
setMarketSession((prev) => (prev === nextSession ? prev : nextSession));
}, 30_000);
return () => window.clearInterval(timerId);
}, []);
useEffect(() => {
if (!isConnected || lastTickAt) return;
const timer = window.setTimeout(() => {
setError(
"실시간 연결은 되었지만 체결 데이터가 없습니다. 장 시간(한국시간)과 TR ID를 확인해 주세요.",
);
}, 8000);
return () => window.clearTimeout(timer);
}, [isConnected, lastTickAt]);
useEffect(() => {
setLatestTick(null);
setRecentTradeTicks([]);
setError(null);
setLastTickAt(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 tradeTrId = resolveTradeTrId(credentials.tradingEnv, marketSession);
const orderBookTrId = obSymbol
? resolveOrderBookTrId(credentials.tradingEnv, marketSession)
: null;
const connect = async () => {
try {
setError(null);
setIsConnected(false);
const wsConnection = await useKisRuntimeStore
.getState()
.getOrFetchWsConnection();
if (!wsConnection) {
throw new Error("웹소켓 승인키 발급에 실패했습니다.");
}
if (disposed) return;
approvalKeyRef.current = wsConnection.approvalKey;
socket = new WebSocket(`${wsConnection.wsUrl}/tryitout/${tradeTrId}`);
socketRef.current = socket;
socket.onopen = () => {
if (disposed || !approvalKeyRef.current) return;
socket?.send(
JSON.stringify(
buildKisRealtimeMessage(
approvalKeyRef.current,
symbol,
tradeTrId,
"1",
),
),
);
if (obSymbol && orderBookTrId) {
socket?.send(
JSON.stringify(
buildKisRealtimeMessage(
approvalKeyRef.current,
obSymbol,
orderBookTrId,
"1",
),
),
);
}
setIsConnected(true);
};
socket.onmessage = (event) => {
if (disposed || typeof event.data !== "string") return;
if (obSymbol && onOrderBookMsg) {
const orderBook = parseKisRealtimeOrderbook(event.data, obSymbol);
if (orderBook) {
orderBook.tradingEnv = credentials.tradingEnv;
onOrderBookMsg(orderBook);
return;
}
}
const ticks = parseKisRealtimeTickBatch(event.data, symbol);
if (ticks.length === 0) return;
const meaningfulTicks = ticks.filter((tick) => tick.tradeVolume > 0);
const dedupedTicks = meaningfulTicks.filter((tick) => {
const key = `${tick.tickTime}-${tick.price}-${tick.tradeVolume}`;
if (seenTickRef.current.has(key)) return false;
seenTickRef.current.add(key);
return true;
});
const latest = ticks[ticks.length - 1];
setLatestTick(latest);
if (dedupedTicks.length > 0) {
setRecentTradeTicks((prev) =>
[...dedupedTicks.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;
return () => {
disposed = true;
setIsConnected(false);
const key = approvalKeyRef.current;
if (socket?.readyState === WebSocket.OPEN && key) {
socket.send(
JSON.stringify(buildKisRealtimeMessage(key, symbol, tradeTrId, "2")),
);
if (obSymbol && orderBookTrId) {
socket.send(
JSON.stringify(
buildKisRealtimeMessage(key, obSymbol, orderBookTrId, "2"),
),
);
}
}
socket?.close();
if (socketRef.current === socket) socketRef.current = null;
approvalKeyRef.current = null;
seenRef.clear();
};
}, [
symbol,
isVerified,
credentials,
marketSession,
onTick,
obSymbol,
onOrderBookMsg,
]);
return {
latestTick,
recentTradeTicks,
isConnected,
error,
lastTickAt,
realtimeTrId,
};
}
function resolveSessionInClient() {
if (typeof window === "undefined") {
return resolveDomesticKisSession();
}
try {
const override = window.localStorage.getItem(
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
);
return resolveDomesticKisSession(override);
} catch {
return resolveDomesticKisSession();
}
}

View File

@@ -1,9 +1,9 @@
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import type {
DashboardKisRevokeResponse,
DashboardKisValidateResponse,
DashboardKisWsApprovalResponse,
} from "@/features/dashboard/types/dashboard.types";
} from "@/features/trade/types/trade.types";
interface KisApiBaseResponse {
ok: boolean;

View File

@@ -3,11 +3,11 @@ import { useShallow } from "zustand/react/shallow";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
import {
revokeKisCredentials,
validateKisCredentials,
} from "@/features/dashboard/apis/kis-auth.api";
} from "@/features/settings/apis/kis-auth.api";
import {
KeyRound,
Shield,

View File

@@ -0,0 +1,51 @@
"use client";
import { useShallow } from "zustand/react/shallow";
import { KisAuthForm } from "@/features/settings/components/KisAuthForm";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
/**
* @description 설정 페이지 컨테이너입니다. KIS 연결 상태와 인증 폼을 카드 UI로 제공합니다.
* @see app/(main)/settings/page.tsx 로그인 확인 후 이 컴포넌트를 렌더링합니다.
* @see features/settings/components/KisAuthForm.tsx 실제 인증 입력/검증/해제를 담당합니다.
*/
export function SettingsContainer() {
// 상태 정의: 연결 상태 표시용 전역 인증 상태를 구독합니다.
const { verifiedCredentials, isKisVerified } = useKisRuntimeStore(
useShallow((state) => ({
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
})),
);
return (
<section className="mx-auto flex w-full max-w-5xl flex-col gap-5 p-4 md:p-6">
{/* ========== STATUS CARD ========== */}
<article className="rounded-2xl border border-brand-200 bg-muted/35 p-4 dark:border-brand-800/45 dark:bg-brand-900/20">
<h1 className="text-xl font-semibold tracking-tight text-foreground">
KIS API
</h1>
<div className="mt-3 flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium text-foreground"> :</span>
{isKisVerified ? (
<span className="inline-flex items-center rounded-full bg-brand-100 px-2.5 py-1 text-xs font-semibold text-brand-700 dark:bg-brand-900/45 dark:text-brand-200">
<span className="mr-1.5 h-2 w-2 rounded-full bg-brand-500" />
({verifiedCredentials?.tradingEnv === "real" ? "실전" : "모의"})
</span>
) : (
<span className="inline-flex items-center rounded-full bg-brand-50 px-2.5 py-1 text-xs font-semibold text-brand-700 dark:bg-brand-900/30 dark:text-brand-200">
<span className="mr-1.5 h-2 w-2 rounded-full bg-brand-300 dark:bg-brand-500/70" />
</span>
)}
</div>
</article>
{/* ========== AUTH FORM CARD ========== */}
<article className="rounded-2xl border border-brand-200 bg-background p-4 shadow-sm dark:border-brand-800/45 dark:bg-brand-900/14">
<KisAuthForm />
</article>
</section>
);
}

View File

@@ -1,14 +1,14 @@
"use client";
import { fetchKisWebSocketApproval } from "@/features/dashboard/apis/kis-auth.api";
import type { KisTradingEnv } from "@/features/dashboard/types/dashboard.types";
import { fetchKisWebSocketApproval } from "@/features/settings/apis/kis-auth.api";
import type { KisTradingEnv } from "@/features/trade/types/trade.types";
import { createJSONStorage, persist } from "zustand/middleware";
import { create } from "zustand";
/**
* @file features/dashboard/store/use-kis-runtime-store.ts
* @file features/settings/store/use-kis-runtime-store.ts
* @description Stores KIS input, verification, and websocket connection state.
* @see features/dashboard/hooks/useKisTradeWebSocket.ts
* @see features/trade/hooks/useKisTradeWebSocket.ts
*/
export interface KisRuntimeCredentials {
appKey: string;
@@ -34,6 +34,8 @@ interface KisRuntimeStoreState {
wsApprovalKey: string | null;
wsUrl: string | null;
_hasHydrated: boolean;
}
interface KisRuntimeStoreActions {
@@ -48,6 +50,7 @@ interface KisRuntimeStoreActions {
invalidateKisVerification: () => void;
clearKisRuntimeSession: (tradingEnv: KisTradingEnv) => void;
getOrFetchWsConnection: () => Promise<KisWsConnection | null>;
setHasHydrated: (state: boolean) => void;
}
const INITIAL_STATE: KisRuntimeStoreState = {
@@ -60,6 +63,7 @@ const INITIAL_STATE: KisRuntimeStoreState = {
tradingEnv: "real",
wsApprovalKey: null,
wsUrl: null,
_hasHydrated: false,
};
const RESET_VERIFICATION_STATE = {
@@ -73,7 +77,7 @@ let wsConnectionPromise: Promise<KisWsConnection | null> | null = null;
/**
* @description Runtime store for KIS session.
* @see features/dashboard/components/auth/KisAuthForm.tsx
* @see features/settings/components/KisAuthForm.tsx
*/
export const useKisRuntimeStore = create<
KisRuntimeStoreState & KisRuntimeStoreActions
@@ -173,10 +177,18 @@ export const useKisRuntimeStore = create<
return wsConnectionPromise;
},
setHasHydrated: (state) => {
set({
_hasHydrated: state,
});
},
}),
{
name: "autotrade-kis-runtime-store",
storage: createJSONStorage(() => localStorage),
onRehydrateStorage: () => (state) => {
state?.setHasHydrated(true);
},
partialize: (state) => ({
kisTradingEnvInput: state.kisTradingEnvInput,
kisAppKeyInput: state.kisAppKeyInput,

View File

@@ -1,4 +1,4 @@
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import type {
DashboardChartTimeframe,
DashboardStockCashOrderRequest,
@@ -7,7 +7,7 @@ import type {
DashboardStockOrderBookResponse,
DashboardStockOverviewResponse,
DashboardStockSearchResponse,
} from "@/features/dashboard/types/dashboard.types";
} from "@/features/trade/types/trade.types";
import {
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,

View File

@@ -0,0 +1,232 @@
"use client";
import { type FormEvent, useCallback, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
import { TradeAccessGate } from "@/features/trade/components/guards/TradeAccessGate";
import { TradeDashboardContent } from "@/features/trade/components/layout/TradeDashboardContent";
import { TradeSearchSection } from "@/features/trade/components/search/TradeSearchSection";
import { useStockSearch } from "@/features/trade/hooks/useStockSearch";
import { useOrderBook } from "@/features/trade/hooks/useOrderBook";
import { useKisTradeWebSocket } from "@/features/trade/hooks/useKisTradeWebSocket";
import { useStockOverview } from "@/features/trade/hooks/useStockOverview";
import { useCurrentPrice } from "@/features/trade/hooks/useCurrentPrice";
import { useTradeSearchPanel } from "@/features/trade/hooks/useTradeSearchPanel";
import type {
DashboardStockOrderBookResponse,
DashboardStockSearchItem,
} from "@/features/trade/types/trade.types";
/**
* @description 트레이딩 페이지 메인 컨테이너입니다.
* @see app/(main)/trade/page.tsx 로그인 완료 후 이 컴포넌트를 렌더링합니다.
* @see app/(main)/settings/page.tsx 미인증 상태일 때 설정 페이지로 이동하도록 안내합니다.
* @see features/trade/hooks/useStockSearch.ts 검색 입력/요청/히스토리 상태를 관리합니다.
* @see features/trade/hooks/useStockOverview.ts 선택 종목 상세 상태를 관리합니다.
*/
export function TradeContainer() {
// [State] 호가 실시간 데이터 (체결 WS와 동일 소켓에서 수신)
const [realtimeOrderBook, setRealtimeOrderBook] =
useState<DashboardStockOrderBookResponse | null>(null);
const { verifiedCredentials, isKisVerified, _hasHydrated } =
useKisRuntimeStore(
useShallow((state) => ({
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
_hasHydrated: state._hasHydrated,
})),
);
const {
keyword,
setKeyword,
searchResults,
setSearchError,
isSearching,
search,
clearSearch,
searchHistory,
appendSearchHistory,
removeSearchHistory,
clearSearchHistory,
} = useStockSearch();
const { selectedStock, loadOverview, updateRealtimeTradeTick } =
useStockOverview();
const canTrade = isKisVerified && !!verifiedCredentials;
const canSearch = canTrade;
const {
searchShellRef,
isSearchPanelOpen,
markSkipNextAutoSearch,
openSearchPanel,
closeSearchPanel,
handleSearchShellBlur,
handleSearchShellKeyDown,
} = useTradeSearchPanel({
canSearch,
keyword,
verifiedCredentials,
search,
clearSearch,
});
/**
* @description 체결 WS에서 전달받은 실시간 호가를 상태에 저장합니다.
* @see features/trade/hooks/useKisTradeWebSocket.ts onOrderBookMessage 콜백
* @see features/trade/hooks/useOrderBook.ts externalRealtimeOrderBook 주입
*/
const handleOrderBookMessage = useCallback(
(data: DashboardStockOrderBookResponse) => {
setRealtimeOrderBook(data);
},
[],
);
// 1. Trade WebSocket (체결 + 호가 통합)
const { latestTick, 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,
},
);
// 3. Price Calculation Logic (Hook)
const {
currentPrice,
change,
changeRate,
prevClose: referencePrice,
} = useCurrentPrice({
stock: selectedStock,
latestTick,
orderBook,
});
/**
* @description 검색 전 API 인증 여부를 확인합니다.
* @see features/trade/components/search/StockSearchForm.tsx 검색 제출 전 공통 가드로 사용합니다.
*/
const ensureSearchReady = useCallback(() => {
if (canSearch) return true;
setSearchError("API 키 검증을 먼저 완료해 주세요.");
return false;
}, [canSearch, setSearchError]);
/**
* @description 수동 검색 버튼(엔터 포함) 제출 이벤트를 처리합니다.
* @see features/trade/components/search/StockSearchForm.tsx onSubmit 이벤트로 호출됩니다.
*/
const handleSearchSubmit = useCallback(
(event: FormEvent) => {
event.preventDefault();
if (!ensureSearchReady() || !verifiedCredentials) return;
search(keyword, verifiedCredentials);
},
[ensureSearchReady, keyword, search, verifiedCredentials],
);
/**
* @description 검색 결과/히스토리에서 종목을 선택하면 종목 상세를 로드하고 히스토리를 갱신합니다.
* @see features/trade/components/search/StockSearchResults.tsx onSelect 이벤트
* @see features/trade/components/search/StockSearchHistory.tsx onSelect 이벤트
*/
const handleSelectStock = useCallback(
(item: DashboardStockSearchItem) => {
if (!ensureSearchReady() || !verifiedCredentials) return;
// 같은 종목 재선택 시 중복 overview 요청을 막고 검색 목록만 닫습니다.
if (selectedStock?.symbol === item.symbol) {
clearSearch();
closeSearchPanel();
return;
}
// 카드 선택으로 keyword가 바뀔 때 자동 검색이 다시 실행되지 않게 한 번 건너뜁니다.
markSkipNextAutoSearch();
setKeyword(item.name);
clearSearch();
closeSearchPanel();
appendSearchHistory(item);
loadOverview(item.symbol, verifiedCredentials, item.market);
},
[
ensureSearchReady,
verifiedCredentials,
selectedStock?.symbol,
clearSearch,
closeSearchPanel,
setKeyword,
appendSearchHistory,
loadOverview,
markSkipNextAutoSearch,
],
);
if (!_hasHydrated) {
return (
<div className="flex h-full items-center justify-center p-6">
<LoadingSpinner />
</div>
);
}
if (!canTrade) {
return <TradeAccessGate canTrade={canTrade} />;
}
return (
<div className="relative h-full flex flex-col">
{/* ========== SEARCH SECTION ========== */}
<TradeSearchSection
canSearch={canSearch}
isSearchPanelOpen={isSearchPanelOpen}
isSearching={isSearching}
keyword={keyword}
selectedSymbol={selectedStock?.symbol}
searchResults={searchResults}
searchHistory={searchHistory}
searchShellRef={searchShellRef}
onKeywordChange={setKeyword}
onSearchSubmit={handleSearchSubmit}
onSearchFocus={openSearchPanel}
onSearchShellBlur={handleSearchShellBlur}
onSearchShellKeyDown={handleSearchShellKeyDown}
onSelectStock={handleSelectStock}
onRemoveHistory={removeSearchHistory}
onClearHistory={clearSearchHistory}
/>
{/* ========== DASHBOARD SECTION ========== */}
<TradeDashboardContent
selectedStock={selectedStock}
verifiedCredentials={verifiedCredentials}
latestTick={latestTick}
recentTradeTicks={recentTradeTicks}
orderBook={orderBook}
isOrderBookLoading={isOrderBookLoading}
referencePrice={referencePrice}
currentPrice={currentPrice}
change={change}
changeRate={changeRate}
/>
</div>
);
}

View File

@@ -13,13 +13,13 @@ import {
import { ChevronDown } from "lucide-react";
import { useTheme } from "next-themes";
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 { fetchStockChart } from "@/features/trade/apis/kis-stock.api";
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import type {
DashboardChartTimeframe,
DashboardRealtimeTradeTick,
StockCandlePoint,
} from "@/features/dashboard/types/dashboard.types";
} from "@/features/trade/types/trade.types";
import { cn } from "@/lib/utils";
import {
type ChartBar,
@@ -119,7 +119,7 @@ interface StockLineChartProps {
/**
* @description TradingView , timeframe별 KIS API를 .
* @see features/dashboard/apis/kis-stock.api.ts fetchStockChart
* @see features/trade/apis/kis-stock.api.ts fetchStockChart
* @see lib/kis/domestic.ts getDomesticChart
*/
export function StockLineChart({
@@ -199,7 +199,7 @@ export function StockLineChart({
/**
* @description lightweight-charts OHLCV .
* @see features/dashboard/components/chart/StockLineChart.tsx renderableBars useMemo
* @see features/trade/components/chart/StockLineChart.tsx renderableBars useMemo
*/
const setSeriesData = useCallback((nextBars: ChartBar[]) => {
const candleSeries = candleSeriesRef.current;
@@ -524,8 +524,8 @@ export function StockLineChart({
/**
* @description WebSocket timeframe .
* @see features/dashboard/hooks/useKisTradeWebSocket.ts latestTick
* @see features/dashboard/components/chart/chart-utils.ts toRealtimeTickBar
* @see features/trade/hooks/useKisTradeWebSocket.ts latestTick
* @see features/trade/components/chart/chart-utils.ts toRealtimeTickBar
*/
useEffect(() => {
if (!latestTick) return;
@@ -544,7 +544,7 @@ export function StockLineChart({
/**
* @description (1m/30m/1h) WS .
* @see features/dashboard/apis/kis-stock.api.ts fetchStockChart
* @see features/trade/apis/kis-stock.api.ts fetchStockChart
* @see lib/kis/domestic.ts getDomesticChart
*/
useEffect(() => {

View File

@@ -12,7 +12,7 @@ import type {
DashboardChartTimeframe,
DashboardRealtimeTradeTick,
StockCandlePoint,
} from "@/features/dashboard/types/dashboard.types";
} from "@/features/trade/types/trade.types";
const KRW_FORMATTER = new Intl.NumberFormat("ko-KR");
const KST_TIME_ZONE = "Asia/Seoul";
@@ -228,8 +228,8 @@ export function upsertRealtimeBar(
/**
* @description ChartBar로 . (KST + tickTime )
* @see features/dashboard/hooks/useKisTradeWebSocket.ts latestTick
* @see features/dashboard/components/chart/StockLineChart.tsx
* @see features/trade/hooks/useKisTradeWebSocket.ts latestTick
* @see features/trade/components/chart/StockLineChart.tsx
*/
export function toRealtimeTickBar(
tick: DashboardRealtimeTradeTick,
@@ -260,7 +260,7 @@ export function toRealtimeTickBar(
/**
* @description lightweight-charts X축 KST .
* @see features/dashboard/components/chart/StockLineChart.tsx createChart options.timeScale.tickMarkFormatter
* @see features/trade/components/chart/StockLineChart.tsx createChart options.timeScale.tickMarkFormatter
*/
export function formatKstTickMark(time: Time, tickMarkType: TickMarkType) {
const date = toDateFromChartTime(time);
@@ -275,7 +275,7 @@ export function formatKstTickMark(time: Time, tickMarkType: TickMarkType) {
/**
* @description crosshair KST로 .
* @see features/dashboard/components/chart/StockLineChart.tsx createChart options.localization.timeFormatter
* @see features/trade/components/chart/StockLineChart.tsx createChart options.localization.timeFormatter
*/
export function formatKstCrosshairTime(time: Time) {
const date = toDateFromChartTime(time);

View File

@@ -6,13 +6,13 @@ import {
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { StockLineChart } from "@/features/dashboard/components/chart/StockLineChart";
import { StockPriceBadge } from "@/features/dashboard/components/details/StockPriceBadge";
import { StockLineChart } from "@/features/trade/components/chart/StockLineChart";
import { StockPriceBadge } from "@/features/trade/components/details/StockPriceBadge";
import type {
DashboardStockItem,
DashboardPriceSource,
DashboardMarketPhase,
} from "@/features/dashboard/types/dashboard.types";
} from "@/features/trade/types/trade.types";
const PRICE_FORMATTER = new Intl.NumberFormat("ko-KR");
function formatVolume(value: number) {

View File

@@ -0,0 +1,36 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
interface TradeAccessGateProps {
canTrade: boolean;
}
/**
* @description KIS 인증 여부에 따라 트레이드 화면 접근 가이드를 렌더링합니다.
* @see features/trade/components/TradeContainer.tsx TradeContainer의 인증 가드 UI를 분리합니다.
* @see app/(main)/settings/page.tsx 미인증 사용자를 설정 페이지로 이동시킵니다.
*/
export function TradeAccessGate({ canTrade }: TradeAccessGateProps) {
if (canTrade) return null;
return (
<div className="flex h-full items-center justify-center p-6">
<section className="w-full max-w-xl rounded-2xl border border-brand-200 bg-background p-6 shadow-sm dark:border-brand-800/45 dark:bg-brand-900/18">
{/* ========== UNVERIFIED NOTICE ========== */}
<h2 className="text-lg font-semibold text-foreground">
KIS API .
</h2>
<p className="mt-2 text-sm text-muted-foreground">
App Key/App Secret을 .
</p>
{/* ========== ACTION ========== */}
<div className="mt-4">
<Button asChild className="bg-brand-600 hover:bg-brand-700">
<Link href="/settings"> </Link>
</Button>
</div>
</section>
</div>
);
}

View File

@@ -1,6 +1,6 @@
// import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { DashboardStockItem } from "@/features/dashboard/types/dashboard.types";
import { DashboardStockItem } from "@/features/trade/types/trade.types";
import { cn } from "@/lib/utils";
interface StockHeaderProps {

View File

@@ -0,0 +1,99 @@
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import { StockLineChart } from "@/features/trade/components/chart/StockLineChart";
import { StockHeader } from "@/features/trade/components/header/StockHeader";
import { DashboardLayout } from "@/features/trade/components/layout/DashboardLayout";
import { OrderForm } from "@/features/trade/components/order/OrderForm";
import { OrderBook } from "@/features/trade/components/orderbook/OrderBook";
import type {
DashboardRealtimeTradeTick,
DashboardStockItem,
DashboardStockOrderBookResponse,
} from "@/features/trade/types/trade.types";
import { cn } from "@/lib/utils";
interface TradeDashboardContentProps {
selectedStock: DashboardStockItem | null;
verifiedCredentials: KisRuntimeCredentials | null;
latestTick: DashboardRealtimeTradeTick | null;
recentTradeTicks: DashboardRealtimeTradeTick[];
orderBook: DashboardStockOrderBookResponse | null;
isOrderBookLoading: boolean;
referencePrice?: number;
currentPrice?: number;
change?: number;
changeRate?: number;
}
/**
* @description 트레이드 본문(헤더/차트/호가/주문)을 조합해서 렌더링합니다.
* @see features/trade/components/TradeContainer.tsx TradeContainer가 화면 조합 코드를 단순화하기 위해 사용합니다.
* @see features/trade/components/layout/DashboardLayout.tsx 실제 4분할 레이아웃은 DashboardLayout에서 처리합니다.
*/
export function TradeDashboardContent({
selectedStock,
verifiedCredentials,
latestTick,
recentTradeTicks,
orderBook,
isOrderBookLoading,
referencePrice,
currentPrice,
change,
changeRate,
}: TradeDashboardContentProps) {
return (
<div
className={cn(
"transition-opacity duration-200 h-full flex-1 min-h-0 overflow-x-hidden",
!selectedStock && "opacity-20 pointer-events-none",
)}
>
{/* ========== DASHBOARD LAYOUT ========== */}
<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}
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={selectedStock.candles}
credentials={verifiedCredentials}
latestTick={latestTick}
/>
</div>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
</div>
)
}
orderBook={
<OrderBook
symbol={selectedStock?.symbol}
referencePrice={referencePrice}
currentPrice={currentPrice}
latestTick={latestTick}
recentTicks={recentTradeTicks}
orderBook={orderBook}
isLoading={isOrderBookLoading}
/>
}
orderForm={<OrderForm stock={selectedStock ?? undefined} />}
/>
</div>
);
}

View File

@@ -3,12 +3,12 @@ import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useOrder } from "@/features/dashboard/hooks/useOrder";
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
import { useOrder } from "@/features/trade/hooks/useOrder";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
import type {
DashboardOrderSide,
DashboardStockItem,
} from "@/features/dashboard/types/dashboard.types";
} from "@/features/trade/types/trade.types";
interface OrderFormProps {
stock?: DashboardStockItem;
@@ -16,8 +16,8 @@ interface OrderFormProps {
/**
* @description / .
* @see features/dashboard/hooks/useOrder.ts placeOrder - API
* @see features/dashboard/components/DashboardContainer.tsx OrderForm -
* @see features/trade/hooks/useOrder.ts placeOrder - API
* @see features/trade/components/TradeContainer.tsx OrderForm -
*/
export function OrderForm({ stock }: OrderFormProps) {
const verifiedCredentials = useKisRuntimeStore(
@@ -161,7 +161,7 @@ export function OrderForm({ stock }: OrderFormProps) {
/**
* @description (//) .
* @see features/dashboard/components/order/OrderForm.tsx OrderForm - /
* @see features/trade/components/order/OrderForm.tsx OrderForm - /
*/
function OrderInputs({
type,
@@ -236,7 +236,7 @@ function OrderInputs({
/**
* @description (10/25/50/100%) .
* @see features/dashboard/components/order/OrderForm.tsx setPercent -
* @see features/trade/components/order/OrderForm.tsx setPercent -
*/
function PercentButtons({ onSelect }: { onSelect: (pct: string) => void }) {
return (

View File

@@ -5,7 +5,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import type {
DashboardRealtimeTradeTick,
DashboardStockOrderBookResponse,
} from "@/features/dashboard/types/dashboard.types";
} from "@/features/trade/types/trade.types";
import { cn } from "@/lib/utils";
import { AnimatedQuantity } from "./AnimatedQuantity";
@@ -51,6 +51,52 @@ function fmtTime(hms: string) {
return `${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}`;
}
/**
* @description (//) .
* @see features/trade/components/orderbook/OrderBook.tsx TradeTape .
*/
function resolveTickExecutionSide(
tick: DashboardRealtimeTradeTick,
olderTick?: DashboardRealtimeTradeTick,
) {
// 실시간 체결구분 코드(CNTG_CLS_CODE) 우선 해석
const executionClassCode = (tick.executionClassCode ?? "").trim();
if (executionClassCode === "1" || executionClassCode === "2") {
return "buy" as const;
}
if (executionClassCode === "4" || executionClassCode === "5") {
return "sell" as const;
}
// 누적 건수 기반 데이터는 절대값이 아니라 "증분"으로 판단해야 편향을 줄일 수 있습니다.
if (olderTick) {
const netBuyDelta =
tick.netBuyExecutionCount - olderTick.netBuyExecutionCount;
if (netBuyDelta > 0) return "buy" as const;
if (netBuyDelta < 0) return "sell" as const;
const buyCountDelta = tick.buyExecutionCount - olderTick.buyExecutionCount;
const sellCountDelta =
tick.sellExecutionCount - olderTick.sellExecutionCount;
if (buyCountDelta > sellCountDelta) return "buy" as const;
if (buyCountDelta < sellCountDelta) return "sell" as const;
}
if (tick.askPrice1 > 0 && tick.bidPrice1 > 0) {
if (tick.price >= tick.askPrice1 && tick.price > tick.bidPrice1) {
return "buy" as const;
}
if (tick.price <= tick.bidPrice1 && tick.price < tick.askPrice1) {
return "sell" as const;
}
}
if (tick.tradeStrength > 100) return "buy" as const;
if (tick.tradeStrength < 100) return "sell" as const;
return "neutral" as const;
}
// ─── 메인 컴포넌트 ──────────────────────────────────────
/**
@@ -305,13 +351,17 @@ function BookSideRows({
{isAsk && (
<>
<DepthBar ratio={ratio} side="ask" />
<AnimatedQuantity
value={row.size}
format={fmt}
useColor
side="ask"
className="relative z-10"
/>
{row.size > 0 ? (
<AnimatedQuantity
value={row.size}
format={fmt}
useColor
side="ask"
className="relative z-10"
/>
) : (
<span className="relative z-10 text-transparent">0</span>
)}
</>
)}
</div>
@@ -350,13 +400,17 @@ function BookSideRows({
{!isAsk && (
<>
<DepthBar ratio={ratio} side="bid" />
<AnimatedQuantity
value={row.size}
format={fmt}
useColor
side="bid"
className="relative z-10"
/>
{row.size > 0 ? (
<AnimatedQuantity
value={row.size}
format={fmt}
useColor
side="bid"
className="relative z-10"
/>
) : (
<span className="relative z-10 text-transparent">0</span>
)}
</>
)}
</div>
@@ -506,25 +560,42 @@ function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
.
</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 dark:border-brand-800/35"
>
<div className="flex items-center tabular-nums">
{fmtTime(t.tickTime)}
{ticks.map((t, i) => {
const olderTick = ticks[i + 1];
const executionSide = resolveTickExecutionSide(t, olderTick);
// UI 흐름: 체결목록 UI -> resolveTickExecutionSide(현재/이전 틱) -> 색상 class 결정 -> 체결량 렌더 반영
const volumeToneClass =
executionSide === "buy"
? "text-red-600"
: executionSide === "sell"
? "text-blue-600 dark:text-blue-400"
: "text-muted-foreground dark:text-brand-100/70";
return (
<div
key={`${t.tickTime}-${t.price}-${i}`}
className="grid h-8 grid-cols-4 border-b border-border/40 px-2 text-xs dark:border-brand-800/35"
>
<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={cn(
"flex items-center justify-end tabular-nums",
volumeToneClass,
)}
>
{fmt(t.tradeVolume)}
</div>
<div className="flex items-center justify-end tabular-nums">
{t.tradeStrength.toFixed(2)}%
</div>
</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 dark:text-blue-400">
{fmt(t.tradeVolume)}
</div>
<div className="flex items-center justify-end tabular-nums">
{t.tradeStrength.toFixed(2)}%
</div>
</div>
))}
);
})}
</div>
</ScrollArea>
</div>

View File

@@ -14,7 +14,7 @@ interface StockSearchFormProps {
/**
* @description / .
* @see features/dashboard/components/DashboardContainer.tsx .
* @see features/trade/components/TradeContainer.tsx .
*/
export function StockSearchForm({
keyword,

View File

@@ -1,7 +1,7 @@
import { Clock3, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { DashboardStockSearchHistoryItem } from "@/features/dashboard/types/dashboard.types";
import type { DashboardStockSearchHistoryItem } from "@/features/trade/types/trade.types";
interface StockSearchHistoryProps {
items: DashboardStockSearchHistoryItem[];
@@ -13,8 +13,8 @@ interface StockSearchHistoryProps {
/**
* @description , // .
* @see features/dashboard/components/DashboardContainer.tsx UI로 .
* @see features/dashboard/hooks/useStockSearch.ts searchHistory .
* @see features/trade/components/TradeContainer.tsx UI로 .
* @see features/trade/hooks/useStockSearch.ts searchHistory .
*/
export function StockSearchHistory({
items,

View File

@@ -1,7 +1,7 @@
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";
import type { DashboardStockSearchItem } from "@/features/trade/types/trade.types";
interface StockSearchResultsProps {
items: DashboardStockSearchItem[];

View File

@@ -0,0 +1,101 @@
import type { FormEvent, KeyboardEvent, FocusEvent, MutableRefObject } from "react";
import { StockSearchForm } from "@/features/trade/components/search/StockSearchForm";
import { StockSearchHistory } from "@/features/trade/components/search/StockSearchHistory";
import { StockSearchResults } from "@/features/trade/components/search/StockSearchResults";
import type {
DashboardStockSearchHistoryItem,
DashboardStockSearchItem,
} from "@/features/trade/types/trade.types";
interface TradeSearchSectionProps {
canSearch: boolean;
isSearchPanelOpen: boolean;
isSearching: boolean;
keyword: string;
selectedSymbol?: string;
searchResults: DashboardStockSearchItem[];
searchHistory: DashboardStockSearchHistoryItem[];
searchShellRef: MutableRefObject<HTMLDivElement | null>;
onKeywordChange: (value: string) => void;
onSearchSubmit: (event: FormEvent) => void;
onSearchFocus: () => void;
onSearchShellBlur: (event: FocusEvent<HTMLDivElement>) => void;
onSearchShellKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
onSelectStock: (item: DashboardStockSearchItem) => void;
onRemoveHistory: (symbol: string) => void;
onClearHistory: () => void;
}
/**
* @description 트레이드 화면 상단의 검색 입력/결과/히스토리 드롭다운 영역을 렌더링합니다.
* @see features/trade/components/TradeContainer.tsx TradeContainer에서 검색 섹션을 분리해 렌더 복잡도를 줄입니다.
* @see features/trade/hooks/useTradeSearchPanel.ts 패널 열림/닫힘 및 포커스 핸들러를 전달받습니다.
*/
export function TradeSearchSection({
canSearch,
isSearchPanelOpen,
isSearching,
keyword,
selectedSymbol,
searchResults,
searchHistory,
searchShellRef,
onKeywordChange,
onSearchSubmit,
onSearchFocus,
onSearchShellBlur,
onSearchShellKeyDown,
onSelectStock,
onRemoveHistory,
onClearHistory,
}: TradeSearchSectionProps) {
return (
<div className="z-30 flex-none border-b bg-background/95 p-4 backdrop-blur-sm dark:border-brand-800/45 dark:bg-brand-900/22">
{/* ========== SEARCH SHELL ========== */}
<div
ref={searchShellRef}
onBlurCapture={onSearchShellBlur}
onKeyDownCapture={onSearchShellKeyDown}
className="relative mx-auto max-w-2xl"
>
<StockSearchForm
keyword={keyword}
onKeywordChange={onKeywordChange}
onSubmit={onSearchSubmit}
onInputFocus={onSearchFocus}
disabled={!canSearch}
isLoading={isSearching}
/>
{/* ========== SEARCH DROPDOWN ========== */}
{isSearchPanelOpen && canSearch && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-80 overflow-x-hidden overflow-y-auto rounded-md border bg-background shadow-lg dark:border-brand-800/45 dark:bg-brand-950/95">
{searchResults.length > 0 ? (
<StockSearchResults
items={searchResults}
onSelect={onSelectStock}
selectedSymbol={selectedSymbol}
/>
) : keyword.trim() ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
{isSearching ? "검색 중..." : "검색 결과가 없습니다."}
</div>
) : searchHistory.length > 0 ? (
<StockSearchHistory
items={searchHistory}
onSelect={onSelectStock}
onRemove={onRemoveHistory}
onClear={onClearHistory}
selectedSymbol={selectedSymbol}
/>
) : (
<div className="px-3 py-2 text-sm text-muted-foreground">
.
</div>
)}
</div>
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import rawStocks from "@/features/dashboard/data/korean-stocks.json";
import type { KoreanStockIndexItem } from "@/features/dashboard/types/dashboard.types";
import rawStocks from "@/features/trade/data/korean-stocks.json";
import type { KoreanStockIndexItem } from "@/features/trade/types/trade.types";
/**
* (KOSPI + KOSDAQ)

View File

@@ -1,5 +1,5 @@
/**
* @file features/dashboard/data/mock-stocks.ts
* @file features/trade/data/mock-stocks.ts
* @description 1 UI
* @remarks
* - API .
@@ -7,12 +7,12 @@
* - / .
*/
import type { DashboardStockItem } from "@/features/dashboard/types/dashboard.types";
import type { DashboardStockItem } from "@/features/trade/types/trade.types";
/**
*
* @see app/(main)/dashboard/page.tsx DashboardPage가 DashboardMain을 .
* @see features/dashboard/components/dashboard-main.tsx // .
* @see features/trade/components/dashboard-main.tsx // .
*/
export const MOCK_STOCKS: DashboardStockItem[] = [
{

View File

@@ -3,7 +3,7 @@ import type {
DashboardRealtimeTradeTick,
DashboardStockItem,
DashboardStockOrderBookResponse,
} from "@/features/dashboard/types/dashboard.types";
} from "@/features/trade/types/trade.types";
interface UseCurrentPriceParams {
stock?: DashboardStockItem | null;

View File

@@ -0,0 +1,488 @@
import { useEffect, useRef, useState } from "react";
import {
type KisRuntimeCredentials,
useKisRuntimeStore,
} from "@/features/settings/store/use-kis-runtime-store";
import type {
DashboardRealtimeTradeTick,
DashboardStockOrderBookResponse,
} from "@/features/trade/types/trade.types";
import {
buildKisRealtimeMessage,
parseKisRealtimeOrderbook,
parseKisRealtimeTickBatch,
} from "@/features/trade/utils/kis-realtime.utils";
import {
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
resolveDomesticKisSession,
shouldUseAfterHoursSinglePriceTr,
shouldUseExpectedExecutionTr,
type DomesticKisSession,
} from "@/lib/kis/domestic-market-session";
const TRADE_TR_ID = "H0STCNT0";
const TRADE_TR_ID_EXPECTED = "H0STANC0";
const TRADE_TR_ID_OVERTIME = "H0STOUP0";
const TRADE_TR_ID_OVERTIME_EXPECTED = "H0STOAC0";
const TRADE_TR_ID_TOTAL = "H0UNCNT0";
const TRADE_TR_ID_TOTAL_EXPECTED = "H0UNANC0";
const ORDERBOOK_TR_ID = "H0STASP0";
const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0";
const MAX_TRADE_TICKS = 10;
const WS_DEBUG_STORAGE_KEY = "KIS_WS_DEBUG";
/**
* @description 장 구간/시장별 누락을 줄이기 위해 TR ID를 우선순위 배열로 반환합니다.
* @see features/trade/hooks/useKisTradeWebSocket.ts useEffect WebSocket 구독 등록
* @see temp-kis-domestic-functions-ws.py ccnl_krx/ccnl_total/exp_ccnl_krx/exp_ccnl_total/overtime_ccnl_krx
*/
function resolveTradeTrIds(
env: KisRuntimeCredentials["tradingEnv"],
session: DomesticKisSession,
) {
if (env === "mock") return [TRADE_TR_ID];
if (shouldUseAfterHoursSinglePriceTr(session)) {
// 시간외 단일가(16:00~18:00): 전용 TR + 통합 TR 백업
return uniqueTrIds([
TRADE_TR_ID_OVERTIME,
TRADE_TR_ID_OVERTIME_EXPECTED,
TRADE_TR_ID_TOTAL,
TRADE_TR_ID_TOTAL_EXPECTED,
]);
}
if (shouldUseExpectedExecutionTr(session)) {
// 동시호가 구간(장전/장마감): 예상체결 TR을 우선, 일반체결/통합체결을 백업
return uniqueTrIds([
TRADE_TR_ID_EXPECTED,
TRADE_TR_ID_TOTAL_EXPECTED,
TRADE_TR_ID,
TRADE_TR_ID_TOTAL,
]);
}
if (session === "afterCloseFixedPrice") {
// 시간외 종가(15:40~16:00): 브로커별 라우팅 차이를 대비해 일반/시간외/통합 TR을 함께 구독
return uniqueTrIds([
TRADE_TR_ID,
TRADE_TR_ID_TOTAL,
TRADE_TR_ID_OVERTIME,
TRADE_TR_ID_OVERTIME_EXPECTED,
TRADE_TR_ID_TOTAL_EXPECTED,
]);
}
return uniqueTrIds([TRADE_TR_ID, TRADE_TR_ID_TOTAL]);
}
/**
* @description 장 구간별 호가 TR ID 후보를 우선순위 배열로 반환합니다.
* @see features/trade/hooks/useKisTradeWebSocket.ts useEffect WebSocket 구독 등록
* @see temp-kis-domestic-functions-ws.py asking_price_krx/asking_price_total/overtime_asking_price_krx
*/
function resolveOrderBookTrIds(
env: KisRuntimeCredentials["tradingEnv"],
session: DomesticKisSession,
) {
if (env === "mock") return [ORDERBOOK_TR_ID];
if (shouldUseAfterHoursSinglePriceTr(session)) {
// 시간외 단일가(16:00~18:00)는 KRX 전용 호가 TR만 구독합니다.
// 통합 TR(H0UNASP0)을 같이 구독하면 종목별로 포맷/잔량이 섞여 보일 수 있습니다.
return uniqueTrIds([ORDERBOOK_TR_ID_OVERTIME]);
}
if (session === "afterCloseFixedPrice") {
return uniqueTrIds([ORDERBOOK_TR_ID_OVERTIME]);
}
// UI 흐름: 호가창 UI -> useKisTradeWebSocket onmessage -> onOrderBookMessage
// -> TradeContainer setRealtimeOrderBook -> useOrderBook 병합 -> OrderBook 렌더
// 장중에는 KRX 전용(H0STASP0)만 구독해 값이 번갈아 덮이는 현상을 방지합니다.
return uniqueTrIds([ORDERBOOK_TR_ID]);
}
/**
* @description 콘솔 디버그 플래그를 확인합니다.
* @see features/trade/hooks/useKisTradeWebSocket.ts socket.onmessage
*/
function isWsDebugEnabled() {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(WS_DEBUG_STORAGE_KEY) === "1";
} catch {
return false;
}
}
/**
* @description 실시간 웹소켓 제어(JSON) 메시지를 파싱합니다.
* @see features/trade/hooks/useKisTradeWebSocket.ts socket.onmessage
*/
function parseWsControlMessage(raw: string) {
if (!raw.startsWith("{")) return null;
try {
return JSON.parse(raw) as {
header?: { tr_id?: string };
body?: { rt_cd?: string; msg1?: string };
};
} catch {
return null;
}
}
/**
* @description 실시간 원문에서 파이프 구분 TR ID를 빠르게 추출합니다.
* @see features/trade/hooks/useKisTradeWebSocket.ts socket.onmessage
*/
function peekPipeTrId(raw: string) {
const parts = raw.split("|");
return parts.length > 1 ? parts[1] : "";
}
function uniqueTrIds(ids: string[]) {
return [...new Set(ids)];
}
/**
* @description Subscribes trade ticks and orderbook over one websocket.
* @see features/trade/components/TradeContainer.tsx
* @see lib/kis/domestic-market-session.ts
*/
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 [recentTradeTicks, setRecentTradeTicks] = useState<
DashboardRealtimeTradeTick[]
>([]);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastTickAt, setLastTickAt] = useState<number | null>(null);
const [marketSession, setMarketSession] = useState<DomesticKisSession>(() =>
resolveSessionInClient(),
);
const socketRef = useRef<WebSocket | null>(null);
const approvalKeyRef = useRef<string | null>(null);
const seenTickRef = useRef<Set<string>>(new Set());
const obSymbol = options?.orderBookSymbol;
const onOrderBookMsg = options?.onOrderBookMessage;
const realtimeTrIds = credentials
? resolveTradeTrIds(credentials.tradingEnv, marketSession)
: [TRADE_TR_ID];
const realtimeTrId = credentials
? realtimeTrIds[0] ?? TRADE_TR_ID
: TRADE_TR_ID;
useEffect(() => {
const timerId = window.setInterval(() => {
const nextSession = resolveSessionInClient();
setMarketSession((prev) => (prev === nextSession ? prev : nextSession));
}, 30_000);
return () => window.clearInterval(timerId);
}, []);
useEffect(() => {
if (!isConnected || lastTickAt) return;
const timer = window.setTimeout(() => {
setError(
"실시간 연결은 되었지만 체결 데이터가 없습니다. 장 시간(한국시간)과 TR ID를 확인해 주세요.",
);
}, 8000);
return () => window.clearTimeout(timer);
}, [isConnected, lastTickAt]);
useEffect(() => {
setLatestTick(null);
setRecentTradeTicks([]);
setError(null);
setLastTickAt(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 debugEnabled = isWsDebugEnabled();
const tradeTrIds = resolveTradeTrIds(credentials.tradingEnv, marketSession);
const orderBookTrIds =
obSymbol && onOrderBookMsg
? resolveOrderBookTrIds(credentials.tradingEnv, marketSession)
: [];
const subscribe = (
key: string,
targetSymbol: string,
trId: string,
trType: "1" | "2",
) => {
socket?.send(
JSON.stringify(buildKisRealtimeMessage(key, targetSymbol, trId, trType)),
);
};
const connect = async () => {
try {
setError(null);
setIsConnected(false);
const wsConnection = await useKisRuntimeStore
.getState()
.getOrFetchWsConnection();
if (!wsConnection) {
throw new Error("웹소켓 승인키 발급에 실패했습니다.");
}
if (disposed) return;
approvalKeyRef.current = wsConnection.approvalKey;
// 공식 샘플과 동일하게 /tryitout 엔드포인트로 연결하고, TR은 payload로 구독합니다.
socket = new WebSocket(`${wsConnection.wsUrl}/tryitout`);
socketRef.current = socket;
socket.onopen = () => {
if (disposed || !approvalKeyRef.current) return;
for (const trId of tradeTrIds) {
subscribe(approvalKeyRef.current, symbol, trId, "1");
}
if (obSymbol) {
for (const trId of orderBookTrIds) {
subscribe(approvalKeyRef.current, obSymbol, trId, "1");
}
}
if (debugEnabled) {
console.info("[KisRealtime] Subscribed", {
symbol,
marketSession,
tradeTrIds,
orderBookSymbol: obSymbol ?? null,
orderBookTrIds,
});
}
setIsConnected(true);
};
socket.onmessage = (event) => {
if (disposed || typeof event.data !== "string") return;
const control = parseWsControlMessage(event.data);
if (control) {
const trId = control.header?.tr_id ?? "";
if (trId === "PINGPONG") {
// 서버 Keepalive에 응답하지 않으면 연결이 끊길 수 있습니다.
socket?.send(event.data);
return;
}
if (debugEnabled) {
console.info("[KisRealtime] Control", {
trId,
rt_cd: control.body?.rt_cd,
message: control.body?.msg1,
});
}
return;
}
if (obSymbol && onOrderBookMsg) {
const orderBook = parseKisRealtimeOrderbook(event.data, obSymbol);
if (orderBook) {
orderBook.tradingEnv = credentials.tradingEnv;
if (debugEnabled) {
console.debug("[KisRealtime] OrderBook", {
trId: peekPipeTrId(event.data),
symbol: orderBook.symbol,
businessHour: orderBook.businessHour,
hourClassCode: orderBook.hourClassCode,
});
}
onOrderBookMsg(orderBook);
return;
}
}
const ticks = parseKisRealtimeTickBatch(event.data, symbol);
if (ticks.length === 0) {
if (debugEnabled && event.data.includes("|")) {
console.debug("[KisRealtime] Unparsed payload", {
trId: peekPipeTrId(event.data),
preview: event.data.slice(0, 220),
});
}
return;
}
const meaningfulTicks = ticks.filter((tick) => tick.tradeVolume > 0);
if (meaningfulTicks.length === 0) {
if (debugEnabled) {
console.debug("[KisRealtime] Ignored zero-volume ticks", {
trId: peekPipeTrId(event.data),
parsedCount: ticks.length,
});
}
return;
}
const dedupedTicks = meaningfulTicks.filter((tick) => {
const key = `${tick.tickTime}-${tick.price}-${tick.tradeVolume}`;
if (seenTickRef.current.has(key)) return false;
seenTickRef.current.add(key);
if (seenTickRef.current.size > 5_000) {
seenTickRef.current.clear();
}
return true;
});
const latest = meaningfulTicks[meaningfulTicks.length - 1];
setLatestTick(latest);
if (debugEnabled) {
console.debug("[KisRealtime] Tick", {
trId: peekPipeTrId(event.data),
symbol: latest.symbol,
tickTime: latest.tickTime,
price: latest.price,
tradeVolume: latest.tradeVolume,
executionClassCode: latest.executionClassCode,
buyExecutionCount: latest.buyExecutionCount,
sellExecutionCount: latest.sellExecutionCount,
netBuyExecutionCount: latest.netBuyExecutionCount,
parsedCount: ticks.length,
});
}
if (dedupedTicks.length > 0) {
setRecentTradeTicks((prev) =>
[...dedupedTicks.reverse(), ...prev].slice(0, MAX_TRADE_TICKS),
);
}
setError(null);
setLastTickAt(Date.now());
onTick?.(latest);
};
socket.onerror = () => {
if (!disposed) {
if (debugEnabled) {
console.warn("[KisRealtime] WebSocket error", {
symbol,
marketSession,
tradeTrIds,
});
}
setIsConnected(false);
}
};
socket.onclose = () => {
if (!disposed) {
if (debugEnabled) {
console.warn("[KisRealtime] WebSocket closed", {
symbol,
marketSession,
tradeTrIds,
});
}
setIsConnected(false);
}
};
} catch (err) {
if (disposed) return;
setError(
err instanceof Error
? err.message
: "실시간 웹소켓 초기화 중 오류가 발생했습니다.",
);
setIsConnected(false);
}
};
void connect();
const seenRef = seenTickRef.current;
return () => {
disposed = true;
setIsConnected(false);
const key = approvalKeyRef.current;
if (socket?.readyState === WebSocket.OPEN && key) {
for (const trId of tradeTrIds) {
subscribe(key, symbol, trId, "2");
}
if (obSymbol) {
for (const trId of orderBookTrIds) {
subscribe(key, obSymbol, trId, "2");
}
}
}
socket?.close();
if (socketRef.current === socket) socketRef.current = null;
approvalKeyRef.current = null;
seenRef.clear();
};
}, [
symbol,
isVerified,
credentials,
marketSession,
onTick,
obSymbol,
onOrderBookMsg,
]);
return {
latestTick,
recentTradeTicks,
isConnected,
error,
lastTickAt,
realtimeTrId,
};
}
function resolveSessionInClient() {
if (typeof window === "undefined") {
return resolveDomesticKisSession();
}
try {
const override = window.localStorage.getItem(
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
);
return resolveDomesticKisSession(override);
} catch {
return resolveDomesticKisSession();
}
}

View File

@@ -1,10 +1,10 @@
import { useState, useCallback } from "react";
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import type {
DashboardStockCashOrderRequest,
DashboardStockCashOrderResponse,
} from "@/features/dashboard/types/dashboard.types";
import { fetchOrderCash } from "@/features/dashboard/apis/kis-stock.api";
} from "@/features/trade/types/trade.types";
import { fetchOrderCash } from "@/features/trade/apis/kis-stock.api";
export function useOrder() {
const [isLoading, setIsLoading] = useState(false);

View File

@@ -1,15 +1,15 @@
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 type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import type { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types";
import { fetchStockOrderBook } from "@/features/trade/apis/kis-stock.api";
import { toast } from "sonner";
/**
* @description REST , .
* DashboardContainer에서 useKisTradeWebSocket을
* TradeContainer에서 useKisTradeWebSocket을
* WebSocket으로 externalRealtimeOrderBook으로 .
* @see features/dashboard/components/DashboardContainer.tsx
* @see features/dashboard/components/orderbook/OrderBook.tsx
* @see features/trade/components/TradeContainer.tsx
* @see features/trade/components/orderbook/OrderBook.tsx
*/
export function useOrderBook(
symbol: string | undefined,

View File

@@ -1,13 +1,13 @@
import { useCallback, useState, useTransition } from "react";
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type { KisRuntimeCredentials } from "@/features/settings/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";
} from "@/features/trade/types/trade.types";
import { fetchStockOverview } from "@/features/trade/apis/kis-stock.api";
interface OverviewMeta {
priceSource: DashboardPriceSource;
@@ -60,8 +60,8 @@ export function useStockOverview() {
/**
* / .
* StockLineChart API .
* @see features/dashboard/components/DashboardContainer.tsx useKisTradeWebSocket onTick
* @see features/dashboard/components/chart/StockLineChart.tsx fetchStockChart
* @see features/trade/components/TradeContainer.tsx useKisTradeWebSocket onTick
* @see features/trade/components/chart/StockLineChart.tsx fetchStockChart
*/
const updateRealtimeTradeTick = useCallback(
(tick: DashboardRealtimeTradeTick) => {

View File

@@ -1,10 +1,10 @@
import { useCallback, useRef, useState } from "react";
import { fetchStockSearch } from "@/features/dashboard/apis/kis-stock.api";
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import { fetchStockSearch } from "@/features/trade/apis/kis-stock.api";
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import type {
DashboardStockSearchHistoryItem,
DashboardStockSearchItem,
} from "@/features/dashboard/types/dashboard.types";
} from "@/features/trade/types/trade.types";
const SEARCH_HISTORY_STORAGE_KEY = "jurini:stock-search-history:v1";
const SEARCH_HISTORY_LIMIT = 12;
@@ -44,12 +44,12 @@ function writeSearchHistory(items: DashboardStockSearchHistoryItem[]) {
/**
* @description (//) (localStorage) .
* @see features/dashboard/components/DashboardContainer.tsx // .
* @see features/dashboard/components/search/StockSearchHistory.tsx .
* @see features/trade/components/TradeContainer.tsx // .
* @see features/trade/components/search/StockSearchHistory.tsx .
*/
export function useStockSearch() {
// ========== SEARCH STATE ==========
const [keyword, setKeyword] = useState("삼성전자");
const [keyword, setKeyword] = useState("");
const [searchResults, setSearchResults] = useState<DashboardStockSearchItem[]>([]);
const [error, setError] = useState<string | null>(null);
const [isSearching, setIsSearching] = useState(false);
@@ -92,7 +92,7 @@ export function useStockSearch() {
/**
* @description API를 .
* @see features/dashboard/components/DashboardContainer.tsx handleSearchSubmit / .
* @see features/trade/components/TradeContainer.tsx handleSearchSubmit / .
*/
const search = useCallback(
(query: string, credentials: KisRuntimeCredentials | null) => {
@@ -119,7 +119,7 @@ export function useStockSearch() {
/**
* @description .
* @see features/dashboard/components/DashboardContainer.tsx .
* @see features/trade/components/TradeContainer.tsx .
*/
const clearSearch = useCallback(() => {
abortRef.current?.abort();
@@ -130,7 +130,7 @@ export function useStockSearch() {
/**
* @description API setter입니다.
* @see features/dashboard/components/DashboardContainer.tsx ensureSearchReady .
* @see features/trade/components/TradeContainer.tsx ensureSearchReady .
*/
const setSearchError = useCallback((message: string | null) => {
setError(message);
@@ -138,7 +138,7 @@ export function useStockSearch() {
/**
* @description ( ).
* @see features/dashboard/components/DashboardContainer.tsx handleSelectStock .
* @see features/trade/components/TradeContainer.tsx handleSelectStock .
*/
const appendSearchHistory = useCallback((item: DashboardStockSearchItem) => {
setSearchHistory((prev) => {
@@ -155,7 +155,7 @@ export function useStockSearch() {
/**
* @description .
* @see features/dashboard/components/search/StockSearchHistory.tsx .
* @see features/trade/components/search/StockSearchHistory.tsx .
*/
const removeSearchHistory = useCallback((symbol: string) => {
setSearchHistory((prev) => {
@@ -167,7 +167,7 @@ export function useStockSearch() {
/**
* @description .
* @see features/dashboard/components/search/StockSearchHistory.tsx .
* @see features/trade/components/search/StockSearchHistory.tsx .
*/
const clearSearchHistory = useCallback(() => {
setSearchHistory([]);

View File

@@ -0,0 +1,118 @@
import {
type FocusEvent,
type KeyboardEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
interface UseTradeSearchPanelParams {
canSearch: boolean;
keyword: string;
verifiedCredentials: KisRuntimeCredentials | null;
search: (query: string, credentials: KisRuntimeCredentials | null) => void;
clearSearch: () => void;
}
/**
* @description 트레이드 검색 패널(열림/닫힘/자동검색/포커스 이탈) UI 상태를 관리합니다.
* @see features/trade/components/TradeContainer.tsx TradeContainer에서 검색 관련 상태 조합을 단순화하기 위해 사용합니다.
* @see features/trade/components/search/TradeSearchSection.tsx 검색 UI 이벤트 핸들러를 전달합니다.
*/
export function useTradeSearchPanel({
canSearch,
keyword,
verifiedCredentials,
search,
clearSearch,
}: UseTradeSearchPanelParams) {
// [Ref] 종목 선택 직후 자동 검색을 1회 건너뛰기 위한 플래그
const skipNextAutoSearchRef = useRef(false);
// [Ref] 검색 패널 루트 (포커스 아웃 감지 범위)
const searchShellRef = useRef<HTMLDivElement | null>(null);
// [State] 검색 패널 열림 상태
const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false);
/**
* @description 다음 자동 검색 사이클 1회를 건너뛰도록 표시합니다.
* @see features/trade/components/TradeContainer.tsx handleSelectStock 종목 선택 직후 중복 검색 방지에 사용합니다.
*/
const markSkipNextAutoSearch = useCallback(() => {
skipNextAutoSearchRef.current = true;
}, []);
const closeSearchPanel = useCallback(() => {
setIsSearchPanelOpen(false);
}, []);
const openSearchPanel = useCallback(() => {
if (!canSearch) return;
setIsSearchPanelOpen(true);
}, [canSearch]);
/**
* @description 검색 박스에서 포커스가 완전히 벗어나면 드롭다운을 닫습니다.
* @see features/trade/components/search/TradeSearchSection.tsx onBlurCapture 이벤트로 연결됩니다.
*/
const handleSearchShellBlur = useCallback(
(event: FocusEvent<HTMLDivElement>) => {
const nextTarget = event.relatedTarget as Node | null;
if (nextTarget && searchShellRef.current?.contains(nextTarget)) return;
closeSearchPanel();
},
[closeSearchPanel],
);
/**
* @description ESC 키 입력 시 검색 드롭다운을 닫고 포커스를 해제합니다.
* @see features/trade/components/search/TradeSearchSection.tsx onKeyDownCapture 이벤트로 연결됩니다.
*/
const handleSearchShellKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== "Escape") return;
closeSearchPanel();
(event.target as HTMLElement | null)?.blur?.();
},
[closeSearchPanel],
);
useEffect(() => {
// [Step 1] 종목 선택 직후 1회 자동 검색 스킵 처리
if (skipNextAutoSearchRef.current) {
skipNextAutoSearchRef.current = false;
return;
}
// [Step 2] 인증 불가 상태면 검색 결과를 즉시 정리
if (!canSearch) {
clearSearch();
return;
}
const trimmed = keyword.trim();
// [Step 3] 입력값이 비어 있으면 검색 상태 초기화
if (!trimmed) {
clearSearch();
return;
}
// [Step 4] 입력 디바운스 후 검색 실행
const timer = window.setTimeout(() => {
search(trimmed, verifiedCredentials);
}, 220);
return () => window.clearTimeout(timer);
}, [canSearch, keyword, verifiedCredentials, search, clearSearch]);
return {
searchShellRef,
isSearchPanelOpen: canSearch && isSearchPanelOpen,
markSkipNextAutoSearch,
openSearchPanel,
closeSearchPanel,
handleSearchShellBlur,
handleSearchShellKeyDown,
};
}

View File

@@ -1,5 +1,5 @@
/**
* @file features/dashboard/types/dashboard.types.ts
* @file features/trade/types/trade.types.ts
* @description (//)
*/
@@ -75,7 +75,7 @@ export interface DashboardStockSearchItem {
/**
* 1
* @see features/dashboard/hooks/useStockSearch.ts localStorage에 / .
* @see features/trade/hooks/useStockSearch.ts localStorage에 / .
*/
export interface DashboardStockSearchHistoryItem
extends DashboardStockSearchItem {
@@ -153,6 +153,7 @@ export interface DashboardRealtimeTradeTick {
sellExecutionCount: number;
buyExecutionCount: number;
netBuyExecutionCount: number;
executionClassCode?: string;
open: number;
high: number;
low: number;

View File

@@ -1,14 +1,20 @@
import type {
DashboardRealtimeTradeTick,
DashboardStockOrderBookResponse,
} from "@/features/dashboard/types/dashboard.types";
} from "@/features/trade/types/trade.types";
const REALTIME_SIGN_NEGATIVE = new Set(["4", "5"]);
const ALLOWED_REALTIME_TRADE_TR_IDS = new Set([
const EXECUTED_REALTIME_TRADE_TR_IDS = new Set([
"H0STCNT0",
"H0STANC0",
"H0STOUP0",
"H0UNCNT0",
"H0NXCNT0",
]);
const EXPECTED_REALTIME_TRADE_TR_IDS = new Set([
"H0STANC0",
"H0STOAC0",
"H0UNANC0",
"H0NXANC0",
]);
const TICK_FIELD_INDEX = {
@@ -29,10 +35,12 @@ const TICK_FIELD_INDEX = {
buyExecutionCount: 16,
netBuyExecutionCount: 17,
tradeStrength: 18,
executionClassCode: 21,
} as const;
/**
* KIS ??щ/? ??붿€?????
* @description KIS / .
* @see features/trade/hooks/useKisTradeWebSocket.ts / payload .
*/
export function buildKisRealtimeMessage(
approvalKey: string,
@@ -57,9 +65,10 @@ export function buildKisRealtimeMessage(
}
/**
* ?? ??raw)?? ??????
* - ?( ????? ? 붿
* - ?щ ?€?0 ? ?? ?
* @description (raw) .
* - ( ) .
* - 0 .
* @see features/trade/hooks/useKisTradeWebSocket.ts onmessage .
*/
export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
if (!/^([01])\|/.test(raw)) return [] as DashboardRealtimeTradeTick[];
@@ -69,7 +78,10 @@ export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
// TR ID check: regular tick / expected tick / after-hours tick.
const receivedTrId = parts[1];
if (!ALLOWED_REALTIME_TRADE_TR_IDS.has(receivedTrId)) {
const isExecutedTick = EXECUTED_REALTIME_TRADE_TR_IDS.has(receivedTrId);
const isExpectedTick = EXPECTED_REALTIME_TRADE_TR_IDS.has(receivedTrId);
// 체결 화면에는 "실제 체결 TR"만 반영하고 예상체결 TR은 제외합니다.
if (!isExecutedTick || isExpectedTick) {
return [] as DashboardRealtimeTradeTick[];
}
@@ -86,18 +98,15 @@ export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
return [] as DashboardRealtimeTradeTick[];
}
const normalizedExpected = normalizeDomesticSymbol(expectedSymbol);
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 normalizedSymbol = normalizeDomesticSymbol(symbol);
if (normalizedSymbol !== normalizedExpected) {
continue;
}
const price = readNumber(values, base + TICK_FIELD_INDEX.price);
@@ -117,7 +126,7 @@ export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
: rawChangeRate;
ticks.push({
symbol,
symbol: normalizedExpected,
tickTime: readString(values, base + TICK_FIELD_INDEX.tickTime),
price,
change,
@@ -142,6 +151,10 @@ export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
values,
base + TICK_FIELD_INDEX.netBuyExecutionCount,
),
executionClassCode: readString(
values,
base + TICK_FIELD_INDEX.executionClassCode,
),
open: readNumber(values, base + TICK_FIELD_INDEX.open),
high: readNumber(values, base + TICK_FIELD_INDEX.high),
low: readNumber(values, base + TICK_FIELD_INDEX.low),
@@ -152,7 +165,8 @@ export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
}
/**
* KIS ????(H0STASP0/H0UNASP0/H0STOAA0)?OrderBook ъ?????
* @description KIS (H0STASP0/H0UNASP0/H0STOAA0) OrderBook .
* @see features/trade/hooks/useKisTradeWebSocket.ts onOrderBookMessage .
*/
export function parseKisRealtimeOrderbook(
raw: string,
@@ -224,8 +238,8 @@ export function parseKisRealtimeOrderbook(
}
/**
* @description ? ?? ? ??? 6? ????.
* @see features/dashboard/utils/kis-realtime.utils.ts parseKisRealtimeOrderbook
* @description 6 .
* @see features/trade/utils/kis-realtime.utils.ts parseKisRealtimeOrderbook .
*/
function normalizeDomesticSymbol(value: string) {
const trimmed = value.trim();

View File

@@ -90,7 +90,7 @@ function tryParseApprovalResponse(rawText: string): KisApprovalResponse {
/**
* @description 승인키를 캐시에서 반환하거나 새로 발급합니다.
* @see features/dashboard/store/use-kis-runtime-store.ts
* @see features/settings/store/use-kis-runtime-store.ts
*/
export async function getKisApprovalKey(credentials?: KisCredentialInput) {
const cacheKey = getApprovalCacheKey(credentials);

View File

@@ -29,7 +29,7 @@ const AFTER_HOURS_SINGLE_END_MINUTES = 18 * 60; // 18:00
/**
* @description Converts external string to strict session enum.
* @see lib/kis/domestic.ts getDomesticOrderBook
* @see features/dashboard/hooks/useKisTradeWebSocket.ts resolveSessionInClient
* @see features/trade/hooks/useKisTradeWebSocket.ts resolveSessionInClient
*/
export function parseDomesticKisSession(value?: string | null) {
if (!value) return null;
@@ -53,7 +53,7 @@ export function parseDomesticKisSession(value?: string | null) {
/**
* @description Returns current session in KST.
* @see features/dashboard/hooks/useKisTradeWebSocket.ts WebSocket TR switching
* @see features/trade/hooks/useKisTradeWebSocket.ts WebSocket TR switching
* @see lib/kis/domestic.ts REST orderbook source switching
*/
export function getDomesticKisSessionInKst(now = new Date()): DomesticKisSession {
@@ -104,7 +104,7 @@ export function getDomesticKisSessionInKst(now = new Date()): DomesticKisSession
/**
* @description If override is valid, use it. Otherwise use real KST time.
* @see app/api/kis/domestic/orderbook/route.ts session override header
* @see features/dashboard/hooks/useKisTradeWebSocket.ts localStorage override
* @see features/trade/hooks/useKisTradeWebSocket.ts localStorage override
*/
export function resolveDomesticKisSession(
override?: string | null,
@@ -143,7 +143,7 @@ export function shouldUseOvertimeOrderBookApi(session: DomesticKisSession) {
/**
* @description Whether trade tick should use expected-execution TR.
* @see features/dashboard/hooks/useKisTradeWebSocket.ts resolveTradeTrId
* @see features/trade/hooks/useKisTradeWebSocket.ts resolveTradeTrId
*/
export function shouldUseExpectedExecutionTr(session: DomesticKisSession) {
return session === "openAuction" || session === "closeAuction";
@@ -151,7 +151,7 @@ export function shouldUseExpectedExecutionTr(session: DomesticKisSession) {
/**
* @description Whether trade tick/orderbook should use after-hours single-price TR.
* @see features/dashboard/hooks/useKisTradeWebSocket.ts resolveTradeTrId
* @see features/trade/hooks/useKisTradeWebSocket.ts resolveTradeTrId
*/
export function shouldUseAfterHoursSinglePriceTr(session: DomesticKisSession) {
return session === "afterHoursSinglePrice";

View File

@@ -2,7 +2,7 @@ import type {
DashboardChartTimeframe,
DashboardStockItem,
StockCandlePoint,
} from "@/features/dashboard/types/dashboard.types";
} from "@/features/trade/types/trade.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { kisGet } from "@/lib/kis/client";
import {

View File

@@ -1,6 +1,4 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { clearKisApprovalKeyCache } from "@/lib/kis/approval";
import type { KisCredentialInput } from "@/lib/kis/config";
import { getKisConfig } from "@/lib/kis/config";
@@ -26,10 +24,6 @@ interface KisTokenCache {
expiresAt: number;
}
interface PersistedTokenCache {
[cacheKey: string]: KisTokenCache;
}
interface KisRevokeResponse {
code?: number | string;
message?: string;
@@ -39,7 +33,6 @@ interface KisRevokeResponse {
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");
@@ -50,59 +43,6 @@ function getTokenCacheKey(credentials?: KisCredentialInput) {
return `${config.tradingEnv}:${hashKey(config.appKey)}`;
}
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 when file does not exist
}
return;
}
await writePersistedTokenCache(cache);
}
function tryParseTokenResponse(rawText: string): KisTokenResponse {
try {
return JSON.parse(rawText) as KisTokenResponse;
@@ -226,12 +166,6 @@ export async function getKisAccessToken(credentials?: KisCredentialInput) {
return cached.token;
}
const persisted = await getPersistedToken(cacheKey);
if (persisted) {
tokenCacheMap.set(cacheKey, persisted);
return persisted.token;
}
const inFlight = tokenIssueInFlightMap.get(cacheKey);
if (inFlight) {
const shared = await inFlight;
@@ -246,7 +180,6 @@ export async function getKisAccessToken(credentials?: KisCredentialInput) {
});
tokenCacheMap.set(cacheKey, next);
await setPersistedToken(cacheKey, next);
return next.token;
}
@@ -289,7 +222,6 @@ export async function revokeKisAccessToken(credentials?: KisCredentialInput) {
tokenCacheMap.delete(cacheKey);
tokenIssueInFlightMap.delete(cacheKey);
await clearPersistedToken(cacheKey);
clearKisApprovalKeyCache(credentials);
return payload.message ?? "액세스 토큰 폐기가 완료되었습니다.";

View File

@@ -3,7 +3,7 @@ import { KisCredentialInput } from "@/lib/kis/config";
import {
DashboardOrderSide,
DashboardOrderType,
} from "@/features/dashboard/types/dashboard.types";
} from "@/features/trade/types/trade.types";
/**
* @file lib/kis/trade.ts

View File

@@ -3,10 +3,12 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev --port 3001 --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"sync:stocks": "node scripts/sync-korean-stocks.mjs",
"sync:stocks:check": "node scripts/sync-korean-stocks.mjs --check"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",

View File

@@ -1,61 +0,0 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- generic [ref=e2]:
- banner [ref=e3]:
- generic [ref=e4]:
- link "AutoTrade" [ref=e5] [cursor=pointer]:
- /url: /
- generic [ref=e8]: AutoTrade
- generic [ref=e9]:
- button "Toggle theme" [ref=e10]:
- img
- generic [ref=e11]: Toggle theme
- link "시작하기" [ref=e13] [cursor=pointer]:
- /url: /signup
- main [ref=e18]:
- generic [ref=e20]:
- generic [ref=e21]:
- generic [ref=e23]: 👋
- generic [ref=e24]: 환영합니다!
- generic [ref=e25]: 서비스 이용을 위해 로그인해 주세요.
- generic [ref=e27]:
- generic [ref=e28]:
- generic [ref=e29]:
- generic [ref=e30]: 이메일
- textbox "이메일" [ref=e31]:
- /placeholder: your@email.com
- generic [ref=e32]:
- generic [ref=e33]: 비밀번호
- textbox "비밀번호" [ref=e34]:
- /placeholder: ••••••••
- generic [ref=e35]:
- generic [ref=e36]:
- checkbox "이메일 기억하기" [ref=e37]
- checkbox
- generic [ref=e38] [cursor=pointer]: 이메일 기억하기
- link "비밀번호 찾기" [ref=e39] [cursor=pointer]:
- /url: /forgot-password
- button "로그인" [ref=e40]
- paragraph [ref=e41]:
- text: 계정이 없으신가요?
- link "회원가입 하기" [ref=e42] [cursor=pointer]:
- /url: /signup
- generic [ref=e44]: 또는 소셜 로그인
- generic [ref=e45]:
- button "Google" [ref=e47]:
- img
- text: Google
- button "Kakao" [ref=e49]:
- img
- text: Kakao
- generic [ref=e50]:
- img [ref=e52]
- button "Open Tanstack query devtools" [ref=e100] [cursor=pointer]:
- img [ref=e101]
- region "Notifications alt+T"
- button "Open Next.js Dev Tools" [ref=e154] [cursor=pointer]:
- img [ref=e155]
- alert [ref=e158]
```

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,334 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import { inflateRawSync } from "node:zlib";
/**
* @file scripts/sync-korean-stocks.mjs
* @description KIS 종목 마스터 파일(KOSPI/KOSDAQ)로 검색 인덱스 JSON을 자동 갱신합니다.
*/
const OUTPUT_FILE_PATH = path.resolve(
process.cwd(),
"features/trade/data/korean-stocks.json",
);
const SOURCE_CONFIGS = [
{
market: "KOSPI",
tailWidth: 228,
url: "https://new.real.download.dws.co.kr/common/master/kospi_code.mst.zip",
},
{
market: "KOSDAQ",
tailWidth: 222,
url: "https://new.real.download.dws.co.kr/common/master/kosdaq_code.mst.zip",
},
];
const MIN_EXPECTED_TOTAL = 3000;
const MIN_EXPECTED_PER_MARKET = 1000;
/**
* CLI 진입점
* @see scripts/sync-korean-stocks.mjs main() 종목 인덱스 갱신 파이프라인을 실행합니다.
*/
async function main() {
const options = parseCliArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
const allItems = [];
for (const source of SOURCE_CONFIGS) {
const zipBuffer = await downloadBinary(source.url);
const { fileName, data } = extractFirstZipEntry(zipBuffer);
const parsed = parseMasterRows(data, source.market, source.tailWidth);
console.log(
`[sync:stocks] ${source.market} parsed ${parsed.length} rows from ${fileName}`,
);
allItems.push(...parsed);
}
const normalized = normalizeItems(allItems);
validateCounts(normalized);
const nextJson = `${JSON.stringify(normalized, null, 2)}\n`;
const nextHash = sha256(nextJson);
if (options.dryRun) {
console.log(`[sync:stocks] dry-run complete, rows=${normalized.length}, hash=${nextHash}`);
return;
}
if (options.check) {
const currentJson = await readFile(OUTPUT_FILE_PATH, "utf8").catch(() => "");
const currentHash = sha256(currentJson);
if (currentJson !== nextJson) {
console.error(
`[sync:stocks] out-of-date: current=${currentHash}, next=${nextHash}`,
);
console.error("[sync:stocks] run `npm run sync:stocks` to update.");
process.exitCode = 1;
return;
}
console.log(`[sync:stocks] up-to-date, rows=${normalized.length}, hash=${currentHash}`);
return;
}
await writeFileAtomically(OUTPUT_FILE_PATH, nextJson);
console.log(`[sync:stocks] updated ${OUTPUT_FILE_PATH}`);
console.log(`[sync:stocks] rows=${normalized.length}, hash=${nextHash}`);
}
/**
* CLI 인자 파서
* @see scripts/sync-korean-stocks.mjs main() 실행 모드를 결정합니다.
*/
function parseCliArgs(args) {
return {
check: args.includes("--check"),
dryRun: args.includes("--dry-run"),
help: args.includes("--help") || args.includes("-h"),
};
}
/**
* 도움말 출력
* @see scripts/sync-korean-stocks.mjs parseCliArgs() 전달된 옵션을 안내합니다.
*/
function printHelp() {
console.log("Usage: node scripts/sync-korean-stocks.mjs [--check] [--dry-run]");
console.log("");
console.log("Options:");
console.log(" --check compare generated JSON with current file and exit 1 on diff");
console.log(" --dry-run parse and validate data without writing output");
console.log(" -h, --help show this help message");
}
/**
* 원격 바이너리 다운로드
* @see scripts/sync-korean-stocks.mjs main() KIS 마스터 ZIP 파일을 가져옵니다.
*/
async function downloadBinary(url) {
const response = await fetch(url, {
headers: {
"user-agent": "auto-trade-stock-sync/1.0",
},
});
if (!response.ok) {
throw new Error(`Failed to download ${url} (${response.status} ${response.statusText})`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
}
/**
* ZIP 첫 번째 엔트리 추출
* @see scripts/sync-korean-stocks.mjs main() ZIP에서 .mst 본문을 읽어옵니다.
*/
function extractFirstZipEntry(zipBuffer) {
const eocdOffset = findEndOfCentralDirectory(zipBuffer);
const totalEntries = zipBuffer.readUInt16LE(eocdOffset + 10);
const centralDirectoryOffset = zipBuffer.readUInt32LE(eocdOffset + 16);
if (totalEntries < 1) {
throw new Error("ZIP has no entries.");
}
const cdSignature = zipBuffer.readUInt32LE(centralDirectoryOffset);
if (cdSignature !== 0x02014b50) {
throw new Error("Invalid central directory signature.");
}
const method = zipBuffer.readUInt16LE(centralDirectoryOffset + 10);
const compressedSize = zipBuffer.readUInt32LE(centralDirectoryOffset + 20);
const uncompressedSize = zipBuffer.readUInt32LE(centralDirectoryOffset + 24);
const fileNameLength = zipBuffer.readUInt16LE(centralDirectoryOffset + 28);
const extraLength = zipBuffer.readUInt16LE(centralDirectoryOffset + 30);
const commentLength = zipBuffer.readUInt16LE(centralDirectoryOffset + 32);
const localHeaderOffset = zipBuffer.readUInt32LE(centralDirectoryOffset + 42);
const fileNameStart = centralDirectoryOffset + 46;
const fileNameEnd = fileNameStart + fileNameLength;
const fileName = zipBuffer.subarray(fileNameStart, fileNameEnd).toString("utf8");
const _unused = extraLength + commentLength;
void _unused;
const localSignature = zipBuffer.readUInt32LE(localHeaderOffset);
if (localSignature !== 0x04034b50) {
throw new Error("Invalid local header signature.");
}
const localNameLength = zipBuffer.readUInt16LE(localHeaderOffset + 26);
const localExtraLength = zipBuffer.readUInt16LE(localHeaderOffset + 28);
const dataStart = localHeaderOffset + 30 + localNameLength + localExtraLength;
const dataEnd = dataStart + compressedSize;
const compressedData = zipBuffer.subarray(dataStart, dataEnd);
let data;
if (method === 0) {
data = compressedData;
} else if (method === 8) {
data = inflateRawSync(compressedData);
} else {
throw new Error(`Unsupported ZIP compression method: ${method}`);
}
if (uncompressedSize !== 0 && data.length !== uncompressedSize) {
throw new Error(
`Uncompressed size mismatch for ${fileName}: expected=${uncompressedSize}, actual=${data.length}`,
);
}
return { fileName, data };
}
/**
* EOCD(End Of Central Directory) 오프셋 탐색
* @see scripts/sync-korean-stocks.mjs extractFirstZipEntry() ZIP 중앙 디렉터리 위치를 찾습니다.
*/
function findEndOfCentralDirectory(zipBuffer) {
const minOffset = Math.max(0, zipBuffer.length - 65557);
for (let i = zipBuffer.length - 22; i >= minOffset; i -= 1) {
if (zipBuffer.readUInt32LE(i) === 0x06054b50) {
return i;
}
}
throw new Error("EOCD signature not found in ZIP.");
}
/**
* .mst 텍스트 파싱
* @see scripts/sync-korean-stocks.mjs main() symbol/name/standardCode를 추출합니다.
*/
function parseMasterRows(mstBuffer, market, tailWidth) {
const decoder = new TextDecoder("euc-kr");
const text = decoder.decode(mstBuffer);
const lines = text.split(/\r?\n/);
const items = [];
for (const rawLine of lines) {
const line = rawLine.trimEnd();
if (!line) continue;
if (line.length <= tailWidth + 21) continue;
const part1 = line.slice(0, line.length - tailWidth);
const symbol = part1.slice(0, 9).trim();
const standardCode = part1.slice(9, 21).trim();
const name = part1.slice(21).trim();
if (!/^\d{6}$/.test(symbol)) continue;
if (!standardCode || !name) continue;
items.push({
symbol,
name,
market,
standardCode,
});
}
return items;
}
/**
* 아이템 정규화/중복 제거/정렬
* @see scripts/sync-korean-stocks.mjs main() 검색 인덱스 최종 포맷을 만듭니다.
*/
function normalizeItems(items) {
const uniqueBySymbol = new Map();
for (const item of items) {
const existing = uniqueBySymbol.get(item.symbol);
if (!existing) {
uniqueBySymbol.set(item.symbol, item);
continue;
}
const same =
existing.market === item.market &&
existing.name === item.name &&
existing.standardCode === item.standardCode;
if (!same) {
throw new Error(
`Duplicate symbol conflict (${item.symbol}): ${JSON.stringify(existing)} <> ${JSON.stringify(item)}`,
);
}
}
return [...uniqueBySymbol.values()].sort((a, b) => {
const bySymbol = a.symbol.localeCompare(b.symbol);
if (bySymbol !== 0) return bySymbol;
return a.market.localeCompare(b.market);
});
}
/**
* 기본 품질 검증
* @see scripts/sync-korean-stocks.mjs main() 비정상적으로 적은 데이터면 실패 처리합니다.
*/
function validateCounts(items) {
if (items.length < MIN_EXPECTED_TOTAL) {
throw new Error(
`Total row count is too small: ${items.length} < ${MIN_EXPECTED_TOTAL}`,
);
}
const marketCount = items.reduce(
(acc, item) => {
acc[item.market] += 1;
return acc;
},
{ KOSPI: 0, KOSDAQ: 0 },
);
if (marketCount.KOSPI < MIN_EXPECTED_PER_MARKET) {
throw new Error(
`KOSPI row count is too small: ${marketCount.KOSPI} < ${MIN_EXPECTED_PER_MARKET}`,
);
}
if (marketCount.KOSDAQ < MIN_EXPECTED_PER_MARKET) {
throw new Error(
`KOSDAQ row count is too small: ${marketCount.KOSDAQ} < ${MIN_EXPECTED_PER_MARKET}`,
);
}
}
/**
* 원자적 파일 저장
* @see scripts/sync-korean-stocks.mjs main() 저장 도중 파일 손상을 방지합니다.
*/
async function writeFileAtomically(targetPath, content) {
const dir = path.dirname(targetPath);
const tempPath = path.join(
dir,
`.${path.basename(targetPath)}.${process.pid}.${Date.now()}.tmp`,
);
await mkdir(dir, { recursive: true });
await writeFile(tempPath, content, "utf8");
await rename(tempPath, targetPath);
}
/**
* SHA-256 해시
* @see scripts/sync-korean-stocks.mjs main() 변경 여부를 간단히 비교합니다.
*/
function sha256(value) {
return createHash("sha256").update(value).digest("hex");
}
main().catch((error) => {
console.error(`[sync:stocks] ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,78 +0,0 @@
"""
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()

View File

@@ -1,104 +0,0 @@
'''코스닥주식종목코드(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

View File

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

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,36 +0,0 @@
#홈페이지에서 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"

View File

@@ -1,99 +0,0 @@
/*****************************************************************************
* 코스피 종목 코드 파일 구조
****************************************************************************/
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;

View File

@@ -1,6 +0,0 @@
{
"status": "failed",
"failedTests": [
"2746d92cb07c1216e72c-59289721e6ad6cc3d2d4"
]
}

View File

@@ -1,61 +0,0 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- generic [ref=e2]:
- banner [ref=e3]:
- generic [ref=e4]:
- link "AutoTrade" [ref=e5] [cursor=pointer]:
- /url: /
- generic [ref=e8]: AutoTrade
- generic [ref=e9]:
- button "Toggle theme" [ref=e10]:
- img
- generic [ref=e11]: Toggle theme
- link "시작하기" [ref=e13] [cursor=pointer]:
- /url: /signup
- main [ref=e18]:
- generic [ref=e20]:
- generic [ref=e21]:
- generic [ref=e23]: 👋
- generic [ref=e24]: 환영합니다!
- generic [ref=e25]: 서비스 이용을 위해 로그인해 주세요.
- generic [ref=e27]:
- generic [ref=e28]:
- generic [ref=e29]:
- generic [ref=e30]: 이메일
- textbox "이메일" [ref=e31]:
- /placeholder: your@email.com
- generic [ref=e32]:
- generic [ref=e33]: 비밀번호
- textbox "비밀번호" [ref=e34]:
- /placeholder: ••••••••
- generic [ref=e35]:
- generic [ref=e36]:
- checkbox "이메일 기억하기" [ref=e37]
- checkbox
- generic [ref=e38] [cursor=pointer]: 이메일 기억하기
- link "비밀번호 찾기" [ref=e39] [cursor=pointer]:
- /url: /forgot-password
- button "로그인" [ref=e40]
- paragraph [ref=e41]:
- text: 계정이 없으신가요?
- link "회원가입 하기" [ref=e42] [cursor=pointer]:
- /url: /signup
- generic [ref=e44]: 또는 소셜 로그인
- generic [ref=e45]:
- button "Google" [ref=e47]:
- img
- text: Google
- button "Kakao" [ref=e49]:
- img
- text: Kakao
- generic [ref=e50]:
- img [ref=e52]
- button "Open Tanstack query devtools" [ref=e100] [cursor=pointer]:
- img [ref=e101]
- region "Notifications alt+T"
- button "Open Next.js Dev Tools" [ref=e154] [cursor=pointer]:
- img [ref=e155]
- alert [ref=e158]
```

View File

@@ -1,29 +0,0 @@
import { test, expect } from "@playwright/test";
test.describe("Authentication Flow", () => {
test("Guest should see Landing Page", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle(/AutoTrade/i);
await expect(
page.getByRole("heading", { name: "투자의 미래, 자동화하세요" }),
).toBeVisible();
await expect(
page.getByRole("link", { name: "로그인" }).first(),
).toBeVisible();
});
test("Guest trying to access /dashboard should be redirected to /login", async ({
page,
}) => {
await page.goto("/dashboard");
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole("button", { name: "로그인" })).toBeVisible();
});
test("Login page should load correctly", async ({ page }) => {
await page.goto("/login");
await expect(page.getByLabel("이메일", { exact: true })).toBeVisible();
await expect(page.getByLabel("비밀번호")).toBeVisible();
await expect(page.getByRole("button", { name: "로그인" })).toBeVisible();
});
});

View File

@@ -1,47 +0,0 @@
import { test, expect } from "@playwright/test";
test.describe("Mobile Dashboard Scroll", () => {
test.use({
viewport: { width: 390, height: 844 }, // iPhone 12 Pro size
userAgent:
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Mobile/15E148 Safari/604.1",
});
test("should allow scrolling to access order form at the bottom", async ({
page,
}) => {
// 1. Navigate to dashboard
await page.goto("http://localhost:3001/dashboard");
await page.waitForLoadState("domcontentloaded");
// 2. Check Top Element (Chart)
const chart = page.locator("canvas").first();
await expect(chart).toBeVisible();
// 3. Scroll to Bottom
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(500); // Wait for scroll
// 4. Check Bottom Element (Order Form)
// "매수하기" button is a good indicator of the order form
const buyButton = page.getByRole("button", { name: "매수하기" });
await expect(buyButton).toBeVisible();
// 5. Verify Scroll Height is greater than Viewport Height
const scrollHeight = await page.evaluate(
() => document.documentElement.scrollHeight,
);
const viewportHeight = 844;
expect(scrollHeight).toBeGreaterThan(viewportHeight);
console.log(
`Scroll Height: ${scrollHeight}, Viewport Height: ${viewportHeight}`,
);
// Capture screenshot at bottom
await page.screenshot({
path: "test-results/mobile-scroll-bottom.png",
fullPage: false,
});
});
});