정리
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -119,6 +119,7 @@ storybook-static/
|
||||
*.local
|
||||
.cache/
|
||||
node_modules
|
||||
.tmp/
|
||||
|
||||
# ========================================
|
||||
# Custom
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"mock:7777b1c958636e65539aadf6c4273a0dfa33c17e55d1beb7dbfd033d49457f6e":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIsImF1ZCI6ImRhNWMyYjU5LTA3YTUtNGVhNy1hY2UyLWZlNTMwZTBjM2ZjNCIsInByZHRfY2QiOiIiLCJpc3MiOiJ1bm9ndyIsImV4cCI6MTc3MDc5MzIzOCwiaWF0IjoxNzcwNzA2ODM4LCJqdGkiOiJQUzA2TG12SndjRHl1MDZLVXpPRjVsSHJacWR6ZWJKTXhCVWIifQ.vDnx1Vx-LnzaRETwkR5aQUnl7vV3-KlXG5AVlMRrchjdovJzd5n1vL7YfG_146Swrao2Drw8TwnpdK44-aTrhg","expiresAt":1770793238000},"real:7777b1c958636e65539aadf6c4273a0dfa33c17e55d1beb7dbfd033d49457f6e":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIsImF1ZCI6ImViN2M3NzBhLWZjMDgtNDI3MS05YjZiLTkxYmM1OGY0NmM0ZiIsInByZHRfY2QiOiIiLCJpc3MiOiJ1bm9ndyIsImV4cCI6MTc3MDg4MTMyNCwiaWF0IjoxNzcwNzk0OTI0LCJqdGkiOiJQUzA2TG12SndjRHl1MDZLVXpPRjVsSHJacWR6ZWJKTXhCVWIifQ.3NvIylftH8PmMvFQu9CmfZUwULMoKQIAIlJHt5zW3sj70h9d4yLIi5WMbvFp-akUwYEAQMZHhFMeD4B58eP7BA","expiresAt":1770881324493}}
|
||||
59
AGENTS.md
59
AGENTS.md
@@ -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 프로젝트 관리 및 쿼리
|
||||
|
||||
@@ -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
165
README.md
@@ -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는 운영 환경에 맞게 보완이 필요합니다.
|
||||
|
||||
32
doc-rule.md
32
doc-rule.md
@@ -1,32 +0,0 @@
|
||||
# Antigravity Rules
|
||||
|
||||
This document defines the coding and behavior rules for the Antigravity agent.
|
||||
|
||||
## General Rules
|
||||
|
||||
- **Language**: All output, explanations, and commit messages MUST be in **Korean (한국어)**.
|
||||
- **Tone**: Professional, helpful, and concise.
|
||||
|
||||
## Documentation Rules
|
||||
|
||||
### JSX Comments
|
||||
|
||||
- Mandatory use of section comments in JSX to delineate logical blocks.
|
||||
- Format: `{/* ========== SECTION NAME ========== */}`
|
||||
|
||||
### JSDoc Tags
|
||||
|
||||
- **@see**: Mandatory for function/component documentation. Must include calling file, function/event name, and purpose.
|
||||
- **@author**: Mandatory file-level tag. Use `@author jihoon87.lee`.
|
||||
|
||||
### Inline Comments
|
||||
|
||||
- High density of inline comments required for:
|
||||
- State definitions
|
||||
- Event handlers
|
||||
- Complex logic in JSX
|
||||
- Balance conciseness with clarity.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Follow Project-specific linting and formatting rules.
|
||||
@@ -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 = {
|
||||
@@ -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,
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { type FormEvent, useCallback, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
|
||||
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 { 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 { DashboardLayout } from "@/features/trade/components/layout/DashboardLayout";
|
||||
import { StockHeader } from "@/features/trade/components/header/StockHeader";
|
||||
import { OrderBook } from "@/features/trade/components/orderbook/OrderBook";
|
||||
import { OrderForm } from "@/features/trade/components/order/OrderForm";
|
||||
import { StockLineChart } from "@/features/trade/components/chart/StockLineChart";
|
||||
import { useTradeSearchPanel } from "@/features/trade/hooks/useTradeSearchPanel";
|
||||
import type {
|
||||
DashboardStockOrderBookResponse,
|
||||
DashboardStockSearchItem,
|
||||
@@ -32,18 +26,17 @@ import type {
|
||||
* @see features/trade/hooks/useStockOverview.ts 선택 종목 상세 상태를 관리합니다.
|
||||
*/
|
||||
export function TradeContainer() {
|
||||
const skipNextAutoSearchRef = useRef(false);
|
||||
const searchShellRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 상태 정의: 검색 패널 열림 상태를 관리합니다.
|
||||
const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false);
|
||||
|
||||
const { verifiedCredentials, isKisVerified } = useKisRuntimeStore(
|
||||
useShallow((state) => ({
|
||||
verifiedCredentials: state.verifiedCredentials,
|
||||
isKisVerified: state.isKisVerified,
|
||||
})),
|
||||
);
|
||||
// [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,
|
||||
@@ -58,14 +51,32 @@ export function TradeContainer() {
|
||||
removeSearchHistory,
|
||||
clearSearchHistory,
|
||||
} = useStockSearch();
|
||||
|
||||
const { selectedStock, loadOverview, updateRealtimeTradeTick } =
|
||||
useStockOverview();
|
||||
const canTrade = isKisVerified && !!verifiedCredentials;
|
||||
const canSearch = canTrade;
|
||||
|
||||
// 호가 실시간 데이터 (체결 WS에서 동일 소켓으로 수신)
|
||||
const [realtimeOrderBook, setRealtimeOrderBook] =
|
||||
useState<DashboardStockOrderBookResponse | null>(null);
|
||||
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);
|
||||
@@ -109,9 +120,6 @@ export function TradeContainer() {
|
||||
orderBook,
|
||||
});
|
||||
|
||||
const canTrade = isKisVerified && !!verifiedCredentials;
|
||||
const canSearch = canTrade;
|
||||
|
||||
/**
|
||||
* @description 검색 전 API 인증 여부를 확인합니다.
|
||||
* @see features/trade/components/search/StockSearchForm.tsx 검색 제출 전 공통 가드로 사용합니다.
|
||||
@@ -122,67 +130,12 @@ export function TradeContainer() {
|
||||
return false;
|
||||
}, [canSearch, setSearchError]);
|
||||
|
||||
const closeSearchPanel = useCallback(() => {
|
||||
setIsSearchPanelOpen(false);
|
||||
}, []);
|
||||
|
||||
const openSearchPanel = useCallback(() => {
|
||||
if (!canSearch) return;
|
||||
setIsSearchPanelOpen(true);
|
||||
}, [canSearch]);
|
||||
|
||||
/**
|
||||
* @description 검색 영역 포커스가 완전히 빠지면 드롭다운(검색결과/히스토리)을 닫습니다.
|
||||
* @see features/trade/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(() => {
|
||||
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/trade/components/search/StockSearchForm.tsx onSubmit 이벤트로 호출됩니다.
|
||||
*/
|
||||
const handleSearchSubmit = useCallback(
|
||||
(event: React.FormEvent) => {
|
||||
(event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!ensureSearchReady() || !verifiedCredentials) return;
|
||||
search(keyword, verifiedCredentials);
|
||||
@@ -207,7 +160,7 @@ export function TradeContainer() {
|
||||
}
|
||||
|
||||
// 카드 선택으로 keyword가 바뀔 때 자동 검색이 다시 실행되지 않게 한 번 건너뜁니다.
|
||||
skipNextAutoSearchRef.current = true;
|
||||
markSkipNextAutoSearch();
|
||||
setKeyword(item.name);
|
||||
clearSearch();
|
||||
closeSearchPanel();
|
||||
@@ -223,135 +176,57 @@ export function TradeContainer() {
|
||||
setKeyword,
|
||||
appendSearchHistory,
|
||||
loadOverview,
|
||||
markSkipNextAutoSearch,
|
||||
],
|
||||
);
|
||||
|
||||
if (!canTrade) {
|
||||
if (!_hasHydrated) {
|
||||
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>
|
||||
<div className="mt-4">
|
||||
<Button asChild className="bg-brand-600 hover:bg-brand-700">
|
||||
<Link href="/settings">설정 페이지로 이동</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canTrade) {
|
||||
return <TradeAccessGate canTrade={canTrade} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full flex flex-col">
|
||||
{/* ========== 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}
|
||||
/>
|
||||
{/* ========== 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}
|
||||
/>
|
||||
|
||||
{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>
|
||||
{/* ========== DASHBOARD SECTION ========== */}
|
||||
<TradeDashboardContent
|
||||
selectedStock={selectedStock}
|
||||
verifiedCredentials={verifiedCredentials}
|
||||
latestTick={latestTick}
|
||||
recentTradeTicks={recentTradeTicks}
|
||||
orderBook={orderBook}
|
||||
isOrderBookLoading={isOrderBookLoading}
|
||||
referencePrice={referencePrice}
|
||||
currentPrice={currentPrice}
|
||||
change={change}
|
||||
changeRate={changeRate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
36
features/trade/components/guards/TradeAccessGate.tsx
Normal file
36
features/trade/components/guards/TradeAccessGate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
features/trade/components/layout/TradeDashboardContent.tsx
Normal file
99
features/trade/components/layout/TradeDashboardContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
101
features/trade/components/search/TradeSearchSection.tsx
Normal file
101
features/trade/components/search/TradeSearchSection.tsx
Normal 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
@@ -23,28 +23,128 @@ import {
|
||||
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";
|
||||
|
||||
function resolveTradeTrId(
|
||||
/**
|
||||
* @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)) return TRADE_TR_ID;
|
||||
if (shouldUseExpectedExecutionTr(session)) return TRADE_TR_ID;
|
||||
return TRADE_TR_ID;
|
||||
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]);
|
||||
}
|
||||
|
||||
function resolveOrderBookTrId(
|
||||
/**
|
||||
* @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))
|
||||
return ORDERBOOK_TR_ID_OVERTIME;
|
||||
return ORDERBOOK_TR_ID;
|
||||
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)];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,8 +180,11 @@ export function useKisTradeWebSocket(
|
||||
|
||||
const obSymbol = options?.orderBookSymbol;
|
||||
const onOrderBookMsg = options?.onOrderBookMessage;
|
||||
const realtimeTrIds = credentials
|
||||
? resolveTradeTrIds(credentials.tradingEnv, marketSession)
|
||||
: [TRADE_TR_ID];
|
||||
const realtimeTrId = credentials
|
||||
? resolveTradeTrId(credentials.tradingEnv, marketSession)
|
||||
? realtimeTrIds[0] ?? TRADE_TR_ID
|
||||
: TRADE_TR_ID;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -122,11 +225,24 @@ export function useKisTradeWebSocket(
|
||||
|
||||
let disposed = false;
|
||||
let socket: WebSocket | null = null;
|
||||
const debugEnabled = isWsDebugEnabled();
|
||||
|
||||
const tradeTrId = resolveTradeTrId(credentials.tradingEnv, marketSession);
|
||||
const orderBookTrId = obSymbol
|
||||
? resolveOrderBookTrId(credentials.tradingEnv, marketSession)
|
||||
: null;
|
||||
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 {
|
||||
@@ -144,34 +260,31 @@ export function useKisTradeWebSocket(
|
||||
if (disposed) return;
|
||||
approvalKeyRef.current = wsConnection.approvalKey;
|
||||
|
||||
socket = new WebSocket(`${wsConnection.wsUrl}/tryitout/${tradeTrId}`);
|
||||
// 공식 샘플과 동일하게 /tryitout 엔드포인트로 연결하고, TR은 payload로 구독합니다.
|
||||
socket = new WebSocket(`${wsConnection.wsUrl}/tryitout`);
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
if (disposed || !approvalKeyRef.current) return;
|
||||
|
||||
socket?.send(
|
||||
JSON.stringify(
|
||||
buildKisRealtimeMessage(
|
||||
approvalKeyRef.current,
|
||||
symbol,
|
||||
tradeTrId,
|
||||
"1",
|
||||
),
|
||||
),
|
||||
);
|
||||
for (const trId of tradeTrIds) {
|
||||
subscribe(approvalKeyRef.current, symbol, trId, "1");
|
||||
}
|
||||
|
||||
if (obSymbol && orderBookTrId) {
|
||||
socket?.send(
|
||||
JSON.stringify(
|
||||
buildKisRealtimeMessage(
|
||||
approvalKeyRef.current,
|
||||
obSymbol,
|
||||
orderBookTrId,
|
||||
"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);
|
||||
@@ -180,29 +293,92 @@ export function useKisTradeWebSocket(
|
||||
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) return;
|
||||
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 = ticks[ticks.length - 1];
|
||||
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),
|
||||
@@ -215,11 +391,29 @@ export function useKisTradeWebSocket(
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
if (!disposed) setIsConnected(false);
|
||||
if (!disposed) {
|
||||
if (debugEnabled) {
|
||||
console.warn("[KisRealtime] WebSocket error", {
|
||||
symbol,
|
||||
marketSession,
|
||||
tradeTrIds,
|
||||
});
|
||||
}
|
||||
setIsConnected(false);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (!disposed) setIsConnected(false);
|
||||
if (!disposed) {
|
||||
if (debugEnabled) {
|
||||
console.warn("[KisRealtime] WebSocket closed", {
|
||||
symbol,
|
||||
marketSession,
|
||||
tradeTrIds,
|
||||
});
|
||||
}
|
||||
setIsConnected(false);
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
@@ -242,16 +436,14 @@ export function useKisTradeWebSocket(
|
||||
|
||||
const key = approvalKeyRef.current;
|
||||
if (socket?.readyState === WebSocket.OPEN && key) {
|
||||
socket.send(
|
||||
JSON.stringify(buildKisRealtimeMessage(key, symbol, tradeTrId, "2")),
|
||||
);
|
||||
for (const trId of tradeTrIds) {
|
||||
subscribe(key, symbol, trId, "2");
|
||||
}
|
||||
|
||||
if (obSymbol && orderBookTrId) {
|
||||
socket.send(
|
||||
JSON.stringify(
|
||||
buildKisRealtimeMessage(key, obSymbol, orderBookTrId, "2"),
|
||||
),
|
||||
);
|
||||
if (obSymbol) {
|
||||
for (const trId of orderBookTrIds) {
|
||||
subscribe(key, obSymbol, trId, "2");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ function writeSearchHistory(items: DashboardStockSearchHistoryItem[]) {
|
||||
*/
|
||||
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);
|
||||
|
||||
118
features/trade/hooks/useTradeSearchPanel.ts
Normal file
118
features/trade/hooks/useTradeSearchPanel.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -153,6 +153,7 @@ export interface DashboardRealtimeTradeTick {
|
||||
sellExecutionCount: number;
|
||||
buyExecutionCount: number;
|
||||
netBuyExecutionCount: number;
|
||||
executionClassCode?: string;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
|
||||
@@ -4,11 +4,17 @@ import type {
|
||||
} 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,6 +35,7 @@ const TICK_FIELD_INDEX = {
|
||||
buyExecutionCount: 16,
|
||||
netBuyExecutionCount: 17,
|
||||
tradeStrength: 18,
|
||||
executionClassCode: 21,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -71,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[];
|
||||
}
|
||||
|
||||
@@ -88,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);
|
||||
@@ -119,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,
|
||||
@@ -144,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),
|
||||
|
||||
@@ -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 ?? "액세스 토큰 폐기가 완료되었습니다.";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
334
scripts/sync-korean-stocks.mjs
Normal file
334
scripts/sync-korean-stocks.mjs
Normal 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);
|
||||
});
|
||||
Binary file not shown.
799
temp-kis-auth.py
799
temp-kis-auth.py
@@ -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")
|
||||
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
@@ -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.
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"2746d92cb07c1216e72c-59289721e6ad6cc3d2d4"
|
||||
]
|
||||
}
|
||||
@@ -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]
|
||||
```
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user