From c53f79a86f7105c796776e85298c16459e37b411 Mon Sep 17 00:00:00 2001 From: "jihoon87.lee" Date: Tue, 24 Feb 2026 15:43:56 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8A=B8=EB=A0=88=EC=9D=B4=EB=94=A9=EC=B0=BD?= =?UTF-8?q?=20UI=20=EB=B0=B0=EC=B9=98=20=EB=B0=8F=20UX=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=EA=B8=B0=ED=9A=8D=EC=84=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common-docs/features-autotrade-design.md | 266 +++++++++++++ .../dashboard/hooks/use-holdings-realtime.ts | 57 +-- .../kis-realtime/hooks/useKisWebSocket.ts | 10 +- .../kis-realtime/stores/kisWebSocketStore.ts | 60 ++- features/trade/components/TradeContainer.tsx | 83 +++- .../trade/components/chart/StockLineChart.tsx | 66 ++-- .../trade/components/header/StockHeader.tsx | 170 ++++++-- .../components/holdings/HoldingsPanel.tsx | 311 +++++++++++++++ .../components/layout/DashboardLayout.tsx | 81 ++-- .../layout/TradeDashboardContent.tsx | 42 +- features/trade/components/order/OrderForm.tsx | 228 +++++++++-- .../trade/components/orderbook/OrderBook.tsx | 367 ++++++++++-------- .../components/search/StockSearchForm.tsx | 12 +- .../components/search/TradeSearchSection.tsx | 228 ++++++++--- .../trade/hooks/useOrderbookSubscription.ts | 11 +- .../trade/hooks/useTradeTickSubscription.ts | 11 +- 16 files changed, 1553 insertions(+), 450 deletions(-) create mode 100644 common-docs/features-autotrade-design.md create mode 100644 features/trade/components/holdings/HoldingsPanel.tsx diff --git a/common-docs/features-autotrade-design.md b/common-docs/features-autotrade-design.md new file mode 100644 index 0000000..3d79bb4 --- /dev/null +++ b/common-docs/features-autotrade-design.md @@ -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 키/계좌 정보는 서버 저장을 금지하고 요청 단위 처리만 허용합니다. diff --git a/features/dashboard/hooks/use-holdings-realtime.ts b/features/dashboard/hooks/use-holdings-realtime.ts index 0d12fe3..72d0b12 100644 --- a/features/dashboard/hooks/use-holdings-realtime.ts +++ b/features/dashboard/hooks/use-holdings-realtime.ts @@ -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 >({}); - 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 }; } diff --git a/features/kis-realtime/hooks/useKisWebSocket.ts b/features/kis-realtime/hooks/useKisWebSocket.ts index 020090a..73d2db5 100644 --- a/features/kis-realtime/hooks/useKisWebSocket.ts +++ b/features/kis-realtime/hooks/useKisWebSocket.ts @@ -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 }; } diff --git a/features/kis-realtime/stores/kisWebSocketStore.ts b/features/kis-realtime/stores/kisWebSocketStore.ts index c7c2a44..361e08c 100644 --- a/features/kis-realtime/stores/kisWebSocketStore.ts +++ b/features/kis-realtime/stores/kisWebSocketStore.ts @@ -104,7 +104,8 @@ export const useKisWebSocketStore = create((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((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((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((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((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((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((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((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; diff --git a/features/trade/components/TradeContainer.tsx b/features/trade/components/TradeContainer.tsx index 1407a98..586a25e 100644 --- a/features/trade/components/TradeContainer.tsx +++ b/features/trade/components/TradeContainer.tsx @@ -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(null); + // [State] 선택 종목과 매칭할 보유 종목 목록 + const [holdings, setHoldings] = useState([]); 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 ( -
+
{/* ========== SEARCH SECTION ========== */}
); diff --git a/features/trade/components/chart/StockLineChart.tsx b/features/trade/components/chart/StockLineChart.tsx index 28065a5..0fac850 100644 --- a/features/trade/components/chart/StockLineChart.tsx +++ b/features/trade/components/chart/StockLineChart.tsx @@ -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 ( -
+
{/* ========== CHART TOOLBAR ========== */}
@@ -668,14 +689,15 @@ export function StockLineChart({
- 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{" "} = 0 ? "text-red-600" : "text-blue-600 dark:text-blue-400", )} > - {formatPrice(latest?.close ?? 0)} ({formatSignedPercent(changeRate)}) + {formatPrice(latest?.close ?? 0)} ({formatSignedPercent(changeRate)} + )
diff --git a/features/trade/components/header/StockHeader.tsx b/features/trade/components/header/StockHeader.tsx index 357f131..33c78a1 100644 --- a/features/trade/components/header/StockHeader.tsx +++ b/features/trade/components/header/StockHeader.tsx @@ -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 ( -
+
{/* ========== STOCK SUMMARY ========== */}
-
-

- {stock.name} -

+ {/* 종목명 + 코드 */} +
+
+

+ {stock.name} +

+ + {stock.market} + +
- {stock.symbol}/{stock.market} + {stock.symbol}
-
- {price} - - {changeRate}% {change} + {/* 현재가 + 등락 */} +
+ + {price} +
+ + {isRise ? "▲" : isFall ? "▼" : ""} + {changeRate}% + + + {isRise && "+"} + {change} + +
- {/* ========== STATS ========== */} -
-
-

고가

-

{high || "--"}

-
-
-

저가

-

{low || "--"}

-
-
-

거래량(24H)

-

{volume || "--"}

-
+ {/* ========== MOBILE STATS ========== */} +
+ + +
{/* ========== DESKTOP STATS ========== */} -
-
- 고가 - {high || "--"} -
-
- 저가 - {low || "--"} -
-
- 거래량(24H) - {volume || "--"} -
+
+ + + + +
); } + +/** 모바일 통계 카드 */ +function StatCard({ + label, + value, + tone, +}: { + label: string; + value: string; + tone?: "ask" | "bid"; +}) { + return ( +
+

+ {label} +

+

+ {value} +

+
+ ); +} + +/** 데스크톱 통계 항목 */ +function DesktopStat({ + label, + value, + tone, +}: { + label: string; + value: string; + tone?: "ask" | "bid"; +}) { + return ( +
+ + {label} + + + {value} + +
+ ); +} diff --git a/features/trade/components/holdings/HoldingsPanel.tsx b/features/trade/components/holdings/HoldingsPanel.tsx new file mode 100644 index 0000000..2884c12 --- /dev/null +++ b/features/trade/components/holdings/HoldingsPanel.tsx @@ -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(null); + const [holdings, setHoldings] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 ( +
+ {/* ========== HOLDINGS HEADER ========== */} +
+
+ + + {/* 요약 배지: 수익/손실 */} + {summary && !isLoading && ( +
= 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 ? ( + + ) : ( + + )} + {summary.totalProfitLoss >= 0 ? "+" : ""} + {fmt(summary.totalProfitLoss)}원 ( + {summary.totalProfitRate >= 0 ? "+" : ""} + {summary.totalProfitRate.toFixed(2)}%) +
+ )} +
+ + {/* 새로고침 버튼 */} + +
+ + {/* ========== HOLDINGS CONTENT ========== */} + {isExpanded && ( +
+ {/* 요약 바 */} + {summary && !isLoading && ( +
+ + + = 0 ? "+" : ""}${fmt(summary.totalProfitLoss)}원`} + tone={ + summary.totalProfitLoss > 0 + ? "profit" + : summary.totalProfitLoss < 0 + ? "loss" + : "neutral" + } + /> + = 0 ? "+" : ""}${summary.totalProfitRate.toFixed(2)}%`} + tone={ + summary.totalProfitRate > 0 + ? "profit" + : summary.totalProfitRate < 0 + ? "loss" + : "neutral" + } + /> +
+ )} + + {/* 로딩 상태 */} + {isLoading && } + + {/* 에러 상태 */} + {!isLoading && error && ( +
+ + {error} +
+ )} + + {/* 보유 종목 없음 */} + {!isLoading && !error && holdings.length === 0 && ( +
+ 보유 중인 종목이 없습니다. +
+ )} + + {/* 보유 종목 테이블 */} + {!isLoading && !error && holdings.length > 0 && ( +
+ {/* 테이블 헤더 */} +
+
종목명
+
보유수량
+
평균단가
+
현재가
+
평가손익
+
수익률
+
+ + {/* 종목 행 */} + {holdings.map((holding) => ( + + ))} +
+ )} +
+ )} +
+ ); +} + +/** 요약 항목 */ +function SummaryItem({ + label, + value, + tone, +}: { + label: string; + value: string; + tone?: "profit" | "loss" | "neutral"; +}) { + return ( +
+

+ {label} +

+

+ {value} +

+
+ ); +} + +/** 보유 종목 행 */ +function HoldingRow({ holding }: { holding: DashboardHoldingItem }) { + return ( +
+ {/* 종목명 */} +
+

+ {holding.name} +

+

+ {holding.symbol} · {holding.market} +

+
+ + {/* 보유수량 */} +
+ {fmt(holding.quantity)}주 +
+ + {/* 평균단가 */} +
+ {fmt(holding.averagePrice)} +
+ + {/* 현재가 */} +
+ {fmt(holding.currentPrice)} +
+ + {/* 평가손익 */} +
+ {holding.profitLoss >= 0 ? "+" : ""} + {fmt(holding.profitLoss)} +
+ + {/* 수익률 */} +
+ {holding.profitRate >= 0 ? "+" : ""} + {holding.profitRate.toFixed(2)}% +
+
+ ); +} + +/** 로딩 스켈레톤 */ +function HoldingsSkeleton() { + return ( +
+ {[1, 2, 3].map((i) => ( +
+ + + + +
+ ))} +
+ ); +} diff --git a/features/trade/components/layout/DashboardLayout.tsx b/features/trade/components/layout/DashboardLayout.tsx index dc03ed8..7ec27d8 100644 --- a/features/trade/components/layout/DashboardLayout.tsx +++ b/features/trade/components/layout/DashboardLayout.tsx @@ -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 (
- {/* 1. Header Area */} -
- {header} -
+ {/* ========== 1. OPTIONAL HEADER AREA ========== */} + {header && ( +
+ {header} +
+ )} - {/* 2. Main Content Area */} -
-
- {/* ========== CHART SECTION ========== */} -
-
-
-

+ {/* ========== 2. MAIN CONTENT AREA ========== */} +

+
+ {/* ========== TOP: CHART AREA ========== */} +
+ {/* 모바일 전용 차트 토글 */} +
+
+ +

실시간 차트

-

- 거래 화면 집중을 위해 기본은 접힌 상태입니다. -

- {/* UI 흐름: 차트 토글 버튼 -> onToggleChart 호출 -> TradeDashboardContent의 상태 변경 -> 차트 wrapper 높이 반영 */} + {/* UI 흐름: 토글 클릭 -> onToggleChart -> 상위 상태 변경 -> 차트 표시/숨김 */} @@ -84,28 +77,28 @@ export function DashboardLayout({
-
+
{chart}
- {/* ========== ORDERBOOK + ORDER SECTION ========== */} -
-
-
+ {/* ========== BOTTOM: ORDERBOOK + ORDER AREA ========== */} +
+
+
{orderBook}
-
+
-
-
{orderForm}
-
-
+
+
{orderForm}
+
+
diff --git a/features/trade/components/layout/TradeDashboardContent.tsx b/features/trade/components/layout/TradeDashboardContent.tsx index 9e8d45d..1f6c59f 100644 --- a/features/trade/components/layout/TradeDashboardContent.tsx +++ b/features/trade/components/layout/TradeDashboardContent.tsx @@ -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 (
{/* ========== DASHBOARD LAYOUT ========== */} - ) : null - } chart={ selectedStock ? (
@@ -95,7 +76,12 @@ export function TradeDashboardContent({ isLoading={isOrderBookLoading} /> } - orderForm={} + orderForm={ + + } isChartVisible={isChartVisible} onToggleChart={() => setIsChartVisible((prev) => !prev)} /> diff --git a/features/trade/components/order/OrderForm.tsx b/features/trade/components/order/OrderForm.tsx index 52b5189..598a805 100644 --- a/features/trade/components/order/OrderForm.tsx +++ b/features/trade/components/order/OrderForm.tsx @@ -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(stock?.currentPrice.toString() || ""); + const [price, setPrice] = useState( + stock?.currentPrice.toString() || "", + ); const [quantity, setQuantity] = useState(""); 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 ( -
+
setActiveTab(value as "buy" | "sell")} className="flex h-full w-full flex-col" > {/* ========== ORDER SIDE TABS ========== */} - + 매수 매도 + {/* ========== CURRENT PRICE INFO ========== */} + {stock && ( +
+ + 현재가 + + + {stock.currentPrice.toLocaleString()}원 + +
+ )} + {/* ========== BUY TAB ========== */} - +
+ + +
{/* ========== SELL TAB ========== */} - +
+ + +
@@ -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 ( -
-
- 주문가능 - - {type === "buy" ? "KRW" : "주"} +
+ {/* 주문 가능 */} +
+ + 주문가능 + + + - {type === "buy" ? "KRW" : "주"} +
{hasError && ( -
+
{errorMessage}
)} + {/* 가격 입력 */}
- + {type === "buy" ? "매수가격" : "매도가격"} setPrice(e.target.value)} @@ -210,10 +278,11 @@ function OrderInputs({ />
+ {/* 수량 입력 */}
- 주문수량 + 주문수량 setQuantity(e.target.value)} @@ -221,13 +290,15 @@ function OrderInputs({ />
+ {/* 총액 */}
- 주문총액 + 주문총액 0 ? `${totalPrice.toLocaleString()}원` : ""} readOnly disabled={disabled} + placeholder="0원" />
@@ -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 ( -
+
{["10%", "25%", "50%", "100%"].map((pct) => (
); } + +/** + * @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 ( +
+

+ 보유 정보 +

+
+ + + + = 0 ? "+" : ""}${holding.profitLoss.toLocaleString("ko-KR")}원`} + toneClass={profitToneClass} + /> + = 0 ? "+" : ""}${holding.profitRate.toFixed(2)}%`} + toneClass={profitToneClass} + /> +
+
+ ); +} + +/** + * @description 보유정보 카드의 단일 라벨/값 행을 렌더링합니다. + * @see features/trade/components/order/OrderForm.tsx HoldingInfoPanel + */ +function HoldingInfoRow({ + label, + value, + toneClass, +}: { + label: string; + value: string; + toneClass?: string; +}) { + return ( +
+ {label} + + {value} + +
+ ); +} + +/** + * @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"; +} diff --git a/features/trade/components/orderbook/OrderBook.tsx b/features/trade/components/orderbook/OrderBook.tsx index 08d5cf2..827f7d3 100644 --- a/features/trade/components/orderbook/OrderBook.tsx +++ b/features/trade/components/orderbook/OrderBook.tsx @@ -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 ( -
+
{/* 탭 헤더 */} -
+
일반호가 @@ -351,27 +357,36 @@ export function OrderBook({ {/* ── 일반호가 탭 ── */} -
+
{/* 호가 테이블 */}
{isTickFallbackActive && (
- 시간외 전용 호가(`H0STOAA0`) 미수신 상태입니다. 체결(`H0UNCNT0`) - 1호가 기준으로 표시 중입니다. + 시간외 전용 호가(`H0STOAA0`) 미수신 상태입니다. + 체결(`H0UNCNT0`) 1호가 기준으로 표시 중입니다.
)} - + {/* 매도호가 */} {/* 중앙 바: 현재 체결가 */} -
-
+
+
{totalAsk > 0 ? fmt(totalAsk) : ""}
-
- +
+ 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 @@ -381,7 +396,7 @@ export function OrderBook({ {latestPrice > 0 && basePrice > 0 && ( = basePrice ? "text-red-500" : "text-blue-600 dark:text-blue-400", @@ -391,7 +406,7 @@ export function OrderBook({ )}
-
+
{totalBid > 0 ? fmt(totalBid) : ""}
@@ -401,21 +416,25 @@ export function OrderBook({
- {/* 체결 목록: 데스크톱에서는 호가 오른쪽, 모바일에서는 아래 */} + {/* 체결량 영역 */}
- +
+ +
- {/* 우측 요약 패널 */} -
- + {/* 실시간 정보 영역 */} +
+
+ +
@@ -450,10 +469,16 @@ export function OrderBook({ /** 호가 표 헤더 */ function BookHeader() { return ( -
-
매도잔량
-
호가
-
매수잔량
+
+
+ 매도잔량 +
+
+ 호가 +
+
+ 매수잔량 +
); } @@ -474,8 +499,8 @@ function BookSideRows({
{rows.map((row, i) => { @@ -486,9 +511,9 @@ function BookSideRows({
{/* 매도잔량 (좌측) */} @@ -520,19 +545,22 @@ function BookSideRows({ )} > {row.price > 0 ? fmt(row.price) : "-"} - {row.changeValue === null ? "-" : fmtSignedChange(row.changeValue)} + {row.changeValue === null + ? "-" + : fmtSignedChange(row.changeValue)}
@@ -582,71 +610,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 ( -
- - - - - - - - - - - - - - = 0 ? "+" : ""}${imbalance.toFixed(2)}%`} - tone={imbalance >= 0 ? "bid" : "ask"} - /> +
+
+ {summaryItems.map((item) => ( + + ))} +
); } -/** 요약 패널 단일 행 */ -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 +693,13 @@ function Row({ tone?: "ask" | "bid"; }) { return ( -
- +
+ {label} @@ -690,65 +727,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 = ( +
+ {visibleTicks.length === 0 && ( +
+ 체결 데이터가 아직 없습니다. +
+ )} + {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 ( +
+
+ {fmtTime(t.tickTime)} +
+
+ {fmt(t.price)} +
+
+ {fmt(t.tradeVolume)} +
+
+ ); + })} +
+ ); + return ( -
-
+
+
체결시각
체결가
체결량
-
체결강도
- -
- {ticks.length === 0 && ( -
- 체결 데이터가 아직 없습니다. -
- )} - {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 ( -
-
- {fmtTime(t.tickTime)} -
-
- {fmt(t.price)} -
-
- {fmt(t.tradeVolume)} -
-
- {t.tradeStrength.toFixed(2)}% -
-
- ); - })} -
-
+ {shouldUseScrollableList ? ( + {tapeRows} + ) : ( + tapeRows + )}
); } diff --git a/features/trade/components/search/StockSearchForm.tsx b/features/trade/components/search/StockSearchForm.tsx index 77e8e65..4e71040 100644 --- a/features/trade/components/search/StockSearchForm.tsx +++ b/features/trade/components/search/StockSearchForm.tsx @@ -31,7 +31,7 @@ export function StockSearchForm({ }; return ( -
+ {/* ========== SEARCH INPUT ========== */}
@@ -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 && ( diff --git a/features/trade/components/search/TradeSearchSection.tsx b/features/trade/components/search/TradeSearchSection.tsx index 4868680..a430c2d 100644 --- a/features/trade/components/search/TradeSearchSection.tsx +++ b/features/trade/components/search/TradeSearchSection.tsx @@ -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; @@ -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 ( -
- {/* ========== SEARCH SHELL ========== */} -
- +
+ {/* ========== TOP BAR (검색 + 종목 요약 통합) ========== */} +
+ {/* ========== SEARCH SHELL ========== */} +
+ - {/* ========== SEARCH DROPDOWN ========== */} - {isSearchPanelOpen && canSearch && ( -
- {searchResults.length > 0 ? ( - - ) : keyword.trim() ? ( -
- {isSearching ? "검색 중..." : "검색 결과가 없습니다."} -
- ) : searchHistory.length > 0 ? ( - - ) : ( -
- 최근 검색 종목이 없습니다. -
- )} -
- )} + {/* ========== SEARCH DROPDOWN ========== */} + {isSearchPanelOpen && canSearch && ( +
+ {searchResults.length > 0 ? ( + + ) : keyword.trim() ? ( +
+ {isSearching ? "검색 중..." : "검색 결과가 없습니다."} +
+ ) : searchHistory.length > 0 ? ( + + ) : ( +
+ 최근 검색 종목이 없습니다. +
+ )} +
+ )} +
+ +
); } + +/** + * @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 ( +
+
+ 종목을 선택하면 현재가/보유손익이 여기에 표시됩니다. +
+
+ ); + } + + 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 ( +
+
+
+

+ {stock.name} +

+

+ {stock.symbol} · {stock.market} +

+
+ +
+

+ {displayPrice.toLocaleString("ko-KR")} +

+

+ {isRise ? "+" : ""} + {displayChange.toLocaleString("ko-KR")} ( + {isRise ? "+" : ""} + {displayChangeRate.toFixed(2)}%) +

+
+ +
+ + + +
+
+
+ ); +} + +/** + * @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 ( +
+

+ {label} +

+

+ {value} +

+
+ ); +} diff --git a/features/trade/hooks/useOrderbookSubscription.ts b/features/trade/hooks/useOrderbookSubscription.ts index 235b227..623ad36 100644 --- a/features/trade/hooks/useOrderbookSubscription.ts +++ b/features/trade/hooks/useOrderbookSubscription.ts @@ -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(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]); } diff --git a/features/trade/hooks/useTradeTickSubscription.ts b/features/trade/hooks/useTradeTickSubscription.ts index ade740a..7e344b5 100644 --- a/features/trade/hooks/useTradeTickSubscription.ts +++ b/features/trade/hooks/useTradeTickSubscription.ts @@ -45,7 +45,8 @@ export function useTradeTickSubscription({ const activeTradeTrIdRef = useRef(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 }; }