트레이딩창 UI 배치 및 UX 수정 및 기획서 추가

This commit is contained in:
2026-02-24 15:43:56 +09:00
parent 19ebb1c6ea
commit a16af8ad7d
16 changed files with 1615 additions and 479 deletions

View File

@@ -0,0 +1,266 @@
# 브라우저 상주 자동매매 통합 계획서 v3.1 (AI/무저장 정책 반영)
## 요약
1. 자동매매는 브라우저가 켜져 있을 때만 동작합니다.
2. 백그라운드 탭(가려진 탭)에서는 동작을 허용합니다.
3. 탭 종료, 브라우저 종료, 앱 종료, 외부 페이지 이탈 시 자동주문은 즉시 중지됩니다.
4. 종료 직전 강한 경고를 보여주고 중지 이벤트를 서버에 기록합니다.
5. 투자금/손실한도는 퍼센트와 금액을 동시에 받고 더 보수적인 값(더 작은 값)을 실적용합니다.
6. 전략 선택은 프롬프트 입력, 검수 카탈로그, 온라인 실시간 수집을 모두 지원하며 복수선택 가능합니다.
7. 실거래 우선, 장중 기본, 보수적 위험관리 기본값을 유지합니다.
8. AI(인공지능)로 매수/매도 신호 후보를 만들고, 최종 주문은 규칙 엔진(고정 검증 로직)이 결정합니다.
9. 한국투자증권 API 키/시크릿/계좌번호는 서버 DB에 저장하지 않습니다.
10. KIS 민감정보는 브라우저 실행 세션 기준으로만 유지하고, 서버는 요청 처리 시에만 일시 사용합니다.
## 1) 기술 아키텍처
1. 프론트엔드: Next.js 16 App Router + React 19 + TypeScript.
2. 상태관리: Zustand 기반 `autotrade-engine-store` 신규.
3. 실시간: 기존 KIS WebSocket 스토어 재사용, 자동매매 엔진 훅으로 연결.
4. 서버 API: Next.js Route Handler(Node 런타임)로 전략/세션/로그/중지 API 제공.
5. 데이터 저장: Supabase Postgres + RLS(행 단위 권한).
6. 인증: Supabase Auth 세션 필수.
7. 보안: KIS 민감정보는 서버 저장 금지, 요청 단위(한 번 호출)로만 처리.
## 2) 배포 구조
1. 앱 배포: Vercel(기존 유지).
2. DB/인증: Supabase(기존 유지).
3. 자동매매 엔진: 브라우저 내부 실행(별도 워커 서버 없음).
4. 서버 역할: 주문 위임, 상태 기록, 위험한도 검증, 감사로그 저장(민감정보 저장 제외).
5. 만료 정리: Vercel Cron(1분 주기) 또는 DB 함수로 heartbeat 만료 세션 `stopped` 전환.
6. 장애 로그: Vercel Logs + Supabase Logs + Sentry(권장) 연동.
## 3) 필수 환경변수
1. `NEXT_PUBLIC_SUPABASE_URL`
2. `NEXT_PUBLIC_SUPABASE_ANON_KEY`
3. `SUPABASE_SERVICE_ROLE_KEY`
4. `AUTOTRADE_HEARTBEAT_TTL_SEC` (기본 90)
5. `AUTOTRADE_MAX_DAILY_ORDERS_DEFAULT` (기본 20)
6. `AUTOTRADE_ONLINE_STRATEGY_ENABLED` (기본 true)
7. `ONLINE_STRATEGY_PROVIDER_KEY` (온라인 수집용 키)
8. `KIS_SERVER_STORAGE_DISABLED` (고정값 `true`, 서버 저장 차단 가드)
## 3-1) KIS 키/계좌 무저장 정책(추가)
1. 저장 금지 대상: `appKey`, `appSecret`, `accountNo`, `accountProductCode`.
2. 서버 DB(Supabase 포함)에는 위 값을 절대 저장하지 않습니다.
3. 서버 로그에도 원문을 남기지 않고 마스킹(일부 가리기) 처리합니다.
4. 자동매매 요청 시 민감정보는 헤더로 전달하고, 요청 처리 후 즉시 폐기합니다.
5. 브라우저 보관은 `sessionStorage` 우선, `localStorage` 영구 저장은 자동매매 모드에서 금지합니다.
6. UI 흐름: 설정 UI 입력 -> 메모리/세션 저장 -> API 호출 헤더 전달 -> 서버 즉시 사용 후 폐기.
## 4) 데이터 모델(Supabase)
1. `auto_trade_strategies`
2. 주요 컬럼: `user_id`, `name`, `strategy_source_type(prompt|catalog|online)`, `symbols[]`, `allocation_percent`, `allocation_amount`, `effective_allocation_amount`, `daily_loss_percent`, `daily_loss_amount`, `effective_daily_loss_limit`, `resolved_params(jsonb)`, `status`.
3. `auto_trade_sessions`
4. 주요 컬럼: `strategy_id`, `desired_state`, `runtime_state`, `leader_tab_id`, `last_heartbeat_at`, `started_at`, `ended_at`, `stop_reason`.
5. `auto_trade_order_attempts`
6. 주요 컬럼: `session_id`, `symbol`, `idempotency_key(unique)`, `request_payload`, `response_payload`, `status`, `blocked_reason`.
7. `auto_trade_signal_logs`
8. 주요 컬럼: `session_id`, `signal_payload`, `decision(execute|skip|block)`, `decision_reason`, `source_type`, `risk_grade`.
9. `auto_trade_online_strategies`
10. 주요 컬럼: `title`, `source_url`, `strategy_text`, `fetched_at`, `parser_score`, `risk_grade`, `is_approved`.
11. `auto_trade_audit_logs`
12. 주요 컬럼: `user_id`, `action`, `payload`, `created_at`.
13. `kis_credentials*` 계열 테이블은 만들지 않습니다(무저장 정책).
## 5) API 설계
1. `POST /api/autotrade/strategies/compile`
2. 입력: 프롬프트/온라인 텍스트.
3. 출력: 표준 규칙(JSON) + 검증결과.
4. `POST /api/autotrade/strategies/validate`
5. 출력: 실행 가능 여부, 차단 사유.
6. `GET /api/autotrade/templates`
7. 검수 카탈로그 전략 목록 제공.
8. `POST /api/autotrade/strategies/discover`
9. 온라인 실시간 수집 전략 목록 제공.
10. `POST /api/autotrade/strategies`
11. 전략 저장(배분/손실한도 실적용값 계산 포함).
12. `POST /api/autotrade/sessions/start`
13. 세션 시작 + 리스크 스냅샷 생성.
14. `POST /api/autotrade/sessions/heartbeat`
15. 리더 탭 생존신호 갱신.
16. `POST /api/autotrade/sessions/stop`
17. `reason`: `browser_exit|external_leave|manual|emergency|heartbeat_timeout`.
18. `GET /api/autotrade/sessions/active`
19. 현재 실행 세션/리더 정보 조회.
20. `GET /api/autotrade/sessions/{id}/logs`
21. 신호/주문/오류 로그 조회.
22. 자동매매 관련 API(주문/세션/리스크)는 요청 헤더에 KIS 정보 포함이 필수입니다.
23. 서버는 헤더 값 유효성만 검사하고 DB에는 저장하지 않습니다.
24. 실패 응답/에러 로그에서도 민감정보는 마스킹합니다.
## 5-1) AI 자동매매 설계(추가)
1. 핵심 원칙: AI는 "신호 후보 생성기", 최종 주문 판단은 "규칙 엔진"이 담당.
2. 이유: AI 단독 주문은 일관성(항상 같은 판단)과 추적성이 약해 리스크가 큽니다.
3. AI 입력 데이터:
4. 실시간 체결/호가, 최근 변동성, 거래량, 전략 파라미터, 장 상태(정규장/시간외).
5. AI 출력 데이터:
6. `signal`(buy/sell/hold), `confidence`(신뢰도), `reason`(한 줄 근거), `ttlSec`(신호 유효시간).
7. 실행 흐름:
8. 사용자 전략 선택/프롬프트 입력 -> AI 해석 -> 규칙 JSON 변환 -> 리스크 검증 -> 주문 실행/차단.
9. 온라인 유명 단타 기법 처리:
10. 실시간 수집 -> 정규화(형식 맞추기) -> 위험등급 부여 -> 사용자 선택 -> 검증 통과 시 활성화.
11. AI 장애 대응:
12. AI 응답 지연/실패 시 신규 주문 중지 또는 보수 모드(`hold`) 강제.
13. AI 드리프트(성능 저하) 대응:
14. 최근 N건 성능 추적 후 기준 미달 전략 자동 일시정지.
15. UI 흐름:
16. 전략 화면 -> "AI 제안 받기" 클릭 -> 제안 전략 목록 표시 -> 사용자 선택/수정 -> 저장/시뮬레이션 -> 시작.
17. 운영 기본값:
18. `confidence`가 임계치(예: 0.65) 미만이면 주문 차단.
19. `reason`이 비어 있으면 주문 차단(설명 없는 주문 금지).
20. 동일 종목 반대 신호가 짧은 시간에 반복되면 쿨다운 연장.
## 5-2) 자동매매 설정 팝업 UX(사용자 요청 반영)
1. 진입 흐름:
2. 자동매매 버튼 클릭 -> 자동매매 설정 팝업 오픈 -> 설정 입력 -> "자동매매 시작" 클릭.
3. 팝업 필수 입력:
4. 전략 프롬프트(자유 입력)
5. 유명 기법 선택(복수 선택): ORB(시가 범위 돌파), VWAP 되돌림, 거래량 돌파, 이동평균 교차, 갭 돌파.
6. 투자금 설정: 퍼센트(%) + 금액(원) 동시 입력.
7. 전략별 일일 손실한도: 퍼센트(%) + 금액(원) 동시 입력.
8. 거래 대상: 종목 다중 선택(또는 관심종목 가져오기).
9. 실행 전 검증:
10. AI 해석 결과 미리보기(어떤 근거로 매수/매도할지 요약)
11. 리스크 요약(실적용 투자금, 실적용 손실한도, 예상 최대 주문 수)
12. 동의 체크(브라우저 종료/외부 이탈 시 즉시 중지)
13. 버튼 정책:
14. 필수값 누락 또는 검증 실패 시 시작 버튼 비활성화.
15. 시작 성공 시 상단 고정 배너와 세션 상태 카드 즉시 표시.
## 5-3) AI API 선택 권장안(실행 가능한 추천)
1. 결론:
2. 1차는 OpenAI API를 기본으로 시작하고, 2차에서 Gemini/Claude를 붙일 수 있게 다중 제공자 어댑터(연결 레이어) 구조로 개발합니다.
3. 추천 이유(요약):
4. Structured Outputs(스키마 고정 출력) + Function Calling(함수 호출) 문서/생태계가 성숙해서 자동매매 검증 파이프라인 구성에 유리합니다.
5. 비용/속도 최적화 모델 선택지가 넓어 PoC(개념검증) -> 운영 전환이 쉽습니다.
6. 제공자별 특징:
7. OpenAI: 엄격 모드(`strict`) 기반 함수 스키마 강제가 명확하고, `parallel_tool_calls=false`로 1회 1액션 제어가 쉽습니다.
8. Gemini: 함수 호출 모드(`AUTO`/`ANY`/`NONE`/`VALIDATED`)가 명확하고 JSON 스키마 출력 지원이 좋아 대체 제공자로 적합합니다.
9. Claude: `strict: true` 도구 호출과 구조화 출력이 강점이며, 보조/백업 제공자로 적합합니다.
10. 운영 권장:
11. 1차: OpenAI 단일 운영
12. 2차: OpenAI 실패/지연 시 Gemini 폴백(대체 경로)
13. 3차: Claude까지 확장하는 3중화(고가용성)
## 5-4) AI 판단 -> 주문 실행 파이프라인(실전형)
1. Step 1. 입력 수집:
2. 사용자 프롬프트 + 선택한 유명 기법 + 실시간 시세/호가 + 보유/가용자산 + 리스크 한도.
3. Step 2. AI 해석:
4. AI가 `signal`, `confidence`, `reason`, `ttlSec`, `proposed_order`를 JSON으로 반환.
5. Step 3. 규칙 엔진 검증:
6. 스키마 검증(형식), 정책 검증(리스크), 시장상태 검증(장중 여부), 중복주문 검증(idempotency).
7. Step 4. 주문 결정:
8. 검증 통과 -> KIS 주문 API 호출.
9. 검증 실패 -> 주문 차단 + 사유 로그 기록.
10. Step 5. 사후 평가:
11. 체결/미체결 결과를 AI 평가 입력으로 재사용해 프롬프트/기법 가중치 조정.
## 5-5) AI 호출 프롬프트/출력 표준(권장 JSON)
1. 시스템 프롬프트 핵심:
2. "너는 주문 실행기가 아니라 신호 생성기다. 스키마에 맞는 JSON만 반환하고 설명문은 금지한다."
3. 출력 스키마:
4. `signal`: `buy|sell|hold`
5. `confidence`: `0~1`
6. `reason`: 짧은 한국어 근거
7. `proposed_order`: `{symbol, side, orderType, price, quantity}`
8. `risk_flags`: `string[]`
9. `ttlSec`: 신호 만료 시간
10. 차단 규칙:
11. `confidence < threshold` 또는 `reason` 누락 또는 `risk_flags`에 차단 사유 포함 시 주문 금지.
## 5-6) 서버 무저장 정책과 AI 호출 결합 방식
1. KIS 민감정보(`appKey`, `appSecret`, `accountNo`)는 AI API 호출 입력에 넣지 않습니다.
2. AI에는 가격/지표/포지션 요약 같은 비식별 데이터(개인 식별이 어려운 데이터)만 전달합니다.
3. 실제 주문 직전 단계에서만 브라우저 세션의 KIS 정보로 주문 API를 호출합니다.
4. 서버는 주문 처리 중 헤더를 일시 사용 후 폐기하며 DB/로그 저장을 금지합니다.
5. 에러 로그/감사로그에는 주문 사유와 결과만 남기고 민감값은 마스킹 처리합니다.
## 6) 브라우저 엔진 동작
1. 엔진 상태: `IDLE`, `ARMED`, `RUNNING`, `STOPPING`, `STOPPED`, `ERROR`.
2. 멀티탭 제어: `localStorage` lock + `BroadcastChannel` 동기화.
3. 리더 탭만 주문 실행, 팔로워 탭은 조회 전용.
4. 주문은 틱 이벤트(WebSocket 수신) 기반으로 처리해 백그라운드 타이머 지연 영향을 줄입니다.
5. heartbeat 10초 주기 전송, TTL 90초 초과 시 서버 강제 종료.
6. 새로고침 시 로컬 snapshot으로 이어서 실행.
7. 브라우저 완전 종료 후 재진입 시 자동 재개 금지, `중지 상태`로 복구 후 사용자 재시작 필요.
8. 백그라운드 탭에서도 WebSocket 이벤트 기반으로 신호 계산/주문은 유지합니다.
## 7) 강한 경고/즉시 중지 UX
1. 실행 중 상단 빨간 경고 바 고정: "브라우저/탭 종료 또는 외부 이동 시 자동주문이 즉시 중지됩니다."
2. 외부 링크 클릭 시 사전 모달 강제: "이동하면 자동매매가 중지됩니다. 계속할까요?"
3. 탭 닫기/브라우저 종료는 `beforeunload` 기본 경고 사용.
4. 종료 시퀀스: `STOPPING` 전환 -> 신규 주문 차단 -> `sendBeacon(stop)` -> lock 해제 -> `STOPPED`.
5. 브라우저 보안 제한으로 `beforeunload` 커스텀 문구는 사용하지 않습니다(표준 경고만 가능).
## 8) 자산 배분/손실한도 입력 규칙
1. 투자금 입력: `퍼센트(%)` + `금액(원)` 동시 입력.
2. 실적용 투자금: `min(가용자산*퍼센트, 금액)`.
3. 일일 손실한도 입력: `퍼센트(%)` + `금액(원)` 동시 입력.
4. 실적용 손실한도: `min(전략투자금*퍼센트, 금액)`.
5. UI에 실적용 값 실시간 계산 표시.
6. 유효성 검증: 0보다 큰 값, 최대 퍼센트 상한, 가용자산 초과 금액 차단.
7. UI에 "현재 가용자산 기준 실제 주문 가능 금액"을 즉시 표시합니다.
## 9) 전략 선택 체계(복수선택)
1. 소스 탭 3개: `프롬프트`, `검수 카탈로그`, `온라인 실시간 수집`.
2. 사용자는 소스별 전략을 여러 개 선택해 하나의 실행세트로 저장 가능.
3. 프롬프트 전략: 자연어 입력 -> 컴파일 -> 검증 통과 시 활성화.
4. 카탈로그 전략: 운영 검수 완료 버전만 제공.
5. 온라인 전략: 실시간 수집 결과를 보여주되 검증 통과 전에는 실행 금지.
6. 온라인/프롬프트 전략은 위험등급(`low|mid|high`) 자동 부여 후 실행 제한에 반영.
## 10) 보수적 위험관리 기본값
1. 전략별 일일 손실한도 기본 2%.
2. 전략별 일일 최대 주문 20건.
3. 종목별 주문 쿨다운 60초.
4. 단일 주문 상한: 전략 투자금의 25%.
5. 데이터 지연 5초 초과 시 신규 주문 차단.
6. 연속 실패 3회 시 자동 중지.
7. lock 충돌 2회 이상 시 자동 중지.
8. 비상정지 버튼은 언제나 최상단 고정 노출.
## 11) 구현 파일 범위
1. `features/autotrade/components/*` (전략 선택, 배분 입력, 경고 배너, 실행 상태 패널)
2. `features/autotrade/hooks/useAutotradeEngine.ts`
3. `features/autotrade/stores/use-autotrade-engine-store.ts`
4. `features/autotrade/types/autotrade.types.ts`
5. `app/api/autotrade/**/route.ts`
6. `lib/autotrade/*` (컴파일, 검증, 리스크 게이트, lock 유틸)
7. 기존 `TradeContainer`/`OrderForm`에 자동매매 섹션 통합
8. `features/settings/store/use-kis-runtime-store.ts` 자동매매 모드에서 민감정보 `persist` 제외
9. `app/api/kis/*``app/api/autotrade/*` 민감정보 마스킹 유틸 공통 적용
## 12) 테스트 시나리오
1. 멀티탭 3개에서 리더 1개만 주문하는지 확인.
2. 백그라운드 탭에서 실시간 신호 기반 주문이 유지되는지 확인.
3. 외부 링크 이탈 시 강한 경고 후 즉시 중지되는지 확인.
4. 탭 종료/브라우저 종료에서 `sendBeacon` + TTL 강제종료가 동작하는지 확인.
5. 퍼센트+금액 입력 시 실적용 값이 작은 값으로 계산되는지 확인.
6. 전략별 일일 손실한도 초과 시 즉시 차단되는지 확인.
7. 온라인 전략 검증 실패 시 실행이 막히는지 확인.
8. 새로고침 후 동일 세션이 중복주문 없이 이어지는지 확인.
9. 서버 DB/로그에 KIS 키/계좌 원문이 저장되지 않는지 확인.
10. AI 응답 누락/지연 시 주문이 차단되는지 확인.
11. AI `confidence` 임계치 미만에서 주문 차단되는지 확인.
## 13) 단계별 배포 계획
1. 1주차: DB 마이그레이션 + API 골격 + 타입 정의.
2. 2주차: 브라우저 엔진(lock/heartbeat/stop flow) + 기본 UI.
3. 3주차: 전략 소스 3종(프롬프트/카탈로그/온라인) + 컴파일/검증.
4. 4주차: 리스크 정책 완성 + 통합/E2E + 운영 모니터링.
5. 롤아웃: 기능 플래그로 5% 사용자 -> 30% -> 전체 오픈.
## 14) 수용 기준
1. 실행 중 종료 트리거 발생 시 신규 주문이 즉시 0건이어야 합니다.
2. 멀티탭에서 중복 주문이 발생하지 않아야 합니다.
3. 사용자는 전략별 투자금/손실한도를 퍼센트+금액으로 모두 설정할 수 있어야 합니다.
4. 프롬프트/카탈로그/온라인 전략 복수선택 저장과 실행이 가능해야 합니다.
5. 로그 화면에서 신호-판단-주문-중지 이유가 연결되어 추적 가능해야 합니다.
## 15) 명시적 가정/기본값
1. "다른 페이지 이동"은 외부 도메인 이탈 기준입니다.
2. 앱 내부 라우트 이동은 중지 트리거가 아닙니다.
3. 브라우저가 완전히 종료되면 자동매매는 반드시 중지 상태로 종료됩니다.
4. 브라우저 재진입 시 자동 재개는 하지 않고 사용자 재시작으로만 실행합니다.
5. 온라인 전략은 "실시간 수집 가능"이지만 "검증 통과 후 실행"을 강제합니다.
6. KIS API 키/계좌 정보는 서버 저장을 금지하고 요청 단위 처리만 허용합니다.

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
import {
type KisRealtimeStockTick,
@@ -21,10 +21,13 @@ export function useHoldingsRealtime(holdings: DashboardHoldingItem[]) {
const [realtimeData, setRealtimeData] = useState<
Record<string, KisRealtimeStockTick>
>({});
const { subscribe, connect, isConnected } = useKisWebSocketStore();
const subscribeRef = useRef(useKisWebSocketStore.getState().subscribe);
const connectRef = useRef(useKisWebSocketStore.getState().connect);
const { isConnected } = useKisWebSocketStore();
const uniqueSymbols = useMemo(
() => Array.from(new Set((holdings ?? []).map((item) => item.symbol))).sort(),
() =>
Array.from(new Set((holdings ?? []).map((item) => item.symbol))).sort(),
[holdings],
);
const symbolKey = useMemo(() => uniqueSymbols.join(","), [uniqueSymbols]);
@@ -37,40 +40,42 @@ export function useHoldingsRealtime(holdings: DashboardHoldingItem[]) {
return () => window.clearTimeout(resetTimerId);
}
if (!isConnected) {
connect();
}
connectRef.current();
const unsubs: (() => void)[] = [];
uniqueSymbols.forEach((symbol) => {
const unsub = subscribe(STOCK_REALTIME_TR_ID, symbol, (data) => {
const tick = parseKisRealtimeStockTick(data);
if (tick) {
setRealtimeData((prev) => {
const prevTick = prev[tick.symbol];
if (
prevTick?.currentPrice === tick.currentPrice &&
prevTick?.change === tick.change &&
prevTick?.changeRate === tick.changeRate
) {
return prev;
}
const unsub = subscribeRef.current(
STOCK_REALTIME_TR_ID,
symbol,
(data: string) => {
const tick = parseKisRealtimeStockTick(data);
if (tick) {
setRealtimeData((prev) => {
const prevTick = prev[tick.symbol];
if (
prevTick?.currentPrice === tick.currentPrice &&
prevTick?.change === tick.change &&
prevTick?.changeRate === tick.changeRate
) {
return prev;
}
return {
...prev,
[tick.symbol]: tick,
};
});
}
});
return {
...prev,
[tick.symbol]: tick,
};
});
}
},
);
unsubs.push(unsub);
});
return () => {
unsubs.forEach((unsub) => unsub());
};
}, [symbolKey, uniqueSymbols, connect, subscribe, isConnected]);
}, [symbolKey, uniqueSymbols]);
return { realtimeData, isConnected };
}

View File

@@ -22,7 +22,9 @@ export function useKisWebSocket({
onMessage,
enabled = true,
}: UseKisWebSocketParams) {
const { subscribe, connect, isConnected } = useKisWebSocketStore();
const subscribeRef = useRef(useKisWebSocketStore.getState().subscribe);
const connectRef = useRef(useKisWebSocketStore.getState().connect);
const { isConnected } = useKisWebSocketStore();
const callbackRef = useRef(onMessage);
// 콜백 함수가 바뀌어도 재구독하지 않도록 ref 사용
@@ -34,10 +36,10 @@ export function useKisWebSocket({
if (!enabled || !symbol || !trId) return;
// 연결 시도 (이미 연결 중이면 스토어에서 무시됨)
connect();
connectRef.current();
// 구독 요청
const unsubscribe = subscribe(trId, symbol, (data) => {
const unsubscribe = subscribeRef.current(trId, symbol, (data) => {
callbackRef.current?.(data);
});
@@ -45,7 +47,7 @@ export function useKisWebSocket({
return () => {
unsubscribe();
};
}, [symbol, trId, enabled, connect, subscribe]);
}, [symbol, trId, enabled]);
return { isConnected };
}

View File

@@ -104,7 +104,8 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
// 소켓 생성
// socket 변수에 할당하기 전에 로컬 변수로 제어하여 이벤트 핸들러 클로저 문제 방지
const ws = new WebSocket(`${wsConnection.wsUrl}/tryitout`);
const ws = new WebSocket(wsConnection.wsUrl);
console.log("[KisWebSocket] Connecting to:", wsConnection.wsUrl);
socket = ws;
ws.onopen = () => {
@@ -147,7 +148,7 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
reconnectAttempt += 1;
const delayMs = getReconnectDelayMs(reconnectAttempt);
console.warn(
`[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"}) -> reconnect in ${delayMs}ms (attempt ${reconnectAttempt}/${MAX_AUTO_RECONNECT_ATTEMPTS})`,
`[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"}, wasClean=${event.wasClean}) -> reconnect in ${delayMs}ms (attempt ${reconnectAttempt}/${MAX_AUTO_RECONNECT_ATTEMPTS})`,
);
window.clearTimeout(reconnectRetryTimer);
@@ -158,7 +159,10 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
return;
}
if (hasSubscribers && reconnectAttempt >= MAX_AUTO_RECONNECT_ATTEMPTS) {
if (
hasSubscribers &&
reconnectAttempt >= MAX_AUTO_RECONNECT_ATTEMPTS
) {
set({
error:
"실시간 연결이 반복 종료되어 자동 재연결을 중단했습니다. 새로고침 또는 수동 재연결을 시도해 주세요.",
@@ -175,7 +179,13 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
ws.onerror = (event) => {
if (socket === ws) {
isConnecting = false;
console.error("[KisWebSocket] Error", event);
const errEvent = event as ErrorEvent;
console.error("[KisWebSocket] Error", {
type: event.type,
message: errEvent?.message,
url: ws.url,
readyState: ws.readyState,
});
set({
isConnected: false,
error: "웹소켓 연결 중 오류가 발생했습니다.",
@@ -207,15 +217,24 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
});
// KIS 제어 메시지: ALREADY IN USE appkey
// 이전 세션이 닫히기 전에 재연결될 때 간헐적으로 발생합니다.
// 이전 세션이 닫히기 전에 재연결될 때 발생합니다.
// KIS 서버가 세션을 정리하는 데 최소 10~30초가 필요하므로
// 충분한 대기 후 재연결합니다.
if (control.msgCd === "OPSP8996") {
const now = Date.now();
if (now - lastAppKeyConflictAt > 5_000) {
lastAppKeyConflictAt = now;
console.warn(
"[KisWebSocket] ALREADY IN USE appkey - 현재 소켓을 닫고 30초 후 재연결합니다.",
);
// 현재 소켓을 즉시 닫아 서버 측 세션 정리 유도
if (socket === ws && ws.readyState === WebSocket.OPEN) {
ws.close(1000, "ALREADY IN USE - graceful close");
}
window.clearTimeout(reconnectRetryTimer);
reconnectRetryTimer = window.setTimeout(() => {
void get().reconnect({ refreshApproval: false });
}, 1_200);
}, 30_000); // 30초 쿨다운
}
}
@@ -255,9 +274,19 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
reconnect: async (options) => {
const refreshApproval = options?.refreshApproval ?? false;
// disconnect()는 manualDisconnectRequested=true를 설정하므로 직접 호출 금지
// 대신 소켓만 직접 닫습니다.
manualDisconnectRequested = false;
window.clearTimeout(reconnectRetryTimer);
reconnectRetryTimer = undefined;
const currentSocket = socket;
get().disconnect();
if (
currentSocket &&
(currentSocket.readyState === WebSocket.OPEN ||
currentSocket.readyState === WebSocket.CONNECTING)
) {
currentSocket.close();
}
if (currentSocket && currentSocket.readyState !== WebSocket.CLOSED) {
await waitForSocketClose(currentSocket);
}
@@ -277,7 +306,10 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
) {
currentSocket.close();
}
if (currentSocket?.readyState === WebSocket.CLOSED && socket === currentSocket) {
if (
currentSocket?.readyState === WebSocket.CLOSED &&
socket === currentSocket
) {
socket = null;
}
set({ isConnected: false });
@@ -306,11 +338,6 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
}
subscriberCounts.set(key, currentCount + 1);
// **연결이 안 되어 있으면 연결 시도**
if (!socket || socket.readyState !== WebSocket.OPEN) {
get().connect();
}
// 3. 구독 해제 함수 반환
return () => {
const callbacks = subscribers.get(key);
@@ -414,7 +441,9 @@ function buildControlErrorMessage(message: KisWsControlMessage) {
return "실시간 연결이 다른 세션과 충돌해 재연결을 시도합니다.";
}
const detail = [message.msg1, message.msgCd].filter(Boolean).join(" / ");
return detail ? `실시간 제어 메시지 오류: ${detail}` : "실시간 제어 메시지 오류";
return detail
? `실시간 제어 메시지 오류: ${detail}`
: "실시간 제어 메시지 오류";
}
/**
@@ -530,7 +559,8 @@ function dispatchRealtimeMessageToSubscribers(
if (subscribedTrId !== trId) return;
if (!normalizedIncomingSymbol) return;
const normalizedSubscribedSymbol = normalizeRealtimeSymbol(subscribedSymbol);
const normalizedSubscribedSymbol =
normalizeRealtimeSymbol(subscribedSymbol);
if (!normalizedSubscribedSymbol) return;
if (normalizedIncomingSymbol !== normalizedSubscribedSymbol) return;

View File

@@ -1,9 +1,11 @@
"use client";
import { type FormEvent, useCallback, useEffect, useState } from "react";
import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useShallow } from "zustand/react/shallow";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { fetchDashboardBalance } from "@/features/dashboard/apis/dashboard.api";
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
import { TradeAccessGate } from "@/features/trade/components/guards/TradeAccessGate";
import { TradeDashboardContent } from "@/features/trade/components/layout/TradeDashboardContent";
@@ -36,6 +38,8 @@ export function TradeContainer() {
// [State] 호가 실시간 데이터 (체결 WS와 동일 소켓에서 수신)
const [realtimeOrderBook, setRealtimeOrderBook] =
useState<DashboardStockOrderBookResponse | null>(null);
// [State] 선택 종목과 매칭할 보유 종목 목록
const [holdings, setHoldings] = useState<DashboardHoldingItem[]>([]);
const { verifiedCredentials, isKisVerified, _hasHydrated } =
useKisRuntimeStore(
useShallow((state) => ({
@@ -60,6 +64,7 @@ export function TradeContainer() {
} = useStockSearch();
const { selectedStock, loadOverview, updateRealtimeTradeTick } =
useStockOverview();
const selectedSymbol = selectedStock?.symbol;
/**
* [Effect] query string이 남아 들어온 경우 즉시 깨끗한 /trade URL로 정리합니다.
@@ -83,7 +88,7 @@ export function TradeContainer() {
const pendingTarget = consumePendingTarget();
if (!pendingTarget) return;
if (selectedStock?.symbol === pendingTarget.symbol) {
if (selectedSymbol === pendingTarget.symbol) {
return;
}
@@ -103,7 +108,7 @@ export function TradeContainer() {
verifiedCredentials,
_hasHydrated,
consumePendingTarget,
selectedStock?.symbol,
selectedSymbol,
loadOverview,
setKeyword,
appendSearchHistory,
@@ -112,6 +117,54 @@ export function TradeContainer() {
const canTrade = isKisVerified && !!verifiedCredentials;
const canSearch = canTrade;
/**
* @description 상단 보유 요약 노출을 위해 잔고를 조회합니다.
* @summary UI 흐름: TradeContainer -> loadHoldingsSnapshot -> fetchDashboardBalance -> holdings 상태 업데이트
* @see features/dashboard/apis/dashboard.api.ts - fetchDashboardBalance 잔고 API를 재사용합니다.
*/
const loadHoldingsSnapshot = useCallback(async () => {
if (!verifiedCredentials?.accountNo?.trim()) {
setHoldings([]);
return;
}
try {
const balance = await fetchDashboardBalance(verifiedCredentials);
setHoldings(balance.holdings);
} catch {
// 상단 요약은 보조 정보이므로 실패 시 화면 전체를 막지 않고 빈 상태로 처리합니다.
setHoldings([]);
}
}, [verifiedCredentials]);
/**
* [Effect] 보유종목 스냅샷 주기 갱신
* @remarks UI 흐름: trade 진입 -> 잔고 조회 -> selectedStock과 symbol 매칭 -> 상단 보유수량/손익 표기
*/
useEffect(() => {
if (!canTrade || !verifiedCredentials?.accountNo?.trim()) {
return;
}
const initialTimerId = window.setTimeout(() => {
void loadHoldingsSnapshot();
}, 0);
const intervalId = window.setInterval(() => {
void loadHoldingsSnapshot();
}, 60_000);
return () => {
window.clearTimeout(initialTimerId);
window.clearInterval(intervalId);
};
}, [canTrade, verifiedCredentials?.accountNo, loadHoldingsSnapshot]);
const matchedHolding = useMemo(() => {
if (!canTrade || !selectedSymbol) return null;
return holdings.find((item) => item.symbol === selectedSymbol) ?? null;
}, [canTrade, holdings, selectedSymbol]);
const {
searchShellRef,
isSearchPanelOpen,
@@ -142,12 +195,12 @@ export function TradeContainer() {
// 1. Trade WebSocket (체결 + 호가 통합)
const { latestTick, recentTradeTicks } = useKisTradeWebSocket(
selectedStock?.symbol,
selectedSymbol,
verifiedCredentials,
isKisVerified,
updateRealtimeTradeTick,
{
orderBookSymbol: selectedStock?.symbol,
orderBookSymbol: selectedSymbol,
orderBookMarket: selectedStock?.market,
onOrderBookMessage: handleOrderBookMessage,
},
@@ -155,12 +208,12 @@ export function TradeContainer() {
// 2. OrderBook (REST 초기 조회 + WS 실시간 병합)
const { orderBook, isLoading: isOrderBookLoading } = useOrderBook(
selectedStock?.symbol,
selectedSymbol,
selectedStock?.market,
verifiedCredentials,
isKisVerified,
{
enabled: !!selectedStock && !!verifiedCredentials && isKisVerified,
enabled: !!selectedSymbol && !!verifiedCredentials && isKisVerified,
externalRealtimeOrderBook: realtimeOrderBook,
},
);
@@ -210,7 +263,7 @@ export function TradeContainer() {
if (!ensureSearchReady() || !verifiedCredentials) return;
// 같은 종목 재선택 시 중복 overview 요청을 막고 검색 목록만 닫습니다.
if (selectedStock?.symbol === item.symbol) {
if (selectedSymbol === item.symbol) {
clearSearch();
closeSearchPanel();
return;
@@ -227,7 +280,7 @@ export function TradeContainer() {
[
ensureSearchReady,
verifiedCredentials,
selectedStock?.symbol,
selectedSymbol,
clearSearch,
closeSearchPanel,
setKeyword,
@@ -250,14 +303,18 @@ export function TradeContainer() {
}
return (
<div className="relative h-full flex flex-col">
<div className="relative flex h-full min-h-0 flex-col overflow-hidden xl:h-[calc(100dvh-4rem)]">
{/* ========== SEARCH SECTION ========== */}
<TradeSearchSection
canSearch={canSearch}
isSearchPanelOpen={isSearchPanelOpen}
isSearching={isSearching}
keyword={keyword}
selectedSymbol={selectedStock?.symbol}
selectedStock={selectedStock}
selectedSymbol={selectedSymbol}
currentPrice={currentPrice}
change={change}
changeRate={changeRate}
searchResults={searchResults}
searchHistory={searchHistory}
searchShellRef={searchShellRef}
@@ -280,9 +337,7 @@ export function TradeContainer() {
orderBook={orderBook}
isOrderBookLoading={isOrderBookLoading}
referencePrice={referencePrice}
currentPrice={currentPrice}
change={change}
changeRate={changeRate}
matchedHolding={matchedHolding}
/>
</div>
);

View File

@@ -37,6 +37,7 @@ import {
const UP_COLOR = "#ef4444";
const MINUTE_SYNC_INTERVAL_MS = 30000;
const REALTIME_STALE_THRESHOLD_MS = 12000;
const CHART_MIN_HEIGHT = 220;
interface ChartPalette {
backgroundColor: string;
@@ -60,7 +61,10 @@ const DEFAULT_CHART_PALETTE: ChartPalette = {
function readCssVar(name: string, fallback: string) {
if (typeof window === "undefined") return fallback;
const value = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim();
const value = window
.getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim();
return value || fallback;
}
@@ -69,16 +73,28 @@ function getChartPaletteFromCssVars(themeMode: "light" | "dark"): ChartPalette {
const backgroundVar = isDark
? "--brand-chart-background-dark"
: "--brand-chart-background-light";
const textVar = isDark ? "--brand-chart-text-dark" : "--brand-chart-text-light";
const borderVar = isDark ? "--brand-chart-border-dark" : "--brand-chart-border-light";
const gridVar = isDark ? "--brand-chart-grid-dark" : "--brand-chart-grid-light";
const textVar = isDark
? "--brand-chart-text-dark"
: "--brand-chart-text-light";
const borderVar = isDark
? "--brand-chart-border-dark"
: "--brand-chart-border-light";
const gridVar = isDark
? "--brand-chart-grid-dark"
: "--brand-chart-grid-light";
const crosshairVar = isDark
? "--brand-chart-crosshair-dark"
: "--brand-chart-crosshair-light";
return {
backgroundColor: readCssVar(backgroundVar, DEFAULT_CHART_PALETTE.backgroundColor),
downColor: readCssVar("--brand-chart-down", DEFAULT_CHART_PALETTE.downColor),
backgroundColor: readCssVar(
backgroundVar,
DEFAULT_CHART_PALETTE.backgroundColor,
),
downColor: readCssVar(
"--brand-chart-down",
DEFAULT_CHART_PALETTE.downColor,
),
volumeDownColor: readCssVar(
"--brand-chart-volume-down",
DEFAULT_CHART_PALETTE.volumeDownColor,
@@ -237,7 +253,8 @@ export function StockLineChart({
* @see lib/kis/domestic.ts getDomesticChart cursor
*/
const handleLoadMore = useCallback(async () => {
if (!symbol || !credentials || !nextCursor || loadingMoreRef.current) return;
if (!symbol || !credentials || !nextCursor || loadingMoreRef.current)
return;
loadingMoreRef.current = true;
setIsLoadingMore(true);
@@ -284,7 +301,7 @@ export function StockLineChart({
const chart = createChart(container, {
width: Math.max(container.clientWidth, 320),
height: Math.max(container.clientHeight, 340),
height: Math.max(container.clientHeight, CHART_MIN_HEIGHT),
layout: {
background: { type: ColorType.Solid, color: palette.backgroundColor },
textColor: palette.textColor,
@@ -298,7 +315,7 @@ export function StockLineChart({
borderColor: palette.borderColor,
scaleMargins: {
top: 0.08,
bottom: 0.24,
bottom: 0.2,
},
},
grid: {
@@ -372,7 +389,7 @@ export function StockLineChart({
const resizeObserver = new ResizeObserver(() => {
chart.resize(
Math.max(container.clientWidth, 320),
Math.max(container.clientHeight, 340),
Math.max(container.clientHeight, CHART_MIN_HEIGHT),
);
});
resizeObserver.observe(container);
@@ -380,7 +397,7 @@ export function StockLineChart({
const rafId = window.requestAnimationFrame(() => {
chart.resize(
Math.max(container.clientWidth, 320),
Math.max(container.clientHeight, 340),
Math.max(container.clientHeight, CHART_MIN_HEIGHT),
);
});
@@ -452,7 +469,9 @@ export function StockLineChart({
if (disposed) return;
let mergedBars = normalizeCandles(firstPage.candles, timeframe);
let resolvedNextCursor = firstPage.hasMore ? firstPage.nextCursor : null;
let resolvedNextCursor = firstPage.hasMore
? firstPage.nextCursor
: null;
// 분봉은 기본 3페이지까지 순차 조회해 오전 구간 누락을 줄입니다.
if (
@@ -474,7 +493,9 @@ export function StockLineChart({
const olderBars = normalizeCandles(olderPage.candles, timeframe);
mergedBars = mergeBars(olderBars, mergedBars);
resolvedNextCursor = olderPage.hasMore ? olderPage.nextCursor : null;
resolvedNextCursor = olderPage.hasMore
? olderPage.nextCursor
: null;
minuteCursor = olderPage.hasMore ? olderPage.nextCursor : null;
extraPageCount += 1;
} catch {
@@ -522,11 +543,11 @@ export function StockLineChart({
}
}, [isChartReady, renderableBars, setSeriesData]);
/**
* @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다.
* @see features/trade/hooks/useKisTradeWebSocket.ts latestTick
* @see features/trade/components/chart/chart-utils.ts toRealtimeTickBar
*/
/**
* @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다.
* @see features/trade/hooks/useKisTradeWebSocket.ts latestTick
* @see features/trade/components/chart/chart-utils.ts toRealtimeTickBar
*/
useEffect(() => {
if (!latestTick) return;
if (bars.length === 0) return;
@@ -600,7 +621,7 @@ export function StockLineChart({
})();
return (
<div className="flex h-full min-h-[340px] flex-col bg-white dark:bg-brand-900/10">
<div className="flex h-full min-h-[280px] flex-col bg-white dark:bg-brand-900/10 xl:min-h-0">
{/* ========== CHART TOOLBAR ========== */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-brand-100 bg-muted/20 px-2 py-2 sm:px-3 dark:border-brand-800/45 dark:bg-brand-900/35">
<div className="flex flex-wrap items-center gap-1 text-xs sm:text-sm">
@@ -668,14 +689,15 @@ export function StockLineChart({
</div>
<div className="text-[11px] text-muted-foreground dark:text-brand-100/85 sm:text-xs">
O {formatPrice(latest?.open ?? 0)} H {formatPrice(latest?.high ?? 0)} L{" "}
{formatPrice(latest?.low ?? 0)} C{" "}
O {formatPrice(latest?.open ?? 0)} H {formatPrice(latest?.high ?? 0)}{" "}
L {formatPrice(latest?.low ?? 0)} C{" "}
<span
className={cn(
change >= 0 ? "text-red-600" : "text-blue-600 dark:text-blue-400",
)}
>
{formatPrice(latest?.close ?? 0)} ({formatSignedPercent(changeRate)})
{formatPrice(latest?.close ?? 0)} ({formatSignedPercent(changeRate)}
)
</span>
</div>
</div>

View File

@@ -1,6 +1,6 @@
// import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { DashboardStockItem } from "@/features/trade/types/trade.types";
import type { DashboardStockItem } from "@/features/trade/types/trade.types";
import { cn } from "@/lib/utils";
interface StockHeaderProps {
@@ -13,6 +13,10 @@ interface StockHeaderProps {
volume?: string;
}
/**
* @description 선택된 종목의 현재가/등락/시세 요약 헤더를 렌더링합니다.
* @see features/trade/components/layout/TradeDashboardContent.tsx - StockHeader 사용 (header prop으로 전달)
*/
export function StockHeader({
stock,
price,
@@ -22,68 +26,154 @@ export function StockHeader({
low,
volume,
}: StockHeaderProps) {
const isRise = changeRate.startsWith("+") || parseFloat(changeRate) > 0;
const isFall = changeRate.startsWith("-") || parseFloat(changeRate) < 0;
const changeRateNum = parseFloat(changeRate);
const isRise = changeRateNum > 0;
const isFall = changeRateNum < 0;
const colorClass = isRise
? "text-red-500"
: isFall
? "text-blue-600 dark:text-blue-400"
: "text-foreground";
const bgGlowClass = isRise
? "from-red-500/10 to-transparent dark:from-red-500/15"
: isFall
? "from-blue-500/10 to-transparent dark:from-blue-500/15"
: "from-brand-500/10 to-transparent";
// 전일종가 계산 (현재가 - 변동액)
const prevClose =
stock.prevClose > 0 ? stock.prevClose.toLocaleString("ko-KR") : "--";
const open = stock.open > 0 ? stock.open.toLocaleString("ko-KR") : "--";
return (
<div className="bg-white px-3 py-1.5 dark:bg-brand-900/22 sm:px-4 sm:py-2">
<div className="bg-white px-3 py-2 dark:bg-brand-900/22 sm:px-4">
{/* ========== STOCK SUMMARY ========== */}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="truncate text-base font-bold leading-tight text-foreground dark:text-brand-50 sm:text-lg">
{stock.name}
</h1>
{/* 종목명 + 코드 */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h1 className="truncate text-base font-bold leading-tight text-foreground dark:text-brand-50 sm:text-lg">
{stock.name}
</h1>
<span className="shrink-0 rounded border border-brand-200/60 bg-brand-50/50 px-1.5 py-0.5 text-[10px] font-medium text-brand-600 dark:border-brand-700/45 dark:bg-brand-900/30 dark:text-brand-200">
{stock.market}
</span>
</div>
<span className="mt-0.5 block text-[11px] text-muted-foreground dark:text-brand-100/70 sm:text-xs">
{stock.symbol}/{stock.market}
{stock.symbol}
</span>
</div>
<div className={cn("shrink-0 text-right", colorClass)}>
<span className="block text-xl font-bold tracking-tight sm:text-2xl">{price}</span>
<span className="text-[11px] font-medium sm:text-xs">
{changeRate}% <span className="ml-1 text-[11px] sm:text-xs">{change}</span>
{/* 현재가 + 등락 */}
<div
className={cn(
"shrink-0 rounded-lg bg-linear-to-l px-3 py-1.5 text-right",
bgGlowClass,
)}
>
<span
className={cn(
"block text-xl font-bold tracking-tight tabular-nums sm:text-2xl",
colorClass,
)}
>
{price}
</span>
<div className="flex items-center justify-end gap-1.5">
<span
className={cn(
"text-[11px] font-medium tabular-nums sm:text-xs",
colorClass,
)}
>
{isRise ? "▲" : isFall ? "▼" : ""}
{changeRate}%
</span>
<span
className={cn("text-[11px] tabular-nums sm:text-xs", colorClass)}
>
{isRise && "+"}
{change}
</span>
</div>
</div>
</div>
{/* ========== STATS ========== */}
<div className="mt-1.5 grid grid-cols-3 gap-2 text-xs md:hidden">
<div className="rounded-md bg-muted/40 px-2 py-1.5 dark:border dark:border-brand-800/45 dark:bg-brand-900/25">
<p className="text-[11px] text-muted-foreground dark:text-brand-100/70"></p>
<p className="font-medium text-red-500">{high || "--"}</p>
</div>
<div className="rounded-md bg-muted/40 px-2 py-1.5 dark:border dark:border-brand-800/45 dark:bg-brand-900/25">
<p className="text-[11px] text-muted-foreground dark:text-brand-100/70"></p>
<p className="font-medium text-blue-600 dark:text-blue-400">{low || "--"}</p>
</div>
<div className="rounded-md bg-muted/40 px-2 py-1.5 dark:border dark:border-brand-800/45 dark:bg-brand-900/25">
<p className="text-[11px] text-muted-foreground dark:text-brand-100/70">(24H)</p>
<p className="font-medium">{volume || "--"}</p>
</div>
{/* ========== MOBILE STATS ========== */}
<div className="mt-2 grid grid-cols-3 gap-1.5 text-xs md:hidden">
<StatCard label="고가" value={high || "--"} tone="ask" />
<StatCard label="저가" value={low || "--"} tone="bid" />
<StatCard label="거래량" value={volume || "--"} />
</div>
<Separator className="mt-1.5 md:hidden" />
{/* ========== DESKTOP STATS ========== */}
<div className="hidden items-center justify-end gap-5 pt-1 text-sm md:flex">
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs dark:text-brand-100/70"></span>
<span className="font-medium text-red-500">{high || "--"}</span>
</div>
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs dark:text-brand-100/70"></span>
<span className="font-medium text-blue-600 dark:text-blue-400">{low || "--"}</span>
</div>
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs dark:text-brand-100/70">(24H)</span>
<span className="font-medium">{volume || "--"}</span>
</div>
<div className="hidden items-center justify-end gap-4 pt-1.5 md:flex">
<DesktopStat label="전일종가" value={prevClose} />
<DesktopStat label="시가" value={open} />
<DesktopStat label="고가" value={high || "--"} tone="ask" />
<DesktopStat label="저가" value={low || "--"} tone="bid" />
<DesktopStat label="거래량" value={volume ? `${volume}` : "--"} />
</div>
</div>
);
}
/** 모바일 통계 카드 */
function StatCard({
label,
value,
tone,
}: {
label: string;
value: string;
tone?: "ask" | "bid";
}) {
return (
<div className="rounded-md bg-muted/40 px-2 py-1.5 dark:border dark:border-brand-800/45 dark:bg-brand-900/25">
<p className="text-[11px] text-muted-foreground dark:text-brand-100/70">
{label}
</p>
<p
className={cn(
"font-semibold",
tone === "ask" && "text-red-500",
tone === "bid" && "text-blue-600 dark:text-blue-400",
)}
>
{value}
</p>
</div>
);
}
/** 데스크톱 통계 항목 */
function DesktopStat({
label,
value,
tone,
}: {
label: string;
value: string;
tone?: "ask" | "bid";
}) {
return (
<div className="flex flex-col items-end">
<span className="text-[11px] text-muted-foreground dark:text-brand-100/70">
{label}
</span>
<span
className={cn(
"text-sm font-semibold tabular-nums",
tone === "ask" && "text-red-500",
tone === "bid" && "text-blue-600 dark:text-blue-400",
!tone && "text-foreground dark:text-brand-50",
)}
>
{value}
</span>
</div>
);
}

View File

@@ -0,0 +1,311 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { RefreshCw, TrendingDown, TrendingUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { fetchDashboardBalance } from "@/features/dashboard/apis/dashboard.api";
import type {
DashboardBalanceSummary,
DashboardHoldingItem,
} from "@/features/dashboard/types/dashboard.types";
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import { cn } from "@/lib/utils";
interface HoldingsPanelProps {
credentials: KisRuntimeCredentials;
}
/** 천단위 포맷 */
function fmt(v: number) {
return Number.isFinite(v) ? v.toLocaleString("ko-KR") : "0";
}
/** 수익률 색상 */
function profitClass(v: number) {
if (v > 0) return "text-red-500";
if (v < 0) return "text-blue-600 dark:text-blue-400";
return "text-muted-foreground";
}
/**
* @description 매매창 하단에 보유 종목 및 평가손익 현황을 표시합니다.
* @see features/trade/components/layout/TradeDashboardContent.tsx - holdingsPanel prop으로 DashboardLayout에 전달
* @see features/dashboard/apis/dashboard.api.ts - fetchDashboardBalance API 호출
*/
export function HoldingsPanel({ credentials }: HoldingsPanelProps) {
// [State] 잔고/보유종목 데이터
const [summary, setSummary] = useState<DashboardBalanceSummary | null>(null);
const [holdings, setHoldings] = useState<DashboardHoldingItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isExpanded, setIsExpanded] = useState(true);
/**
* UI 흐름: HoldingsPanel 마운트 or 새로고침 버튼 -> loadBalance -> fetchDashboardBalance API ->
* 응답 -> summary/holdings 상태 업데이트 -> 테이블 렌더링
*/
const loadBalance = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await fetchDashboardBalance(credentials);
setSummary(data.summary);
setHoldings(data.holdings);
} catch (err) {
setError(
err instanceof Error
? err.message
: "잔고 조회 중 오류가 발생했습니다.",
);
} finally {
setIsLoading(false);
}
}, [credentials]);
// [Effect] 컴포넌트 마운트 시 잔고 조회
useEffect(() => {
loadBalance();
}, [loadBalance]);
return (
<div className="bg-white dark:bg-brand-900/20">
{/* ========== HOLDINGS HEADER ========== */}
<div className="flex items-center justify-between border-b border-border px-4 py-2 dark:border-brand-800/45">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setIsExpanded((prev) => !prev)}
className="flex items-center gap-2 text-sm font-semibold text-foreground dark:text-brand-50 hover:text-brand-600 dark:hover:text-brand-300 transition-colors"
>
<span className="text-brand-500"></span>
<span className="text-xs font-normal text-muted-foreground dark:text-brand-100/60">
({holdings.length})
</span>
</button>
{/* 요약 배지: 수익/손실 */}
{summary && !isLoading && (
<div
className={cn(
"flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold",
summary.totalProfitLoss >= 0
? "bg-red-50 text-red-600 dark:bg-red-900/25 dark:text-red-400"
: "bg-blue-50 text-blue-600 dark:bg-blue-900/25 dark:text-blue-400",
)}
>
{summary.totalProfitLoss >= 0 ? (
<TrendingUp className="h-3 w-3" />
) : (
<TrendingDown className="h-3 w-3" />
)}
{summary.totalProfitLoss >= 0 ? "+" : ""}
{fmt(summary.totalProfitLoss)}&nbsp;(
{summary.totalProfitRate >= 0 ? "+" : ""}
{summary.totalProfitRate.toFixed(2)}%)
</div>
)}
</div>
{/* 새로고침 버튼 */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={loadBalance}
disabled={isLoading}
className="h-7 gap-1 px-2 text-[11px] text-muted-foreground hover:text-brand-600 dark:text-brand-100/60 dark:hover:text-brand-300"
>
<RefreshCw
className={cn("h-3.5 w-3.5", isLoading && "animate-spin")}
/>
</Button>
</div>
{/* ========== HOLDINGS CONTENT ========== */}
{isExpanded && (
<div>
{/* 요약 바 */}
{summary && !isLoading && (
<div className="grid grid-cols-2 gap-2 border-b border-border/50 bg-muted/10 px-4 py-2 dark:border-brand-800/35 dark:bg-brand-900/15 sm:grid-cols-4">
<SummaryItem
label="총 평가금액"
value={`${fmt(summary.evaluationAmount)}`}
/>
<SummaryItem
label="총 매입금액"
value={`${fmt(summary.purchaseAmount)}`}
/>
<SummaryItem
label="평가손익"
value={`${summary.totalProfitLoss >= 0 ? "+" : ""}${fmt(summary.totalProfitLoss)}`}
tone={
summary.totalProfitLoss > 0
? "profit"
: summary.totalProfitLoss < 0
? "loss"
: "neutral"
}
/>
<SummaryItem
label="수익률"
value={`${summary.totalProfitRate >= 0 ? "+" : ""}${summary.totalProfitRate.toFixed(2)}%`}
tone={
summary.totalProfitRate > 0
? "profit"
: summary.totalProfitRate < 0
? "loss"
: "neutral"
}
/>
</div>
)}
{/* 로딩 상태 */}
{isLoading && <HoldingsSkeleton />}
{/* 에러 상태 */}
{!isLoading && error && (
<div className="flex items-center justify-center px-4 py-6 text-sm text-muted-foreground dark:text-brand-100/60">
<span className="mr-2 text-destructive"></span>
{error}
</div>
)}
{/* 보유 종목 없음 */}
{!isLoading && !error && holdings.length === 0 && (
<div className="flex items-center justify-center px-4 py-6 text-sm text-muted-foreground dark:text-brand-100/60">
.
</div>
)}
{/* 보유 종목 테이블 */}
{!isLoading && !error && holdings.length > 0 && (
<div className="overflow-x-auto">
{/* 테이블 헤더 */}
<div className="grid min-w-[600px] grid-cols-[2fr_1fr_1fr_1fr_1fr_1fr] border-b border-border/50 bg-muted/15 px-4 py-1.5 text-[11px] font-medium text-muted-foreground dark:border-brand-800/35 dark:bg-brand-900/20 dark:text-brand-100/65">
<div></div>
<div className="text-right"></div>
<div className="text-right"></div>
<div className="text-right"></div>
<div className="text-right"></div>
<div className="text-right"></div>
</div>
{/* 종목 행 */}
{holdings.map((holding) => (
<HoldingRow key={holding.symbol} holding={holding} />
))}
</div>
)}
</div>
)}
</div>
);
}
/** 요약 항목 */
function SummaryItem({
label,
value,
tone,
}: {
label: string;
value: string;
tone?: "profit" | "loss" | "neutral";
}) {
return (
<div>
<p className="text-[10px] text-muted-foreground dark:text-brand-100/60">
{label}
</p>
<p
className={cn(
"text-xs font-semibold tabular-nums",
tone === "profit" && "text-red-500",
tone === "loss" && "text-blue-600 dark:text-blue-400",
(!tone || tone === "neutral") && "text-foreground dark:text-brand-50",
)}
>
{value}
</p>
</div>
);
}
/** 보유 종목 행 */
function HoldingRow({ holding }: { holding: DashboardHoldingItem }) {
return (
<div className="grid min-w-[600px] grid-cols-[2fr_1fr_1fr_1fr_1fr_1fr] items-center border-b border-border/30 px-4 py-2 text-xs hover:bg-muted/20 dark:border-brand-800/25 dark:hover:bg-brand-900/20">
{/* 종목명 */}
<div className="min-w-0">
<p className="truncate font-medium text-foreground dark:text-brand-50">
{holding.name}
</p>
<p className="text-[10px] text-muted-foreground dark:text-brand-100/55">
{holding.symbol} · {holding.market}
</p>
</div>
{/* 보유수량 */}
<div className="text-right tabular-nums text-foreground dark:text-brand-50">
{fmt(holding.quantity)}
</div>
{/* 평균단가 */}
<div className="text-right tabular-nums text-foreground dark:text-brand-50">
{fmt(holding.averagePrice)}
</div>
{/* 현재가 */}
<div
className={cn(
"text-right tabular-nums font-medium",
profitClass(holding.currentPrice - holding.averagePrice),
)}
>
{fmt(holding.currentPrice)}
</div>
{/* 평가손익 */}
<div
className={cn(
"text-right tabular-nums font-medium",
profitClass(holding.profitLoss),
)}
>
{holding.profitLoss >= 0 ? "+" : ""}
{fmt(holding.profitLoss)}
</div>
{/* 수익률 */}
<div
className={cn(
"text-right tabular-nums font-semibold",
profitClass(holding.profitRate),
)}
>
{holding.profitRate >= 0 ? "+" : ""}
{holding.profitRate.toFixed(2)}%
</div>
</div>
);
}
/** 로딩 스켈레톤 */
function HoldingsSkeleton() {
return (
<div className="space-y-2 px-4 py-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-8 w-24" />
<Skeleton className="h-8 flex-1" />
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-20" />
</div>
))}
</div>
);
}

View File

@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface DashboardLayoutProps {
header: ReactNode;
header?: ReactNode;
chart: ReactNode;
orderBook: ReactNode;
orderForm: ReactNode;
@@ -14,8 +14,9 @@ interface DashboardLayoutProps {
}
/**
* @description 트레이드 본문 레이아웃을 구성합니다. 상단 차트 영역은 보임/숨김 토글을 지원합니다.
* @see features/trade/components/layout/TradeDashboardContent.tsx 상위 컴포넌트에서 차트 토글 상태를 관리하고 본 레이아웃에 전달합니다.
* @description 트레이드 본문을 업비트 스타일의 2단 레이아웃으로 렌더링합니다.
* @summary UI 흐름: TradeDashboardContent -> DashboardLayout -> 상단(차트) + 하단(호가/주문) 배치
* @see features/trade/components/layout/TradeDashboardContent.tsx - 차트 토글 상태와 슬롯 컴포넌트를 전달합니다.
*/
export function DashboardLayout({
header,
@@ -29,54 +30,46 @@ export function DashboardLayout({
return (
<div
className={cn(
"flex flex-col bg-background dark:bg-[linear-gradient(135deg,rgba(53,35,86,0.18),rgba(13,13,20,0.98))]",
// Mobile: Scrollable page height
"min-h-[calc(100vh-64px)]",
// Desktop: Fixed height, no window scroll
"xl:h-[calc(100vh-64px)] xl:overflow-hidden",
"flex h-full min-h-0 flex-col bg-background dark:bg-[linear-gradient(135deg,rgba(53,35,86,0.18),rgba(13,13,20,0.98))]",
className,
)}
>
{/* 1. Header Area */}
<div className="flex-none border-b border-brand-100 bg-white dark:border-brand-800/45 dark:bg-brand-900/22">
{header}
</div>
{/* ========== 1. OPTIONAL HEADER AREA ========== */}
{header && (
<div className="flex-none border-b border-brand-100 bg-white dark:border-brand-800/45 dark:bg-brand-900/22">
{header}
</div>
)}
{/* 2. Main Content Area */}
<div
className={cn(
"flex-1 min-h-0 overflow-y-auto",
"xl:overflow-hidden",
)}
>
<div className="flex min-h-full flex-col xl:h-full xl:min-h-0">
{/* ========== CHART SECTION ========== */}
<section className="flex-none border-b border-border dark:border-brand-800/45">
<div className="flex items-center justify-between gap-2 bg-muted/20 px-3 py-1.5 dark:bg-brand-900/30 sm:px-4">
<div className="min-w-0">
<p className="text-xs font-semibold text-foreground dark:text-brand-50 sm:text-sm">
{/* ========== 2. MAIN CONTENT AREA ========== */}
<div className="flex-1 min-h-0 overflow-y-auto xl:overflow-hidden">
<div className="flex min-h-full flex-col xl:h-full xl:min-h-0 xl:overflow-hidden">
{/* ========== TOP: CHART AREA ========== */}
<section className="flex min-h-0 flex-col border-b border-border dark:border-brand-800/45 xl:h-[34%] xl:min-h-[200px]">
{/* 모바일 전용 차트 토글 */}
<div className="flex items-center justify-between gap-2 bg-muted/15 px-3 py-1.5 dark:bg-brand-900/25 sm:px-4 xl:hidden">
<div className="flex min-w-0 items-center gap-2">
<span className="h-2 w-2 rounded-full bg-green-400 shadow-[0_0_6px_rgba(74,222,128,0.6)]" />
<p className="text-xs font-semibold text-foreground dark:text-brand-50">
</p>
<p className="text-[10px] text-muted-foreground dark:text-brand-100/70 sm:text-[11px]">
.
</p>
</div>
{/* UI 흐름: 차트 토글 버튼 -> onToggleChart 호출 -> TradeDashboardContent의 상태 변경 -> 차트 wrapper 높이 반영 */}
{/* UI 흐름: 토글 클릭 -> onToggleChart -> 상위 상태 변경 -> 차트 표시/숨김 */}
<Button
type="button"
variant="outline"
size="sm"
onClick={onToggleChart}
className="h-7 gap-1 border-brand-200 bg-white px-2 text-[11px] text-brand-700 hover:bg-brand-50 dark:border-brand-700/55 dark:bg-brand-900/35 dark:text-brand-100 dark:hover:bg-brand-800/35 sm:h-8 sm:px-3 sm:text-xs"
className="h-6 gap-1 border-brand-200 bg-white/70 px-2 text-[11px] text-brand-700 hover:bg-brand-50 dark:border-brand-700/55 dark:bg-brand-900/35 dark:text-brand-100 dark:hover:bg-brand-800/35 sm:h-7 sm:px-3"
aria-expanded={isChartVisible}
>
{isChartVisible ? (
<>
<ChevronUp className="h-3.5 w-3.5" />
<ChevronUp className="h-3 w-3" />
</>
) : (
<>
<ChevronDown className="h-3.5 w-3.5" />
<ChevronDown className="h-3 w-3" />
</>
)}
</Button>
@@ -84,28 +77,28 @@ export function DashboardLayout({
<div
className={cn(
"overflow-hidden border-t border-border/70 transition-[max-height,opacity] duration-200 dark:border-brand-800/45",
isChartVisible ? "max-h-[56vh] opacity-100" : "max-h-0 opacity-0",
"overflow-hidden border-t border-border/70 transition-[max-height,opacity] duration-300 dark:border-brand-800/45 xl:flex-1 xl:min-h-0 xl:max-h-none xl:opacity-100",
isChartVisible ? "max-h-[64vh] opacity-100" : "max-h-0 opacity-0",
)}
>
<div className="h-[34vh] min-h-[280px] w-full sm:h-[40vh] xl:h-[34vh] 2xl:h-[38vh]">
<div className="h-[29vh] min-h-[200px] w-full sm:h-[33vh] xl:h-full xl:min-h-0">
{chart}
</div>
</div>
</section>
{/* ========== ORDERBOOK + ORDER SECTION ========== */}
<div className="flex flex-1 min-h-0 flex-col xl:flex-row xl:overflow-hidden">
<section className="flex min-h-0 flex-col border-b border-border dark:border-brand-800/45 xl:flex-1 xl:border-b-0 xl:border-r">
<div className="h-[390px] min-h-0 sm:h-[430px] xl:h-full">
{/* ========== BOTTOM: ORDERBOOK + ORDER AREA ========== */}
<section className="flex flex-1 min-h-0 flex-col xl:grid xl:grid-cols-[minmax(0,1fr)_480px] 2xl:grid-cols-[minmax(0,1fr)_540px] xl:overflow-hidden">
<div className="flex min-h-0 flex-col border-b border-border dark:border-brand-800/45 xl:border-b-0 xl:border-r">
<div className="min-h-0 xl:h-full xl:min-h-0">
{orderBook}
</div>
</section>
</div>
<section className="flex min-h-0 flex-col bg-background dark:bg-brand-900/12 xl:w-[430px] 2xl:w-[470px]">
<div className="min-h-[320px] xl:h-full">{orderForm}</div>
</section>
</div>
<div className="flex min-h-0 flex-col bg-background dark:bg-brand-900/12">
<div className="min-h-[280px] xl:h-full xl:min-h-0">{orderForm}</div>
</div>
</section>
</div>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { useState } from "react";
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
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";
@@ -14,36 +14,32 @@ import { cn } from "@/lib/utils";
interface TradeDashboardContentProps {
selectedStock: DashboardStockItem | null;
matchedHolding?: DashboardHoldingItem | 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에서 처리합니다.
* @description 트레이드 본문(차트/체결+호가/주문)을 조합하여 렌더링합니다.
* @see features/trade/components/TradeContainer.tsx - TradeDashboardContent 렌더링 (selectedStock, verifiedCredentials 등 전달)
* @see features/trade/components/layout/DashboardLayout.tsx - 3열 레이아웃(차트 | 체결+호가 | 매도)을 처리합니다.
*/
export function TradeDashboardContent({
selectedStock,
matchedHolding,
verifiedCredentials,
latestTick,
recentTradeTicks,
orderBook,
isOrderBookLoading,
referencePrice,
currentPrice,
change,
changeRate,
}: TradeDashboardContentProps) {
// [State] 차트 영역 보임/숨김 상태
const [isChartVisible, setIsChartVisible] = useState(false);
// [State] 차트 영역 보임/숨김 - 요청사항 반영: 모바일에서도 기본 표시
const [isChartVisible, setIsChartVisible] = useState(true);
return (
<div
@@ -54,21 +50,6 @@ export function TradeDashboardContent({
>
{/* ========== 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">
@@ -95,7 +76,12 @@ export function TradeDashboardContent({
isLoading={isOrderBookLoading}
/>
}
orderForm={<OrderForm stock={selectedStock ?? undefined} />}
orderForm={
<OrderForm
stock={selectedStock ?? undefined}
matchedHolding={matchedHolding}
/>
}
isChartVisible={isChartVisible}
onToggleChart={() => setIsChartVisible((prev) => !prev)}
/>

View File

@@ -1,4 +1,7 @@
"use client";
import { useState } from "react";
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -9,28 +12,35 @@ import type {
DashboardOrderSide,
DashboardStockItem,
} from "@/features/trade/types/trade.types";
import { cn } from "@/lib/utils";
interface OrderFormProps {
stock?: DashboardStockItem;
matchedHolding?: DashboardHoldingItem | null;
}
/**
* @description 대시보드 주문 패널에서 매수/매도 주문을 입력하고 전송합니다.
* @see features/trade/hooks/useOrder.ts placeOrder - 주문 API 호출
* @see features/trade/components/TradeContainer.tsx OrderForm - 우측 주문 패널 렌더링
* @see features/trade/hooks/useOrder.ts - placeOrder 주문 API 호출
* @see features/trade/components/layout/TradeDashboardContent.tsx - orderForm prop으로 DashboardLayout에 전달
*/
export function OrderForm({ stock }: OrderFormProps) {
export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
const verifiedCredentials = useKisRuntimeStore(
(state) => state.verifiedCredentials,
);
const { placeOrder, isLoading, error } = useOrder();
// ========== FORM STATE ==========
const [price, setPrice] = useState<string>(stock?.currentPrice.toString() || "");
const [price, setPrice] = useState<string>(
stock?.currentPrice.toString() || "",
);
const [quantity, setQuantity] = useState<string>("");
const [activeTab, setActiveTab] = useState<"buy" | "sell">("buy");
// ========== ORDER HANDLER ==========
/**
* UI 흐름: 매수하기/매도하기 버튼 클릭 -> handleOrder -> placeOrder API 호출 -> 주문번호 반환 -> alert
*/
const handleOrder = async (side: DashboardOrderSide) => {
if (!stock || !verifiedCredentials) return;
@@ -79,34 +89,67 @@ export function OrderForm({ stock }: OrderFormProps) {
};
const isMarketDataAvailable = Boolean(stock);
const isBuy = activeTab === "buy";
return (
<div className="h-full border-l border-border bg-background p-3 dark:border-brand-800/45 dark:bg-brand-950/55 sm:p-4">
<div className="h-full bg-background p-3 dark:bg-brand-950/55 sm:p-4">
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as "buy" | "sell")}
className="flex h-full w-full flex-col"
>
{/* ========== ORDER SIDE TABS ========== */}
<TabsList className="mb-3 grid h-10 w-full grid-cols-2 gap-1 border border-brand-200/70 bg-muted/35 p-1 dark:border-brand-700/50 dark:bg-brand-900/28 sm:mb-4">
<TabsList className="mb-3 grid h-10 w-full grid-cols-2 gap-0.5 rounded-lg border border-border/60 bg-muted/30 p-0.5 dark:border-brand-700/50 dark:bg-brand-900/25 sm:mb-4">
<TabsTrigger
value="buy"
className="!h-full rounded-md border border-transparent text-foreground/75 transition-colors dark:text-brand-100/75 data-[state=active]:border-red-400/60 data-[state=active]:bg-red-600 data-[state=active]:text-white data-[state=active]:shadow-[0_0_0_1px_rgba(248,113,113,0.45)]"
className={cn(
"!h-full rounded-md border border-transparent text-sm font-semibold text-foreground/70 transition-all dark:text-brand-100/70",
"data-[state=active]:border-red-400/50 data-[state=active]:bg-red-600 data-[state=active]:text-white data-[state=active]:shadow-[0_2px_8px_rgba(220,38,38,0.4)]",
)}
>
</TabsTrigger>
<TabsTrigger
value="sell"
className="!h-full rounded-md border border-transparent text-foreground/75 transition-colors dark:text-brand-100/75 data-[state=active]:border-blue-400/65 data-[state=active]:bg-blue-600 data-[state=active]:text-white data-[state=active]:shadow-[0_0_0_1px_rgba(96,165,250,0.45)]"
className={cn(
"!h-full rounded-md border border-transparent text-sm font-semibold text-foreground/70 transition-all dark:text-brand-100/70",
"data-[state=active]:border-blue-400/50 data-[state=active]:bg-blue-600 data-[state=active]:text-white data-[state=active]:shadow-[0_2px_8px_rgba(37,99,235,0.4)]",
)}
>
</TabsTrigger>
</TabsList>
{/* ========== CURRENT PRICE INFO ========== */}
{stock && (
<div
className={cn(
"mb-3 flex items-center justify-between rounded-md border px-3 py-2 text-xs",
isBuy
? "border-red-200/60 bg-red-50/50 dark:border-red-800/35 dark:bg-red-950/25"
: "border-blue-200/60 bg-blue-50/50 dark:border-blue-800/35 dark:bg-blue-950/25",
)}
>
<span className="text-muted-foreground dark:text-brand-100/65">
</span>
<span
className={cn(
"font-bold tabular-nums",
isBuy
? "text-red-600 dark:text-red-400"
: "text-blue-600 dark:text-blue-400",
)}
>
{stock.currentPrice.toLocaleString()}
</span>
</div>
)}
{/* ========== BUY TAB ========== */}
<TabsContent
value="buy"
className="flex flex-1 flex-col space-y-3 data-[state=inactive]:hidden sm:space-y-4"
className="flex flex-1 flex-col space-y-2.5 data-[state=inactive]:hidden sm:space-y-3"
>
<OrderInputs
type="buy"
@@ -120,19 +163,26 @@ export function OrderForm({ stock }: OrderFormProps) {
errorMessage={error}
/>
<PercentButtons onSelect={setPercent} />
<Button
className="mt-auto h-11 w-full bg-red-600 text-base text-white shadow-sm ring-1 ring-red-300/35 hover:bg-red-700 dark:bg-red-500 dark:ring-red-300/45 dark:hover:bg-red-400 sm:h-12 sm:text-lg"
disabled={isLoading || !isMarketDataAvailable}
onClick={() => handleOrder("buy")}
>
{isLoading ? <Loader2 className="mr-2 animate-spin" /> : "매수하기"}
</Button>
<div className="mt-auto space-y-2.5 sm:space-y-3">
<HoldingInfoPanel holding={matchedHolding} />
<Button
className="h-11 w-full rounded-lg bg-red-600 text-base font-bold text-white shadow-[0_4px_14px_rgba(220,38,38,0.4)] ring-1 ring-red-300/30 transition-all hover:bg-red-700 hover:shadow-[0_4px_20px_rgba(220,38,38,0.5)] dark:bg-red-500 dark:ring-red-300/40 dark:hover:bg-red-400 sm:h-12 sm:text-lg"
disabled={isLoading || !isMarketDataAvailable}
onClick={() => handleOrder("buy")}
>
{isLoading ? (
<Loader2 className="mr-2 animate-spin" />
) : (
"매수하기"
)}
</Button>
</div>
</TabsContent>
{/* ========== SELL TAB ========== */}
<TabsContent
value="sell"
className="flex flex-1 flex-col space-y-3 data-[state=inactive]:hidden sm:space-y-4"
className="flex flex-1 flex-col space-y-2.5 data-[state=inactive]:hidden sm:space-y-3"
>
<OrderInputs
type="sell"
@@ -146,13 +196,20 @@ export function OrderForm({ stock }: OrderFormProps) {
errorMessage={error}
/>
<PercentButtons onSelect={setPercent} />
<Button
className="mt-auto h-11 w-full bg-blue-600 text-base text-white shadow-sm ring-1 ring-blue-300/35 hover:bg-blue-700 dark:bg-blue-500 dark:ring-blue-300/45 dark:hover:bg-blue-400 sm:h-12 sm:text-lg"
disabled={isLoading || !isMarketDataAvailable}
onClick={() => handleOrder("sell")}
>
{isLoading ? <Loader2 className="mr-2 animate-spin" /> : "매도하기"}
</Button>
<div className="mt-auto space-y-2.5 sm:space-y-3">
<HoldingInfoPanel holding={matchedHolding} />
<Button
className="h-11 w-full rounded-lg bg-blue-600 text-base font-bold text-white shadow-[0_4px_14px_rgba(37,99,235,0.4)] ring-1 ring-blue-300/30 transition-all hover:bg-blue-700 hover:shadow-[0_4px_20px_rgba(37,99,235,0.5)] dark:bg-blue-500 dark:ring-blue-300/40 dark:hover:bg-blue-400 sm:h-12 sm:text-lg"
disabled={isLoading || !isMarketDataAvailable}
onClick={() => handleOrder("sell")}
>
{isLoading ? (
<Loader2 className="mr-2 animate-spin" />
) : (
"매도하기"
)}
</Button>
</div>
</TabsContent>
</Tabs>
</div>
@@ -161,7 +218,7 @@ export function OrderForm({ stock }: OrderFormProps) {
/**
* @description 주문 입력 영역(가격/수량/총액)을 렌더링합니다.
* @see features/trade/components/order/OrderForm.tsx OrderForm - 매수/매도 탭에서 공용 호출
* @see features/trade/components/order/OrderForm.tsx - OrderForm 매수/매도 탭에서 공용 호출
*/
function OrderInputs({
type,
@@ -184,25 +241,36 @@ function OrderInputs({
hasError: boolean;
errorMessage: string | null;
}) {
const labelClass =
"text-xs font-medium text-foreground/80 dark:text-brand-100/80 min-w-[56px]";
const inputClass =
"col-span-3 h-9 text-right font-mono text-sm dark:border-brand-700/55 dark:bg-black/25 dark:text-brand-100";
return (
<div className="space-y-3 sm:space-y-4">
<div className="flex justify-between text-xs text-muted-foreground">
<span></span>
<span>- {type === "buy" ? "KRW" : "주"}</span>
<div className="space-y-2 sm:space-y-2.5">
{/* 주문 가능 */}
<div className="flex items-center justify-between rounded-md bg-muted/30 px-3 py-1.5 text-xs dark:bg-brand-900/25">
<span className="text-muted-foreground dark:text-brand-100/60">
</span>
<span className="font-medium text-foreground dark:text-brand-50">
- {type === "buy" ? "KRW" : "주"}
</span>
</div>
{hasError && (
<div className="rounded bg-destructive/10 p-2 text-xs text-destructive break-keep">
<div className="rounded-md bg-destructive/10 p-2 text-xs text-destructive break-keep">
{errorMessage}
</div>
)}
{/* 가격 입력 */}
<div className="grid grid-cols-4 items-center gap-2">
<span className="text-xs font-medium sm:text-sm">
<span className={labelClass}>
{type === "buy" ? "매수가격" : "매도가격"}
</span>
<Input
className="col-span-3 text-right font-mono dark:border-brand-700/55 dark:bg-black/25 dark:text-brand-100"
className={inputClass}
placeholder="0"
value={price}
onChange={(e) => setPrice(e.target.value)}
@@ -210,10 +278,11 @@ function OrderInputs({
/>
</div>
{/* 수량 입력 */}
<div className="grid grid-cols-4 items-center gap-2">
<span className="text-xs font-medium sm:text-sm"></span>
<span className={labelClass}></span>
<Input
className="col-span-3 text-right font-mono dark:border-brand-700/55 dark:bg-black/25 dark:text-brand-100"
className={inputClass}
placeholder="0"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
@@ -221,13 +290,15 @@ function OrderInputs({
/>
</div>
{/* 총액 */}
<div className="grid grid-cols-4 items-center gap-2">
<span className="text-xs font-medium sm:text-sm"></span>
<span className={labelClass}></span>
<Input
className="col-span-3 bg-muted/50 text-right font-mono dark:border-brand-700/55 dark:bg-black/20 dark:text-brand-100"
value={totalPrice.toLocaleString()}
className={cn(inputClass, "bg-muted/40 dark:bg-black/20")}
value={totalPrice > 0 ? `${totalPrice.toLocaleString()}` : ""}
readOnly
disabled={disabled}
placeholder="0원"
/>
</div>
</div>
@@ -236,17 +307,17 @@ function OrderInputs({
/**
* @description 주문 비율(10/25/50/100%) 단축 버튼을 표시합니다.
* @see features/trade/components/order/OrderForm.tsx setPercent - 버튼 클릭 이벤트 처리
* @see features/trade/components/order/OrderForm.tsx - OrderForm setPercent 이벤트 처리
*/
function PercentButtons({ onSelect }: { onSelect: (pct: string) => void }) {
return (
<div className="mt-2 grid grid-cols-4 gap-2">
<div className="grid grid-cols-4 gap-1.5">
{["10%", "25%", "50%", "100%"].map((pct) => (
<Button
key={pct}
variant="outline"
size="sm"
className="text-xs"
className="h-8 text-xs font-medium border-border/60 hover:border-brand-300 hover:bg-brand-50/50 hover:text-brand-700 dark:border-brand-700/50 dark:hover:border-brand-500 dark:hover:bg-brand-900/30 dark:hover:text-brand-200"
onClick={() => onSelect(pct)}
>
{pct}
@@ -255,3 +326,80 @@ function PercentButtons({ onSelect }: { onSelect: (pct: string) => void }) {
</div>
);
}
/**
* @description 선택 종목이 보유 상태일 때 주문 패널 하단에 보유 요약을 표시합니다.
* @summary UI 흐름: TradeContainer(matchedHolding 계산) -> TradeDashboardContent -> OrderForm -> HoldingInfoPanel 렌더링
* @see features/trade/components/TradeContainer.tsx - selectedSymbol 기준으로 보유종목 매칭 값을 전달합니다.
*/
function HoldingInfoPanel({
holding,
}: {
holding?: DashboardHoldingItem | null;
}) {
if (!holding) return null;
const profitToneClass = getHoldingProfitToneClass(holding.profitLoss);
return (
<div className="rounded-lg border border-border/65 bg-muted/20 p-3 dark:border-brand-700/45 dark:bg-brand-900/28">
<p className="mb-2 text-xs font-semibold text-foreground dark:text-brand-50">
</p>
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
<HoldingInfoRow label="보유수량" value={`${holding.quantity.toLocaleString("ko-KR")}`} />
<HoldingInfoRow
label="평균단가"
value={`${holding.averagePrice.toLocaleString("ko-KR")}`}
/>
<HoldingInfoRow
label="평가금액"
value={`${holding.evaluationAmount.toLocaleString("ko-KR")}`}
/>
<HoldingInfoRow
label="손익"
value={`${holding.profitLoss >= 0 ? "+" : ""}${holding.profitLoss.toLocaleString("ko-KR")}`}
toneClass={profitToneClass}
/>
<HoldingInfoRow
label="수익률"
value={`${holding.profitRate >= 0 ? "+" : ""}${holding.profitRate.toFixed(2)}%`}
toneClass={profitToneClass}
/>
</div>
</div>
);
}
/**
* @description 보유정보 카드의 단일 라벨/값 행을 렌더링합니다.
* @see features/trade/components/order/OrderForm.tsx HoldingInfoPanel
*/
function HoldingInfoRow({
label,
value,
toneClass,
}: {
label: string;
value: string;
toneClass?: string;
}) {
return (
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="text-muted-foreground dark:text-brand-100/70">{label}</span>
<span className={cn("truncate font-semibold tabular-nums text-foreground dark:text-brand-50", toneClass)}>
{value}
</span>
</div>
);
}
/**
* @description 보유 손익 부호에 따른 색상 클래스를 반환합니다.
* @see features/trade/components/order/OrderForm.tsx HoldingInfoPanel
*/
function getHoldingProfitToneClass(value: number) {
if (value > 0) return "text-red-500 dark:text-red-400";
if (value < 0) return "text-blue-600 dark:text-blue-400";
return "text-foreground dark:text-brand-50";
}

View File

@@ -31,7 +31,9 @@ interface BookRow {
* @description 실시간 호가 레벨 데이터가 실제 값을 포함하는지 검사합니다.
* @see features/trade/components/orderbook/OrderBook.tsx levels 계산에서 WS 호가 유효성 판단에 사용합니다.
*/
function hasOrderBookLevelData(levels: DashboardStockOrderBookResponse["levels"]) {
function hasOrderBookLevelData(
levels: DashboardStockOrderBookResponse["levels"],
) {
return levels.some(
(level) =>
level.askPrice > 0 ||
@@ -45,7 +47,9 @@ function hasOrderBookLevelData(levels: DashboardStockOrderBookResponse["levels"]
* @description H0STOAA0 미수신 상황에서 체결(H0UNCNT0) 데이터로 최소 1호가를 구성합니다.
* @see features/trade/utils/kisRealtimeUtils.ts parseKisRealtimeTickBatch 체결 데이터에서 1호가/잔량/총잔량을 파싱합니다.
*/
function buildFallbackLevelsFromTick(latestTick: DashboardRealtimeTradeTick | null) {
function buildFallbackLevelsFromTick(
latestTick: DashboardRealtimeTradeTick | null,
) {
if (!latestTick) return [] as DashboardStockOrderBookResponse["levels"];
if (latestTick.askPrice1 <= 0 && latestTick.bidPrice1 <= 0) {
return [] as DashboardStockOrderBookResponse["levels"];
@@ -292,6 +296,8 @@ export function OrderBook({
const askMax = Math.max(1, ...askRows.map((r) => r.size));
const bidMax = Math.max(1, ...bidRows.map((r) => r.size));
const mobileAskRows = useMemo(() => askRows.slice(0, 6), [askRows]);
const mobileBidRows = useMemo(() => bidRows.slice(0, 6), [bidRows]);
// 스프레드·수급 불균형
const bestAsk = levels.find((l) => l.askPrice > 0)?.askPrice ?? 0;
@@ -332,10 +338,10 @@ export function OrderBook({
}
return (
<div className="flex h-full min-h-0 flex-col bg-white dark:bg-brand-900/10">
<div className="flex h-full min-h-0 flex-col bg-white dark:bg-[linear-gradient(180deg,rgba(13,13,24,0.95),rgba(8,8,18,0.98))]">
<Tabs defaultValue="normal" className="h-full min-h-0">
{/* 탭 헤더 */}
<div className="border-b px-2 pt-2 dark:border-brand-800/45 dark:bg-brand-900/28">
<div className="border-b border-border/60 bg-muted/15 px-2 pt-2 dark:border-brand-800/50 dark:bg-brand-950/60">
<TabsList variant="line" className="w-full justify-start">
<TabsTrigger value="normal" className="px-3">
@@ -351,71 +357,61 @@ export function OrderBook({
{/* ── 일반호가 탭 ── */}
<TabsContent value="normal" className="min-h-0 flex-1">
<div className="flex h-full min-h-0 flex-col border-t dark:border-brand-800/45 xl:grid xl:grid-cols-[minmax(0,1fr)_320px_168px] xl:overflow-hidden">
<div className="flex h-full min-h-0 flex-col border-t dark:border-brand-800/45 xl:grid xl:grid-cols-[minmax(0,1fr)_220px_220px] 2xl:grid-cols-[minmax(0,1fr)_250px_240px] xl:overflow-hidden">
{/* 호가 테이블 */}
<div className="flex min-h-0 flex-col xl:border-r dark:border-brand-800/45">
{isTickFallbackActive && (
<div className="border-b border-amber-200 bg-amber-50 px-2 py-1 text-[11px] text-amber-700 dark:border-amber-700/40 dark:bg-amber-900/20 dark:text-amber-200">
(`H0STOAA0`) . (`H0UNCNT0`)
1 .
(`H0STOAA0`) .
(`H0UNCNT0`) 1 .
</div>
)}
<BookHeader />
<ScrollArea className="min-h-0 flex-1 [&>[data-slot=scroll-area-scrollbar]]:hidden xl:[&>[data-slot=scroll-area-scrollbar]]:flex">
{/* 매도호가 */}
<div className="xl:hidden">
{/* 모바일: 양방향 호가가 항상 보이도록 6호가씩 고정 노출 */}
<BookSideRows rows={mobileAskRows} side="ask" maxSize={askMax} />
<CurrentPriceBar
latestPrice={latestPrice}
basePrice={basePrice}
bestAsk={bestAsk}
totalAsk={totalAsk}
totalBid={totalBid}
/>
<BookSideRows rows={mobileBidRows} side="bid" maxSize={bidMax} />
</div>
<ScrollArea className="hidden min-h-0 flex-1 xl:block">
{/* 데스크톱: 전체 호가 스크롤 */}
<BookSideRows rows={askRows} side="ask" maxSize={askMax} />
{/* 중앙 바: 현재 체결가 */}
<div className="grid h-8 grid-cols-3 items-center border-y-2 border-amber-400 bg-amber-50/80 dark:bg-amber-900/30 xl:h-9">
<div className="px-2 text-right text-[10px] font-medium text-muted-foreground dark:text-brand-100/72">
{totalAsk > 0 ? fmt(totalAsk) : ""}
</div>
<div className="flex items-center justify-center gap-1">
<span className="text-xs font-bold tabular-nums">
{latestPrice > 0
? fmt(latestPrice)
: bestAsk > 0
? fmt(bestAsk)
: "-"}
</span>
{latestPrice > 0 && basePrice > 0 && (
<span
className={cn(
"text-[10px] font-medium",
latestPrice >= basePrice
? "text-red-500"
: "text-blue-600 dark:text-blue-400",
)}
>
{fmtPct(pctChange(latestPrice, basePrice))}
</span>
)}
</div>
<div className="px-2 text-left text-[10px] font-medium text-muted-foreground dark:text-brand-100/72">
{totalBid > 0 ? fmt(totalBid) : ""}
</div>
</div>
{/* 매수호가 */}
<CurrentPriceBar
latestPrice={latestPrice}
basePrice={basePrice}
bestAsk={bestAsk}
totalAsk={totalAsk}
totalBid={totalBid}
/>
<BookSideRows rows={bidRows} side="bid" maxSize={bidMax} />
</ScrollArea>
</div>
{/* 체결 목록: 데스크톱에서는 호가 오른쪽, 모바일에서는 아래 */}
<div className="min-h-[220px] border-t border-border/60 dark:border-brand-800/45 xl:min-h-0 xl:border-t-0 xl:border-r">
<TradeTape ticks={recentTicks} />
{/* 체결량 영역 */}
<div className="min-h-[180px] border-t border-border/60 dark:border-brand-800/45 xl:min-h-0 xl:border-t-0 xl:border-r">
<div className="h-full min-h-0">
<TradeTape ticks={recentTicks} maxRows={10} />
</div>
</div>
{/* 우측 요약 패널 */}
<div className="hidden xl:block min-h-0">
<SummaryPanel
orderBook={orderBook}
latestTick={latestTick}
spread={spread}
imbalance={imbalance}
totalAsk={totalAsk}
totalBid={totalBid}
/>
{/* 실시간 정보 영역 */}
<div className="min-h-[180px] border-t border-border/60 dark:border-brand-800/45 xl:min-h-0 xl:border-t-0">
<div className="h-full min-h-0">
<SummaryPanel
orderBook={orderBook}
latestTick={latestTick}
spread={spread}
imbalance={imbalance}
totalAsk={totalAsk}
totalBid={totalBid}
/>
</div>
</div>
</div>
</TabsContent>
@@ -447,13 +443,75 @@ export function OrderBook({
// ─── 하위 컴포넌트 ──────────────────────────────────────
/**
* @description 호가창 중앙의 현재가/등락률/총잔량 바를 렌더링합니다.
* @summary UI 흐름: OrderBook -> CurrentPriceBar -> 매도/매수 구간 사이 현재 체결 상태를 강조 표시
* @see features/trade/components/orderbook/OrderBook.tsx 일반호가 탭의 모바일/데스크톱 공통 현재가 행
*/
function CurrentPriceBar({
latestPrice,
basePrice,
bestAsk,
totalAsk,
totalBid,
}: {
latestPrice: number;
basePrice: number;
bestAsk: number;
totalAsk: number;
totalBid: number;
}) {
return (
<div className="grid h-10 grid-cols-3 items-center border-y-2 border-amber-400 bg-linear-to-r from-red-50/60 via-amber-50/90 to-blue-50/60 shadow-[0_0_10px_rgba(251,191,36,0.25)] dark:from-red-950/30 dark:via-amber-900/30 dark:to-blue-950/30 xl:h-10">
<div className="px-2 text-right text-[10px] font-semibold text-red-600 dark:text-red-400">
{totalAsk > 0 ? fmt(totalAsk) : ""}
</div>
<div className="flex flex-col items-center justify-center">
<span
className={cn(
"text-base leading-none font-bold tabular-nums",
latestPrice > 0 && basePrice > 0
? latestPrice >= basePrice
? "text-red-600"
: "text-blue-600 dark:text-blue-400"
: "text-foreground dark:text-brand-50",
)}
>
{latestPrice > 0 ? fmt(latestPrice) : bestAsk > 0 ? fmt(bestAsk) : "-"}
</span>
{latestPrice > 0 && basePrice > 0 && (
<span
className={cn(
"text-[11px] font-semibold leading-none",
latestPrice >= basePrice
? "text-red-500"
: "text-blue-600 dark:text-blue-400",
)}
>
{fmtPct(pctChange(latestPrice, basePrice))}
</span>
)}
</div>
<div className="px-2 text-left text-[10px] font-semibold text-blue-600 dark:text-blue-400">
{totalBid > 0 ? fmt(totalBid) : ""}
</div>
</div>
);
}
/** 호가 표 헤더 */
function BookHeader() {
return (
<div className="grid h-8 grid-cols-3 border-b bg-muted/20 text-[11px] font-medium text-muted-foreground dark:border-brand-800/45 dark:bg-brand-900/35 dark:text-brand-100/78">
<div className="flex items-center justify-end px-2"></div>
<div className="flex items-center justify-center border-x"></div>
<div className="flex items-center justify-start px-2"></div>
<div className="grid h-8 grid-cols-3 border-b bg-linear-to-r from-red-50/40 via-muted/20 to-blue-50/40 text-[11px] font-semibold text-muted-foreground dark:border-brand-800/50 dark:from-red-950/30 dark:via-brand-900/40 dark:to-blue-950/30 dark:text-brand-100/80">
<div className="flex items-center justify-end px-2 text-red-600/80 dark:text-red-400/80">
</div>
<div className="flex items-center justify-center border-x border-border/50 dark:border-brand-800/40">
</div>
<div className="flex items-center justify-start px-2 text-blue-600/80 dark:text-blue-400/80">
</div>
</div>
);
}
@@ -474,8 +532,8 @@ function BookSideRows({
<div
className={cn(
isAsk
? "bg-red-50/20 dark:bg-red-950/18"
: "bg-blue-50/55 dark:bg-blue-950/22",
? "bg-linear-to-r from-red-50/40 via-red-50/10 to-transparent dark:from-red-950/35 dark:via-red-950/10 dark:to-transparent"
: "bg-linear-to-r from-transparent via-blue-50/10 to-blue-50/45 dark:from-transparent dark:via-blue-950/10 dark:to-blue-950/35",
)}
>
{rows.map((row, i) => {
@@ -486,9 +544,9 @@ function BookSideRows({
<div
key={`${side}-${row.price}-${i}`}
className={cn(
"grid h-7 grid-cols-3 border-b border-border/40 text-[11px] xl:h-8 xl:text-xs dark:border-brand-800/35",
"grid h-[29px] grid-cols-3 border-b border-border/30 text-[11px] transition-colors hover:bg-muted/15 xl:h-[29px] xl:text-xs dark:border-brand-800/25 dark:hover:bg-brand-900/20",
row.isHighlighted &&
"ring-1 ring-inset ring-amber-400 bg-amber-100/50 dark:bg-amber-800/30",
"ring-1 ring-inset ring-amber-400 bg-amber-100/60 dark:bg-amber-800/35",
)}
>
{/* 매도잔량 (좌측) */}
@@ -520,19 +578,22 @@ function BookSideRows({
)}
>
<span
className={
isAsk ? "text-red-600" : "text-blue-600 dark:text-blue-400"
}
className={cn(
"text-[12px] xl:text-[13px]",
isAsk ? "text-red-600" : "text-blue-600 dark:text-blue-400",
)}
>
{row.price > 0 ? fmt(row.price) : "-"}
</span>
<span
className={cn(
"w-[58px] shrink-0 text-right text-[10px] tabular-nums",
"w-[50px] shrink-0 text-right text-[10px] tabular-nums xl:w-[58px]",
getChangeToneClass(row.changeValue),
)}
>
{row.changeValue === null ? "-" : fmtSignedChange(row.changeValue)}
{row.changeValue === null
? "-"
: fmtSignedChange(row.changeValue)}
</span>
</div>
@@ -582,71 +643,80 @@ function SummaryPanel({
latestTick?.isExpected && (orderBook?.anticipatedVolume ?? 0) > 0
? (orderBook?.anticipatedVolume ?? 0)
: (latestTick?.tradeVolume ?? orderBook?.anticipatedVolume ?? 0);
const summaryItems: SummaryMetric[] = [
{
label: "실시간",
value: orderBook || latestTick ? "연결됨" : "끊김",
tone: orderBook || latestTick ? "bid" : undefined,
},
{ label: "거래량", value: fmt(displayTradeVolume) },
{
label: "누적거래량",
value: fmt(
latestTick?.accumulatedVolume ?? orderBook?.accumulatedVolume ?? 0,
),
},
{
label: "체결강도",
value: latestTick
? `${latestTick.tradeStrength.toFixed(2)}%`
: orderBook?.anticipatedChangeRate !== undefined
? `${orderBook.anticipatedChangeRate.toFixed(2)}%`
: "-",
},
{ label: "예상체결가", value: fmt(orderBook?.anticipatedPrice ?? 0) },
{
label: "매도1호가",
value: latestTick ? fmt(latestTick.askPrice1) : "-",
tone: "ask",
},
{
label: "매수1호가",
value: latestTick ? fmt(latestTick.bidPrice1) : "-",
tone: "bid",
},
{
label: "순매수체결",
value: latestTick ? fmt(latestTick.netBuyExecutionCount) : "-",
},
{ label: "총 매도잔량", value: fmt(totalAsk), tone: "ask" },
{ label: "총 매수잔량", value: fmt(totalBid), tone: "bid" },
{ label: "스프레드", value: fmt(spread) },
{
label: "수급 불균형",
value: `${imbalance >= 0 ? "+" : ""}${imbalance.toFixed(2)}%`,
tone: imbalance >= 0 ? "bid" : "ask",
},
];
return (
<div className="min-w-0 border-l bg-muted/10 p-2 text-[11px] dark:border-brand-800/45 dark:bg-brand-900/30">
<Row
label="실시간"
value={orderBook || latestTick ? "연결됨" : "끊김"}
tone={orderBook || latestTick ? "bid" : undefined}
/>
<Row
label="거래량"
value={fmt(displayTradeVolume)}
/>
<Row
label="누적거래량"
value={fmt(
latestTick?.accumulatedVolume ?? orderBook?.accumulatedVolume ?? 0,
)}
/>
<Row
label="체결강도"
value={
latestTick
? `${latestTick.tradeStrength.toFixed(2)}%`
: orderBook?.anticipatedChangeRate !== undefined
? `${orderBook.anticipatedChangeRate.toFixed(2)}%`
: "-"
}
/>
<Row label="예상체결가" value={fmt(orderBook?.anticipatedPrice ?? 0)} />
<Row
label="매도1호가"
value={latestTick ? fmt(latestTick.askPrice1) : "-"}
tone="ask"
/>
<Row
label="매수1호가"
value={latestTick ? fmt(latestTick.bidPrice1) : "-"}
tone="bid"
/>
<Row
label="매수체결"
value={latestTick ? fmt(latestTick.buyExecutionCount) : "-"}
/>
<Row
label="매도체결"
value={latestTick ? fmt(latestTick.sellExecutionCount) : "-"}
/>
<Row
label="순매수체결"
value={latestTick ? fmt(latestTick.netBuyExecutionCount) : "-"}
/>
<Row label="총 매도잔량" value={fmt(totalAsk)} tone="ask" />
<Row label="총 매수잔량" value={fmt(totalBid)} tone="bid" />
<Row label="스프레드" value={fmt(spread)} />
<Row
label="수급 불균형"
value={`${imbalance >= 0 ? "+" : ""}${imbalance.toFixed(2)}%`}
tone={imbalance >= 0 ? "bid" : "ask"}
/>
<div className="h-full min-w-0 bg-muted/10 p-2 text-xs dark:bg-brand-900/30">
<div className="grid grid-cols-2 gap-1 xl:h-full xl:grid-cols-1 xl:grid-rows-12">
{summaryItems.map((item) => (
<SummaryMetricCell
key={item.label}
label={item.label}
value={item.value}
tone={item.tone}
/>
))}
</div>
</div>
);
}
/** 요약 패널 단일 행 */
function Row({
interface SummaryMetric {
label: string;
value: string;
tone?: "ask" | "bid";
}
/**
* @description 실시간 요약 패널의 2열 메트릭 셀을 렌더링합니다.
* @summary UI 흐름: SummaryPanel -> SummaryMetricCell -> 체결량 하단 정보 패널을 압축해 스크롤 없이 표시
* @see features/trade/components/orderbook/OrderBook.tsx SummaryPanel summaryItems
*/
function SummaryMetricCell({
label,
value,
tone,
@@ -656,13 +726,13 @@ function Row({
tone?: "ask" | "bid";
}) {
return (
<div className="mb-1.5 flex items-center justify-between gap-2 rounded border bg-background px-2 py-1 dark:border-brand-800/45 dark:bg-black/20">
<span className="min-w-0 truncate text-muted-foreground dark:text-brand-100/70">
<div className="flex h-full min-w-0 items-center justify-between gap-2 rounded border bg-background px-2 py-1 dark:border-brand-800/45 dark:bg-black/20">
<span className="truncate text-[11px] text-muted-foreground dark:text-brand-100/70">
{label}
</span>
<span
className={cn(
"shrink-0 font-medium tabular-nums",
"shrink-0 text-xs font-semibold tabular-nums",
tone === "ask" && "text-red-600",
tone === "bid" && "text-blue-600 dark:text-blue-400",
)}
@@ -679,10 +749,10 @@ function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
return (
<div
className={cn(
"absolute inset-y-1 z-0 rounded-sm",
"absolute inset-y-0.5 z-0 rounded-sm transition-[width] duration-150",
side === "ask"
? "right-1 bg-red-200/50 dark:bg-red-800/40"
: "left-1 bg-blue-200/55 dark:bg-blue-500/35",
? "right-0.5 bg-red-300/55 dark:bg-red-700/50"
: "left-0.5 bg-blue-300/60 dark:bg-blue-600/45",
)}
style={{ width: `${ratio}%` }}
/>
@@ -690,65 +760,79 @@ function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
}
/** 체결 목록 (Trade Tape) */
function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
function TradeTape({
ticks,
maxRows,
}: {
ticks: DashboardRealtimeTradeTick[];
maxRows?: number;
}) {
const visibleTicks = typeof maxRows === "number" ? ticks.slice(0, maxRows) : ticks;
const shouldUseScrollableList = typeof maxRows !== "number";
const tapeRows = (
<div>
{visibleTicks.length === 0 && (
<div className="flex min-h-[96px] items-center justify-center text-xs text-muted-foreground dark:text-brand-100/70 xl:min-h-[140px]">
.
</div>
)}
{visibleTicks.map((t, i) => {
const olderTick = visibleTicks[i + 1];
const executionSide = resolveTickExecutionSide(t, olderTick);
// UI 흐름: 체결목록 UI -> resolveTickExecutionSide(현재/이전 틱) -> 색상 class 결정 -> 체결량 렌더 반영
const volumeToneClass =
executionSide === "buy"
? "text-red-600"
: executionSide === "sell"
? "text-blue-600 dark:text-blue-400"
: "text-muted-foreground dark:text-brand-100/70";
return (
<div
key={`${t.tickTime}-${t.price}-${i}`}
className="grid h-7 grid-cols-3 border-b border-border/40 px-2 text-[13px] dark:border-brand-800/35"
>
<div className="flex items-center tabular-nums">
{fmtTime(t.tickTime)}
</div>
<div
className={cn(
"flex items-center justify-end tabular-nums",
getChangeToneClass(
t.change,
"text-foreground dark:text-brand-50",
),
)}
>
{fmt(t.price)}
</div>
<div
className={cn(
"flex items-center justify-end tabular-nums",
volumeToneClass,
)}
>
{fmt(t.tradeVolume)}
</div>
</div>
);
})}
</div>
);
return (
<div className="flex h-full min-h-0 flex-col bg-background dark:bg-brand-900/20">
<div className="grid h-7 grid-cols-4 border-b bg-muted/20 px-2 text-[11px] font-medium text-muted-foreground dark:border-brand-800/45 dark:bg-brand-900/35 dark:text-brand-100/78">
<div className="flex min-h-0 flex-col bg-background dark:bg-brand-900/20">
<div className="grid h-8 grid-cols-3 border-b bg-muted/20 px-2 text-xs font-medium text-muted-foreground dark:border-brand-800/45 dark:bg-brand-900/35 dark:text-brand-100/78">
<div className="flex items-center"></div>
<div className="flex items-center justify-end"></div>
<div className="flex items-center justify-end"></div>
<div className="flex items-center justify-end"></div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div>
{ticks.length === 0 && (
<div className="flex min-h-[160px] items-center justify-center text-xs text-muted-foreground dark:text-brand-100/70">
.
</div>
)}
{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={cn(
"flex items-center justify-end tabular-nums",
getChangeToneClass(t.change, "text-foreground dark:text-brand-50"),
)}
>
{fmt(t.price)}
</div>
<div
className={cn(
"flex items-center justify-end tabular-nums",
volumeToneClass,
)}
>
{fmt(t.tradeVolume)}
</div>
<div className="flex items-center justify-end tabular-nums">
{t.tradeStrength.toFixed(2)}%
</div>
</div>
);
})}
</div>
</ScrollArea>
{shouldUseScrollableList ? (
<ScrollArea className="min-h-0 flex-1">{tapeRows}</ScrollArea>
) : (
tapeRows
)}
</div>
);
}

View File

@@ -31,7 +31,7 @@ export function StockSearchForm({
};
return (
<form onSubmit={onSubmit} className="flex gap-2">
<form onSubmit={onSubmit} className="flex items-center gap-2">
{/* ========== SEARCH INPUT ========== */}
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground dark:text-brand-100/65" />
@@ -39,9 +39,9 @@ export function StockSearchForm({
value={keyword}
onChange={(e) => onKeywordChange(e.target.value)}
onFocus={onInputFocus}
placeholder="종목명 또는 종목코드(6자리)를 입력하세요."
placeholder="종목명 또는 코드 검색"
autoComplete="off"
className="pl-9 pr-9 dark:border-brand-700/55 dark:bg-brand-900/28 dark:text-brand-100 dark:placeholder:text-brand-100/55"
className="h-9 pl-9 pr-9 dark:border-brand-700/55 dark:bg-brand-900/28 dark:text-brand-100 dark:placeholder:text-brand-100/55"
/>
{keyword && (
<button
@@ -57,7 +57,11 @@ export function StockSearchForm({
</div>
{/* ========== SUBMIT BUTTON ========== */}
<Button type="submit" disabled={disabled || isLoading}>
<Button
type="submit"
disabled={disabled || isLoading}
className="h-9 px-2.5 text-xs sm:px-3 sm:text-sm"
>
{isLoading ? "검색 중..." : "검색"}
</Button>
</form>

View File

@@ -3,16 +3,22 @@ import { StockSearchForm } from "@/features/trade/components/search/StockSearchF
import { StockSearchHistory } from "@/features/trade/components/search/StockSearchHistory";
import { StockSearchResults } from "@/features/trade/components/search/StockSearchResults";
import type {
DashboardStockItem,
DashboardStockSearchHistoryItem,
DashboardStockSearchItem,
} from "@/features/trade/types/trade.types";
import { cn } from "@/lib/utils";
interface TradeSearchSectionProps {
canSearch: boolean;
isSearchPanelOpen: boolean;
isSearching: boolean;
keyword: string;
selectedStock: DashboardStockItem | null;
selectedSymbol?: string;
currentPrice?: number;
change?: number;
changeRate?: number;
searchResults: DashboardStockSearchItem[];
searchHistory: DashboardStockSearchHistoryItem[];
searchShellRef: MutableRefObject<HTMLDivElement | null>;
@@ -27,16 +33,20 @@ interface TradeSearchSectionProps {
}
/**
* @description 트레이드 화면 상단의 검색 입력/결과/히스토리 드롭다운 영역을 렌더링합니다.
* @see features/trade/components/TradeContainer.tsx TradeContainer에서 검색 섹션을 분리해 렌더 복잡도를 줄입니다.
* @see features/trade/hooks/useTradeSearchPanel.ts 패널 열림/닫힘 및 포커스 핸들러를 전달받습니다.
* @description 트레이드 화면 상단의 검색 입력/결과/종목 요약 통합 영역을 렌더링합니다.
* @summary UI 흐름: TradeContainer -> TradeSearchSection -> (검색 입력/선택) + (선택 종목 실시간 요약) 반영
* @see features/trade/components/TradeContainer.tsx - 검색 상태/선택 종목 실시간 데이터를 전달니다.
*/
export function TradeSearchSection({
canSearch,
isSearchPanelOpen,
isSearching,
keyword,
selectedStock,
selectedSymbol,
currentPrice,
change,
changeRate,
searchResults,
searchHistory,
searchShellRef,
@@ -50,52 +60,176 @@ export function TradeSearchSection({
onClearHistory,
}: TradeSearchSectionProps) {
return (
<div className="z-30 flex-none border-b bg-background/95 px-3 py-2 backdrop-blur-sm dark:border-brand-800/45 dark:bg-brand-900/22 sm:px-4">
{/* ========== 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}
/>
<div className="z-30 flex-none border-b bg-background/95 px-3 py-1 backdrop-blur-sm dark:border-brand-800/45 dark:bg-brand-900/22 sm:px-4">
{/* ========== TOP BAR (검색 + 종목 요약 통합) ========== */}
<div className="mx-auto flex max-w-[1800px] items-center gap-2">
{/* ========== SEARCH SHELL ========== */}
<div
ref={searchShellRef}
onBlurCapture={onSearchShellBlur}
onKeyDownCapture={onSearchShellKeyDown}
className="relative min-w-0 flex-1 md:max-w-[480px]"
>
<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>
)}
{/* ========== 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>
<InlineStockSummary
stock={selectedStock}
currentPrice={currentPrice}
change={change}
changeRate={changeRate}
/>
</div>
</div>
);
}
/**
* @description 검색창 우측의 선택 종목/보유 종목 요약 배지를 렌더링합니다.
* @see features/trade/components/search/TradeSearchSection.tsx - 상단 1줄 통합 바에서 사용합니다.
*/
function InlineStockSummary({
stock,
currentPrice,
change,
changeRate,
}: {
stock: DashboardStockItem | null;
currentPrice?: number;
change?: number;
changeRate?: number;
}) {
if (!stock) {
return (
<div className="hidden min-w-0 flex-1 items-center justify-end md:flex">
<div className="rounded-md border border-dashed border-border/80 px-3 py-1 text-xs text-muted-foreground dark:border-brand-800/45 dark:text-brand-100/65">
/ .
</div>
</div>
);
}
const displayPrice = currentPrice ?? stock.currentPrice;
const displayChange = change ?? stock.change;
const displayChangeRate = changeRate ?? stock.changeRate;
const isRise = displayChangeRate > 0;
const isFall = displayChangeRate < 0;
const priceToneClass = isRise
? "text-red-600 dark:text-red-400"
: isFall
? "text-blue-600 dark:text-blue-400"
: "text-foreground dark:text-brand-50";
return (
<div className="min-w-0 flex-1">
<div className="flex items-center justify-end gap-2 overflow-hidden rounded-lg border border-brand-200/50 bg-white/70 px-2 py-1 dark:border-brand-700/45 dark:bg-brand-900/30">
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-foreground dark:text-brand-50">
{stock.name}
</p>
<p className="truncate text-[10px] text-muted-foreground dark:text-brand-100/65">
{stock.symbol} · {stock.market}
</p>
</div>
<div className="border-l border-border/65 pl-2 text-right dark:border-brand-700/45">
<p className={cn("text-sm font-bold tabular-nums", priceToneClass)}>
{displayPrice.toLocaleString("ko-KR")}
</p>
<p className={cn("text-[10px] tabular-nums", priceToneClass)}>
{isRise ? "+" : ""}
{displayChange.toLocaleString("ko-KR")} (
{isRise ? "+" : ""}
{displayChangeRate.toFixed(2)}%)
</p>
</div>
<div className="hidden items-center gap-2 border-l border-border/65 pl-2 dark:border-brand-700/45 xl:flex">
<CompactMetric
label="고"
value={stock.high.toLocaleString("ko-KR")}
tone="ask"
/>
<CompactMetric
label="저"
value={stock.low.toLocaleString("ko-KR")}
tone="bid"
/>
<CompactMetric
label="거래량"
value={stock.volume.toLocaleString("ko-KR")}
/>
</div>
</div>
</div>
);
}
/**
* @description 검색 헤더 1줄 안에서 시세 핵심 값(고가/저가/거래량)을 표시하는 칩입니다.
* @summary UI 흐름: InlineStockSummary -> CompactMetric -> 종목 핵심 지표를 축약 표기
* @see features/trade/components/search/TradeSearchSection.tsx - 상단 통합 헤더의 우측 지표 영역
*/
function CompactMetric({
label,
value,
tone,
}: {
label: string;
value: string;
tone?: "ask" | "bid";
}) {
return (
<div className="rounded-md bg-muted/35 px-2 py-1 dark:bg-brand-900/25">
<p className="text-[10px] text-muted-foreground dark:text-brand-100/70">
{label}
</p>
<p
className={cn(
"max-w-[120px] truncate text-[11px] font-semibold tabular-nums",
tone === "ask" && "text-red-600 dark:text-red-400",
tone === "bid" && "text-blue-600 dark:text-blue-400",
!tone && "text-foreground dark:text-brand-50",
)}
>
{value}
</p>
</div>
);
}

View File

@@ -35,7 +35,8 @@ export function useOrderbookSubscription({
marketSession,
onOrderBookMessage,
}: UseOrderbookSubscriptionParams) {
const { subscribe, connect } = useKisWebSocketStore();
const subscribeRef = useRef(useKisWebSocketStore.getState().subscribe);
const connectRef = useRef(useKisWebSocketStore.getState().connect);
const onOrderBookMessageRef = useRef(onOrderBookMessage);
const activeOrderBookTrIdRef = useRef<string | null>(null);
const activeOrderBookTrUpdatedAtRef = useRef(0);
@@ -47,7 +48,7 @@ export function useOrderbookSubscription({
useEffect(() => {
if (!symbol || !isVerified || !credentials) return;
connect();
connectRef.current();
const trIds = resolveOrderBookTrIds(
credentials.tradingEnv,
@@ -83,7 +84,9 @@ export function useOrderbookSubscription({
};
for (const trId of trIds) {
unsubscribers.push(subscribe(trId, symbol, handleOrderBookMessage));
unsubscribers.push(
subscribeRef.current(trId, symbol, handleOrderBookMessage),
);
}
return () => {
@@ -91,5 +94,5 @@ export function useOrderbookSubscription({
activeOrderBookTrIdRef.current = null;
activeOrderBookTrUpdatedAtRef.current = 0;
};
}, [symbol, market, isVerified, credentials, marketSession, connect, subscribe]);
}, [symbol, market, isVerified, credentials, marketSession]);
}

View File

@@ -45,7 +45,8 @@ export function useTradeTickSubscription({
const activeTradeTrIdRef = useRef<string | null>(null);
const activeTradeTrUpdatedAtRef = useRef(0);
const { subscribe, connect } = useKisWebSocketStore();
const subscribeRef = useRef(useKisWebSocketStore.getState().subscribe);
const connectRef = useRef(useKisWebSocketStore.getState().connect);
const onTickRef = useRef(onTick);
useEffect(() => {
@@ -73,7 +74,7 @@ export function useTradeTickSubscription({
useEffect(() => {
if (!symbol || !isVerified || !credentials) return;
connect();
connectRef.current();
const trIds = resolveTradeTrIds(credentials.tradingEnv, marketSession);
const unsubscribers: Array<() => void> = [];
@@ -148,13 +149,15 @@ export function useTradeTickSubscription({
};
for (const trId of trIds) {
unsubscribers.push(subscribe(trId, symbol, handleTradeMessage));
unsubscribers.push(
subscribeRef.current(trId, symbol, handleTradeMessage),
);
}
return () => {
unsubscribers.forEach((unsub) => unsub());
};
}, [symbol, isVerified, credentials, marketSession, connect, subscribe]);
}, [symbol, isVerified, credentials, marketSession]);
return { latestTick, recentTradeTicks, lastTickAt };
}