트레이딩창 UI 배치 및 UX 수정 및 기획서 추가
This commit is contained in:
266
common-docs/features-autotrade-design.md
Normal file
266
common-docs/features-autotrade-design.md
Normal 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 키/계좌 정보는 서버 저장을 금지하고 요청 단위 처리만 허용합니다.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
|
||||||
import {
|
import {
|
||||||
type KisRealtimeStockTick,
|
type KisRealtimeStockTick,
|
||||||
@@ -21,10 +21,13 @@ export function useHoldingsRealtime(holdings: DashboardHoldingItem[]) {
|
|||||||
const [realtimeData, setRealtimeData] = useState<
|
const [realtimeData, setRealtimeData] = useState<
|
||||||
Record<string, KisRealtimeStockTick>
|
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(
|
const uniqueSymbols = useMemo(
|
||||||
() => Array.from(new Set((holdings ?? []).map((item) => item.symbol))).sort(),
|
() =>
|
||||||
|
Array.from(new Set((holdings ?? []).map((item) => item.symbol))).sort(),
|
||||||
[holdings],
|
[holdings],
|
||||||
);
|
);
|
||||||
const symbolKey = useMemo(() => uniqueSymbols.join(","), [uniqueSymbols]);
|
const symbolKey = useMemo(() => uniqueSymbols.join(","), [uniqueSymbols]);
|
||||||
@@ -37,14 +40,15 @@ export function useHoldingsRealtime(holdings: DashboardHoldingItem[]) {
|
|||||||
return () => window.clearTimeout(resetTimerId);
|
return () => window.clearTimeout(resetTimerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isConnected) {
|
connectRef.current();
|
||||||
connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsubs: (() => void)[] = [];
|
const unsubs: (() => void)[] = [];
|
||||||
|
|
||||||
uniqueSymbols.forEach((symbol) => {
|
uniqueSymbols.forEach((symbol) => {
|
||||||
const unsub = subscribe(STOCK_REALTIME_TR_ID, symbol, (data) => {
|
const unsub = subscribeRef.current(
|
||||||
|
STOCK_REALTIME_TR_ID,
|
||||||
|
symbol,
|
||||||
|
(data: string) => {
|
||||||
const tick = parseKisRealtimeStockTick(data);
|
const tick = parseKisRealtimeStockTick(data);
|
||||||
if (tick) {
|
if (tick) {
|
||||||
setRealtimeData((prev) => {
|
setRealtimeData((prev) => {
|
||||||
@@ -63,14 +67,15 @@ export function useHoldingsRealtime(holdings: DashboardHoldingItem[]) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
unsubs.push(unsub);
|
unsubs.push(unsub);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubs.forEach((unsub) => unsub());
|
unsubs.forEach((unsub) => unsub());
|
||||||
};
|
};
|
||||||
}, [symbolKey, uniqueSymbols, connect, subscribe, isConnected]);
|
}, [symbolKey, uniqueSymbols]);
|
||||||
|
|
||||||
return { realtimeData, isConnected };
|
return { realtimeData, isConnected };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ export function useKisWebSocket({
|
|||||||
onMessage,
|
onMessage,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
}: UseKisWebSocketParams) {
|
}: 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);
|
const callbackRef = useRef(onMessage);
|
||||||
|
|
||||||
// 콜백 함수가 바뀌어도 재구독하지 않도록 ref 사용
|
// 콜백 함수가 바뀌어도 재구독하지 않도록 ref 사용
|
||||||
@@ -34,10 +36,10 @@ export function useKisWebSocket({
|
|||||||
if (!enabled || !symbol || !trId) return;
|
if (!enabled || !symbol || !trId) return;
|
||||||
|
|
||||||
// 연결 시도 (이미 연결 중이면 스토어에서 무시됨)
|
// 연결 시도 (이미 연결 중이면 스토어에서 무시됨)
|
||||||
connect();
|
connectRef.current();
|
||||||
|
|
||||||
// 구독 요청
|
// 구독 요청
|
||||||
const unsubscribe = subscribe(trId, symbol, (data) => {
|
const unsubscribe = subscribeRef.current(trId, symbol, (data) => {
|
||||||
callbackRef.current?.(data);
|
callbackRef.current?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,7 +47,7 @@ export function useKisWebSocket({
|
|||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
}, [symbol, trId, enabled, connect, subscribe]);
|
}, [symbol, trId, enabled]);
|
||||||
|
|
||||||
return { isConnected };
|
return { isConnected };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,8 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
|||||||
|
|
||||||
// 소켓 생성
|
// 소켓 생성
|
||||||
// socket 변수에 할당하기 전에 로컬 변수로 제어하여 이벤트 핸들러 클로저 문제 방지
|
// socket 변수에 할당하기 전에 로컬 변수로 제어하여 이벤트 핸들러 클로저 문제 방지
|
||||||
const ws = new WebSocket(`${wsConnection.wsUrl}/tryitout`);
|
const ws = new WebSocket(wsConnection.wsUrl);
|
||||||
|
console.log("[KisWebSocket] Connecting to:", wsConnection.wsUrl);
|
||||||
socket = ws;
|
socket = ws;
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
@@ -147,7 +148,7 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
|||||||
reconnectAttempt += 1;
|
reconnectAttempt += 1;
|
||||||
const delayMs = getReconnectDelayMs(reconnectAttempt);
|
const delayMs = getReconnectDelayMs(reconnectAttempt);
|
||||||
console.warn(
|
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);
|
window.clearTimeout(reconnectRetryTimer);
|
||||||
@@ -158,7 +159,10 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasSubscribers && reconnectAttempt >= MAX_AUTO_RECONNECT_ATTEMPTS) {
|
if (
|
||||||
|
hasSubscribers &&
|
||||||
|
reconnectAttempt >= MAX_AUTO_RECONNECT_ATTEMPTS
|
||||||
|
) {
|
||||||
set({
|
set({
|
||||||
error:
|
error:
|
||||||
"실시간 연결이 반복 종료되어 자동 재연결을 중단했습니다. 새로고침 또는 수동 재연결을 시도해 주세요.",
|
"실시간 연결이 반복 종료되어 자동 재연결을 중단했습니다. 새로고침 또는 수동 재연결을 시도해 주세요.",
|
||||||
@@ -175,7 +179,13 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
|||||||
ws.onerror = (event) => {
|
ws.onerror = (event) => {
|
||||||
if (socket === ws) {
|
if (socket === ws) {
|
||||||
isConnecting = false;
|
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({
|
set({
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
error: "웹소켓 연결 중 오류가 발생했습니다.",
|
error: "웹소켓 연결 중 오류가 발생했습니다.",
|
||||||
@@ -207,15 +217,24 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// KIS 제어 메시지: ALREADY IN USE appkey
|
// KIS 제어 메시지: ALREADY IN USE appkey
|
||||||
// 이전 세션이 닫히기 전에 재연결될 때 간헐적으로 발생합니다.
|
// 이전 세션이 닫히기 전에 재연결될 때 발생합니다.
|
||||||
|
// KIS 서버가 세션을 정리하는 데 최소 10~30초가 필요하므로
|
||||||
|
// 충분한 대기 후 재연결합니다.
|
||||||
if (control.msgCd === "OPSP8996") {
|
if (control.msgCd === "OPSP8996") {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastAppKeyConflictAt > 5_000) {
|
if (now - lastAppKeyConflictAt > 5_000) {
|
||||||
lastAppKeyConflictAt = now;
|
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);
|
window.clearTimeout(reconnectRetryTimer);
|
||||||
reconnectRetryTimer = window.setTimeout(() => {
|
reconnectRetryTimer = window.setTimeout(() => {
|
||||||
void get().reconnect({ refreshApproval: false });
|
void get().reconnect({ refreshApproval: false });
|
||||||
}, 1_200);
|
}, 30_000); // 30초 쿨다운
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,9 +274,19 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
|||||||
|
|
||||||
reconnect: async (options) => {
|
reconnect: async (options) => {
|
||||||
const refreshApproval = options?.refreshApproval ?? false;
|
const refreshApproval = options?.refreshApproval ?? false;
|
||||||
|
// disconnect()는 manualDisconnectRequested=true를 설정하므로 직접 호출 금지
|
||||||
|
// 대신 소켓만 직접 닫습니다.
|
||||||
manualDisconnectRequested = false;
|
manualDisconnectRequested = false;
|
||||||
|
window.clearTimeout(reconnectRetryTimer);
|
||||||
|
reconnectRetryTimer = undefined;
|
||||||
const currentSocket = socket;
|
const currentSocket = socket;
|
||||||
get().disconnect();
|
if (
|
||||||
|
currentSocket &&
|
||||||
|
(currentSocket.readyState === WebSocket.OPEN ||
|
||||||
|
currentSocket.readyState === WebSocket.CONNECTING)
|
||||||
|
) {
|
||||||
|
currentSocket.close();
|
||||||
|
}
|
||||||
if (currentSocket && currentSocket.readyState !== WebSocket.CLOSED) {
|
if (currentSocket && currentSocket.readyState !== WebSocket.CLOSED) {
|
||||||
await waitForSocketClose(currentSocket);
|
await waitForSocketClose(currentSocket);
|
||||||
}
|
}
|
||||||
@@ -277,7 +306,10 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
|||||||
) {
|
) {
|
||||||
currentSocket.close();
|
currentSocket.close();
|
||||||
}
|
}
|
||||||
if (currentSocket?.readyState === WebSocket.CLOSED && socket === currentSocket) {
|
if (
|
||||||
|
currentSocket?.readyState === WebSocket.CLOSED &&
|
||||||
|
socket === currentSocket
|
||||||
|
) {
|
||||||
socket = null;
|
socket = null;
|
||||||
}
|
}
|
||||||
set({ isConnected: false });
|
set({ isConnected: false });
|
||||||
@@ -306,11 +338,6 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
subscriberCounts.set(key, currentCount + 1);
|
subscriberCounts.set(key, currentCount + 1);
|
||||||
|
|
||||||
// **연결이 안 되어 있으면 연결 시도**
|
|
||||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
||||||
get().connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 구독 해제 함수 반환
|
// 3. 구독 해제 함수 반환
|
||||||
return () => {
|
return () => {
|
||||||
const callbacks = subscribers.get(key);
|
const callbacks = subscribers.get(key);
|
||||||
@@ -414,7 +441,9 @@ function buildControlErrorMessage(message: KisWsControlMessage) {
|
|||||||
return "실시간 연결이 다른 세션과 충돌해 재연결을 시도합니다.";
|
return "실시간 연결이 다른 세션과 충돌해 재연결을 시도합니다.";
|
||||||
}
|
}
|
||||||
const detail = [message.msg1, message.msgCd].filter(Boolean).join(" / ");
|
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 (subscribedTrId !== trId) return;
|
||||||
if (!normalizedIncomingSymbol) return;
|
if (!normalizedIncomingSymbol) return;
|
||||||
|
|
||||||
const normalizedSubscribedSymbol = normalizeRealtimeSymbol(subscribedSymbol);
|
const normalizedSubscribedSymbol =
|
||||||
|
normalizeRealtimeSymbol(subscribedSymbol);
|
||||||
if (!normalizedSubscribedSymbol) return;
|
if (!normalizedSubscribedSymbol) return;
|
||||||
if (normalizedIncomingSymbol !== normalizedSubscribedSymbol) return;
|
if (normalizedIncomingSymbol !== normalizedSubscribedSymbol) return;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"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 { useRouter } from "next/navigation";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
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 { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
|
||||||
import { TradeAccessGate } from "@/features/trade/components/guards/TradeAccessGate";
|
import { TradeAccessGate } from "@/features/trade/components/guards/TradeAccessGate";
|
||||||
import { TradeDashboardContent } from "@/features/trade/components/layout/TradeDashboardContent";
|
import { TradeDashboardContent } from "@/features/trade/components/layout/TradeDashboardContent";
|
||||||
@@ -36,6 +38,8 @@ export function TradeContainer() {
|
|||||||
// [State] 호가 실시간 데이터 (체결 WS와 동일 소켓에서 수신)
|
// [State] 호가 실시간 데이터 (체결 WS와 동일 소켓에서 수신)
|
||||||
const [realtimeOrderBook, setRealtimeOrderBook] =
|
const [realtimeOrderBook, setRealtimeOrderBook] =
|
||||||
useState<DashboardStockOrderBookResponse | null>(null);
|
useState<DashboardStockOrderBookResponse | null>(null);
|
||||||
|
// [State] 선택 종목과 매칭할 보유 종목 목록
|
||||||
|
const [holdings, setHoldings] = useState<DashboardHoldingItem[]>([]);
|
||||||
const { verifiedCredentials, isKisVerified, _hasHydrated } =
|
const { verifiedCredentials, isKisVerified, _hasHydrated } =
|
||||||
useKisRuntimeStore(
|
useKisRuntimeStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
@@ -60,6 +64,7 @@ export function TradeContainer() {
|
|||||||
} = useStockSearch();
|
} = useStockSearch();
|
||||||
const { selectedStock, loadOverview, updateRealtimeTradeTick } =
|
const { selectedStock, loadOverview, updateRealtimeTradeTick } =
|
||||||
useStockOverview();
|
useStockOverview();
|
||||||
|
const selectedSymbol = selectedStock?.symbol;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [Effect] query string이 남아 들어온 경우 즉시 깨끗한 /trade URL로 정리합니다.
|
* [Effect] query string이 남아 들어온 경우 즉시 깨끗한 /trade URL로 정리합니다.
|
||||||
@@ -83,7 +88,7 @@ export function TradeContainer() {
|
|||||||
const pendingTarget = consumePendingTarget();
|
const pendingTarget = consumePendingTarget();
|
||||||
if (!pendingTarget) return;
|
if (!pendingTarget) return;
|
||||||
|
|
||||||
if (selectedStock?.symbol === pendingTarget.symbol) {
|
if (selectedSymbol === pendingTarget.symbol) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +108,7 @@ export function TradeContainer() {
|
|||||||
verifiedCredentials,
|
verifiedCredentials,
|
||||||
_hasHydrated,
|
_hasHydrated,
|
||||||
consumePendingTarget,
|
consumePendingTarget,
|
||||||
selectedStock?.symbol,
|
selectedSymbol,
|
||||||
loadOverview,
|
loadOverview,
|
||||||
setKeyword,
|
setKeyword,
|
||||||
appendSearchHistory,
|
appendSearchHistory,
|
||||||
@@ -112,6 +117,54 @@ export function TradeContainer() {
|
|||||||
const canTrade = isKisVerified && !!verifiedCredentials;
|
const canTrade = isKisVerified && !!verifiedCredentials;
|
||||||
const canSearch = canTrade;
|
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 {
|
const {
|
||||||
searchShellRef,
|
searchShellRef,
|
||||||
isSearchPanelOpen,
|
isSearchPanelOpen,
|
||||||
@@ -142,12 +195,12 @@ export function TradeContainer() {
|
|||||||
|
|
||||||
// 1. Trade WebSocket (체결 + 호가 통합)
|
// 1. Trade WebSocket (체결 + 호가 통합)
|
||||||
const { latestTick, recentTradeTicks } = useKisTradeWebSocket(
|
const { latestTick, recentTradeTicks } = useKisTradeWebSocket(
|
||||||
selectedStock?.symbol,
|
selectedSymbol,
|
||||||
verifiedCredentials,
|
verifiedCredentials,
|
||||||
isKisVerified,
|
isKisVerified,
|
||||||
updateRealtimeTradeTick,
|
updateRealtimeTradeTick,
|
||||||
{
|
{
|
||||||
orderBookSymbol: selectedStock?.symbol,
|
orderBookSymbol: selectedSymbol,
|
||||||
orderBookMarket: selectedStock?.market,
|
orderBookMarket: selectedStock?.market,
|
||||||
onOrderBookMessage: handleOrderBookMessage,
|
onOrderBookMessage: handleOrderBookMessage,
|
||||||
},
|
},
|
||||||
@@ -155,12 +208,12 @@ export function TradeContainer() {
|
|||||||
|
|
||||||
// 2. OrderBook (REST 초기 조회 + WS 실시간 병합)
|
// 2. OrderBook (REST 초기 조회 + WS 실시간 병합)
|
||||||
const { orderBook, isLoading: isOrderBookLoading } = useOrderBook(
|
const { orderBook, isLoading: isOrderBookLoading } = useOrderBook(
|
||||||
selectedStock?.symbol,
|
selectedSymbol,
|
||||||
selectedStock?.market,
|
selectedStock?.market,
|
||||||
verifiedCredentials,
|
verifiedCredentials,
|
||||||
isKisVerified,
|
isKisVerified,
|
||||||
{
|
{
|
||||||
enabled: !!selectedStock && !!verifiedCredentials && isKisVerified,
|
enabled: !!selectedSymbol && !!verifiedCredentials && isKisVerified,
|
||||||
externalRealtimeOrderBook: realtimeOrderBook,
|
externalRealtimeOrderBook: realtimeOrderBook,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -210,7 +263,7 @@ export function TradeContainer() {
|
|||||||
if (!ensureSearchReady() || !verifiedCredentials) return;
|
if (!ensureSearchReady() || !verifiedCredentials) return;
|
||||||
|
|
||||||
// 같은 종목 재선택 시 중복 overview 요청을 막고 검색 목록만 닫습니다.
|
// 같은 종목 재선택 시 중복 overview 요청을 막고 검색 목록만 닫습니다.
|
||||||
if (selectedStock?.symbol === item.symbol) {
|
if (selectedSymbol === item.symbol) {
|
||||||
clearSearch();
|
clearSearch();
|
||||||
closeSearchPanel();
|
closeSearchPanel();
|
||||||
return;
|
return;
|
||||||
@@ -227,7 +280,7 @@ export function TradeContainer() {
|
|||||||
[
|
[
|
||||||
ensureSearchReady,
|
ensureSearchReady,
|
||||||
verifiedCredentials,
|
verifiedCredentials,
|
||||||
selectedStock?.symbol,
|
selectedSymbol,
|
||||||
clearSearch,
|
clearSearch,
|
||||||
closeSearchPanel,
|
closeSearchPanel,
|
||||||
setKeyword,
|
setKeyword,
|
||||||
@@ -250,14 +303,18 @@ export function TradeContainer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 ========== */}
|
{/* ========== SEARCH SECTION ========== */}
|
||||||
<TradeSearchSection
|
<TradeSearchSection
|
||||||
canSearch={canSearch}
|
canSearch={canSearch}
|
||||||
isSearchPanelOpen={isSearchPanelOpen}
|
isSearchPanelOpen={isSearchPanelOpen}
|
||||||
isSearching={isSearching}
|
isSearching={isSearching}
|
||||||
keyword={keyword}
|
keyword={keyword}
|
||||||
selectedSymbol={selectedStock?.symbol}
|
selectedStock={selectedStock}
|
||||||
|
selectedSymbol={selectedSymbol}
|
||||||
|
currentPrice={currentPrice}
|
||||||
|
change={change}
|
||||||
|
changeRate={changeRate}
|
||||||
searchResults={searchResults}
|
searchResults={searchResults}
|
||||||
searchHistory={searchHistory}
|
searchHistory={searchHistory}
|
||||||
searchShellRef={searchShellRef}
|
searchShellRef={searchShellRef}
|
||||||
@@ -280,9 +337,7 @@ export function TradeContainer() {
|
|||||||
orderBook={orderBook}
|
orderBook={orderBook}
|
||||||
isOrderBookLoading={isOrderBookLoading}
|
isOrderBookLoading={isOrderBookLoading}
|
||||||
referencePrice={referencePrice}
|
referencePrice={referencePrice}
|
||||||
currentPrice={currentPrice}
|
matchedHolding={matchedHolding}
|
||||||
change={change}
|
|
||||||
changeRate={changeRate}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
const UP_COLOR = "#ef4444";
|
const UP_COLOR = "#ef4444";
|
||||||
const MINUTE_SYNC_INTERVAL_MS = 30000;
|
const MINUTE_SYNC_INTERVAL_MS = 30000;
|
||||||
const REALTIME_STALE_THRESHOLD_MS = 12000;
|
const REALTIME_STALE_THRESHOLD_MS = 12000;
|
||||||
|
const CHART_MIN_HEIGHT = 220;
|
||||||
|
|
||||||
interface ChartPalette {
|
interface ChartPalette {
|
||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
@@ -60,7 +61,10 @@ const DEFAULT_CHART_PALETTE: ChartPalette = {
|
|||||||
|
|
||||||
function readCssVar(name: string, fallback: string) {
|
function readCssVar(name: string, fallback: string) {
|
||||||
if (typeof window === "undefined") return fallback;
|
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;
|
return value || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,16 +73,28 @@ function getChartPaletteFromCssVars(themeMode: "light" | "dark"): ChartPalette {
|
|||||||
const backgroundVar = isDark
|
const backgroundVar = isDark
|
||||||
? "--brand-chart-background-dark"
|
? "--brand-chart-background-dark"
|
||||||
: "--brand-chart-background-light";
|
: "--brand-chart-background-light";
|
||||||
const textVar = isDark ? "--brand-chart-text-dark" : "--brand-chart-text-light";
|
const textVar = isDark
|
||||||
const borderVar = isDark ? "--brand-chart-border-dark" : "--brand-chart-border-light";
|
? "--brand-chart-text-dark"
|
||||||
const gridVar = isDark ? "--brand-chart-grid-dark" : "--brand-chart-grid-light";
|
: "--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
|
const crosshairVar = isDark
|
||||||
? "--brand-chart-crosshair-dark"
|
? "--brand-chart-crosshair-dark"
|
||||||
: "--brand-chart-crosshair-light";
|
: "--brand-chart-crosshair-light";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
backgroundColor: readCssVar(backgroundVar, DEFAULT_CHART_PALETTE.backgroundColor),
|
backgroundColor: readCssVar(
|
||||||
downColor: readCssVar("--brand-chart-down", DEFAULT_CHART_PALETTE.downColor),
|
backgroundVar,
|
||||||
|
DEFAULT_CHART_PALETTE.backgroundColor,
|
||||||
|
),
|
||||||
|
downColor: readCssVar(
|
||||||
|
"--brand-chart-down",
|
||||||
|
DEFAULT_CHART_PALETTE.downColor,
|
||||||
|
),
|
||||||
volumeDownColor: readCssVar(
|
volumeDownColor: readCssVar(
|
||||||
"--brand-chart-volume-down",
|
"--brand-chart-volume-down",
|
||||||
DEFAULT_CHART_PALETTE.volumeDownColor,
|
DEFAULT_CHART_PALETTE.volumeDownColor,
|
||||||
@@ -237,7 +253,8 @@ export function StockLineChart({
|
|||||||
* @see lib/kis/domestic.ts getDomesticChart cursor
|
* @see lib/kis/domestic.ts getDomesticChart cursor
|
||||||
*/
|
*/
|
||||||
const handleLoadMore = useCallback(async () => {
|
const handleLoadMore = useCallback(async () => {
|
||||||
if (!symbol || !credentials || !nextCursor || loadingMoreRef.current) return;
|
if (!symbol || !credentials || !nextCursor || loadingMoreRef.current)
|
||||||
|
return;
|
||||||
|
|
||||||
loadingMoreRef.current = true;
|
loadingMoreRef.current = true;
|
||||||
setIsLoadingMore(true);
|
setIsLoadingMore(true);
|
||||||
@@ -284,7 +301,7 @@ export function StockLineChart({
|
|||||||
|
|
||||||
const chart = createChart(container, {
|
const chart = createChart(container, {
|
||||||
width: Math.max(container.clientWidth, 320),
|
width: Math.max(container.clientWidth, 320),
|
||||||
height: Math.max(container.clientHeight, 340),
|
height: Math.max(container.clientHeight, CHART_MIN_HEIGHT),
|
||||||
layout: {
|
layout: {
|
||||||
background: { type: ColorType.Solid, color: palette.backgroundColor },
|
background: { type: ColorType.Solid, color: palette.backgroundColor },
|
||||||
textColor: palette.textColor,
|
textColor: palette.textColor,
|
||||||
@@ -298,7 +315,7 @@ export function StockLineChart({
|
|||||||
borderColor: palette.borderColor,
|
borderColor: palette.borderColor,
|
||||||
scaleMargins: {
|
scaleMargins: {
|
||||||
top: 0.08,
|
top: 0.08,
|
||||||
bottom: 0.24,
|
bottom: 0.2,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
@@ -372,7 +389,7 @@ export function StockLineChart({
|
|||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
chart.resize(
|
chart.resize(
|
||||||
Math.max(container.clientWidth, 320),
|
Math.max(container.clientWidth, 320),
|
||||||
Math.max(container.clientHeight, 340),
|
Math.max(container.clientHeight, CHART_MIN_HEIGHT),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
resizeObserver.observe(container);
|
resizeObserver.observe(container);
|
||||||
@@ -380,7 +397,7 @@ export function StockLineChart({
|
|||||||
const rafId = window.requestAnimationFrame(() => {
|
const rafId = window.requestAnimationFrame(() => {
|
||||||
chart.resize(
|
chart.resize(
|
||||||
Math.max(container.clientWidth, 320),
|
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;
|
if (disposed) return;
|
||||||
|
|
||||||
let mergedBars = normalizeCandles(firstPage.candles, timeframe);
|
let mergedBars = normalizeCandles(firstPage.candles, timeframe);
|
||||||
let resolvedNextCursor = firstPage.hasMore ? firstPage.nextCursor : null;
|
let resolvedNextCursor = firstPage.hasMore
|
||||||
|
? firstPage.nextCursor
|
||||||
|
: null;
|
||||||
|
|
||||||
// 분봉은 기본 3페이지까지 순차 조회해 오전 구간 누락을 줄입니다.
|
// 분봉은 기본 3페이지까지 순차 조회해 오전 구간 누락을 줄입니다.
|
||||||
if (
|
if (
|
||||||
@@ -474,7 +493,9 @@ export function StockLineChart({
|
|||||||
|
|
||||||
const olderBars = normalizeCandles(olderPage.candles, timeframe);
|
const olderBars = normalizeCandles(olderPage.candles, timeframe);
|
||||||
mergedBars = mergeBars(olderBars, mergedBars);
|
mergedBars = mergeBars(olderBars, mergedBars);
|
||||||
resolvedNextCursor = olderPage.hasMore ? olderPage.nextCursor : null;
|
resolvedNextCursor = olderPage.hasMore
|
||||||
|
? olderPage.nextCursor
|
||||||
|
: null;
|
||||||
minuteCursor = olderPage.hasMore ? olderPage.nextCursor : null;
|
minuteCursor = olderPage.hasMore ? olderPage.nextCursor : null;
|
||||||
extraPageCount += 1;
|
extraPageCount += 1;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -522,7 +543,7 @@ export function StockLineChart({
|
|||||||
}
|
}
|
||||||
}, [isChartReady, renderableBars, setSeriesData]);
|
}, [isChartReady, renderableBars, setSeriesData]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다.
|
* @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다.
|
||||||
* @see features/trade/hooks/useKisTradeWebSocket.ts latestTick
|
* @see features/trade/hooks/useKisTradeWebSocket.ts latestTick
|
||||||
* @see features/trade/components/chart/chart-utils.ts toRealtimeTickBar
|
* @see features/trade/components/chart/chart-utils.ts toRealtimeTickBar
|
||||||
@@ -600,7 +621,7 @@ export function StockLineChart({
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
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 ========== */}
|
{/* ========== 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 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">
|
<div className="flex flex-wrap items-center gap-1 text-xs sm:text-sm">
|
||||||
@@ -668,14 +689,15 @@ export function StockLineChart({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-[11px] text-muted-foreground dark:text-brand-100/85 sm:text-xs">
|
<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{" "}
|
O {formatPrice(latest?.open ?? 0)} H {formatPrice(latest?.high ?? 0)}{" "}
|
||||||
{formatPrice(latest?.low ?? 0)} C{" "}
|
L {formatPrice(latest?.low ?? 0)} C{" "}
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
change >= 0 ? "text-red-600" : "text-blue-600 dark:text-blue-400",
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// import { Badge } from "@/components/ui/badge";
|
// import { Badge } from "@/components/ui/badge";
|
||||||
import { Separator } from "@/components/ui/separator";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface StockHeaderProps {
|
interface StockHeaderProps {
|
||||||
@@ -13,6 +13,10 @@ interface StockHeaderProps {
|
|||||||
volume?: string;
|
volume?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 선택된 종목의 현재가/등락/시세 요약 헤더를 렌더링합니다.
|
||||||
|
* @see features/trade/components/layout/TradeDashboardContent.tsx - StockHeader 사용 (header prop으로 전달)
|
||||||
|
*/
|
||||||
export function StockHeader({
|
export function StockHeader({
|
||||||
stock,
|
stock,
|
||||||
price,
|
price,
|
||||||
@@ -22,68 +26,154 @@ export function StockHeader({
|
|||||||
low,
|
low,
|
||||||
volume,
|
volume,
|
||||||
}: StockHeaderProps) {
|
}: StockHeaderProps) {
|
||||||
const isRise = changeRate.startsWith("+") || parseFloat(changeRate) > 0;
|
const changeRateNum = parseFloat(changeRate);
|
||||||
const isFall = changeRate.startsWith("-") || parseFloat(changeRate) < 0;
|
const isRise = changeRateNum > 0;
|
||||||
|
const isFall = changeRateNum < 0;
|
||||||
const colorClass = isRise
|
const colorClass = isRise
|
||||||
? "text-red-500"
|
? "text-red-500"
|
||||||
: isFall
|
: isFall
|
||||||
? "text-blue-600 dark:text-blue-400"
|
? "text-blue-600 dark:text-blue-400"
|
||||||
: "text-foreground";
|
: "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 (
|
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 ========== */}
|
{/* ========== STOCK SUMMARY ========== */}
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
{/* 종목명 + 코드 */}
|
||||||
|
<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">
|
<h1 className="truncate text-base font-bold leading-tight text-foreground dark:text-brand-50 sm:text-lg">
|
||||||
{stock.name}
|
{stock.name}
|
||||||
</h1>
|
</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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn("shrink-0 text-right", colorClass)}>
|
{/* 현재가 + 등락 */}
|
||||||
<span className="block text-xl font-bold tracking-tight sm:text-2xl">{price}</span>
|
<div
|
||||||
<span className="text-[11px] font-medium sm:text-xs">
|
className={cn(
|
||||||
{changeRate}% <span className="ml-1 text-[11px] sm:text-xs">{change}</span>
|
"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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ========== STATS ========== */}
|
{/* ========== MOBILE STATS ========== */}
|
||||||
<div className="mt-1.5 grid grid-cols-3 gap-2 text-xs md:hidden">
|
<div className="mt-2 grid grid-cols-3 gap-1.5 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">
|
<StatCard label="고가" value={high || "--"} tone="ask" />
|
||||||
<p className="text-[11px] text-muted-foreground dark:text-brand-100/70">고가</p>
|
<StatCard label="저가" value={low || "--"} tone="bid" />
|
||||||
<p className="font-medium text-red-500">{high || "--"}</p>
|
<StatCard label="거래량" value={volume || "--"} />
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className="mt-1.5 md:hidden" />
|
<Separator className="mt-1.5 md:hidden" />
|
||||||
|
|
||||||
{/* ========== DESKTOP STATS ========== */}
|
{/* ========== DESKTOP STATS ========== */}
|
||||||
<div className="hidden items-center justify-end gap-5 pt-1 text-sm md:flex">
|
<div className="hidden items-center justify-end gap-4 pt-1.5 md:flex">
|
||||||
<div className="flex flex-col items-end">
|
<DesktopStat label="전일종가" value={prevClose} />
|
||||||
<span className="text-muted-foreground text-xs dark:text-brand-100/70">고가</span>
|
<DesktopStat label="시가" value={open} />
|
||||||
<span className="font-medium text-red-500">{high || "--"}</span>
|
<DesktopStat label="고가" value={high || "--"} tone="ask" />
|
||||||
</div>
|
<DesktopStat label="저가" value={low || "--"} tone="bid" />
|
||||||
<div className="flex flex-col items-end">
|
<DesktopStat label="거래량" value={volume ? `${volume}주` : "--"} />
|
||||||
<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>
|
</div>
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
311
features/trade/components/holdings/HoldingsPanel.tsx
Normal file
311
features/trade/components/holdings/HoldingsPanel.tsx
Normal 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)}원 (
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface DashboardLayoutProps {
|
interface DashboardLayoutProps {
|
||||||
header: ReactNode;
|
header?: ReactNode;
|
||||||
chart: ReactNode;
|
chart: ReactNode;
|
||||||
orderBook: ReactNode;
|
orderBook: ReactNode;
|
||||||
orderForm: ReactNode;
|
orderForm: ReactNode;
|
||||||
@@ -14,8 +14,9 @@ interface DashboardLayoutProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 트레이드 본문 레이아웃을 구성합니다. 상단 차트 영역은 보임/숨김 토글을 지원합니다.
|
* @description 트레이드 본문을 업비트 스타일의 2단 레이아웃으로 렌더링합니다.
|
||||||
* @see features/trade/components/layout/TradeDashboardContent.tsx 상위 컴포넌트에서 차트 토글 상태를 관리하고 본 레이아웃에 전달합니다.
|
* @summary UI 흐름: TradeDashboardContent -> DashboardLayout -> 상단(차트) + 하단(호가/주문) 배치
|
||||||
|
* @see features/trade/components/layout/TradeDashboardContent.tsx - 차트 토글 상태와 슬롯 컴포넌트를 전달합니다.
|
||||||
*/
|
*/
|
||||||
export function DashboardLayout({
|
export function DashboardLayout({
|
||||||
header,
|
header,
|
||||||
@@ -29,54 +30,46 @@ export function DashboardLayout({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col bg-background dark:bg-[linear-gradient(135deg,rgba(53,35,86,0.18),rgba(13,13,20,0.98))]",
|
"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))]",
|
||||||
// Mobile: Scrollable page height
|
|
||||||
"min-h-[calc(100vh-64px)]",
|
|
||||||
// Desktop: Fixed height, no window scroll
|
|
||||||
"xl:h-[calc(100vh-64px)] xl:overflow-hidden",
|
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* 1. Header Area */}
|
{/* ========== 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">
|
<div className="flex-none border-b border-brand-100 bg-white dark:border-brand-800/45 dark:bg-brand-900/22">
|
||||||
{header}
|
{header}
|
||||||
</div>
|
</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">
|
{/* ========== 2. MAIN CONTENT AREA ========== */}
|
||||||
{/* ========== CHART SECTION ========== */}
|
<div className="flex-1 min-h-0 overflow-y-auto xl:overflow-hidden">
|
||||||
<section className="flex-none border-b border-border dark:border-brand-800/45">
|
<div className="flex min-h-full flex-col xl:h-full xl:min-h-0 xl:overflow-hidden">
|
||||||
<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">
|
{/* ========== TOP: CHART AREA ========== */}
|
||||||
<div className="min-w-0">
|
<section className="flex min-h-0 flex-col border-b border-border dark:border-brand-800/45 xl:h-[34%] xl:min-h-[200px]">
|
||||||
<p className="text-xs font-semibold text-foreground dark:text-brand-50 sm:text-sm">
|
{/* 모바일 전용 차트 토글 */}
|
||||||
|
<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>
|
||||||
<p className="text-[10px] text-muted-foreground dark:text-brand-100/70 sm:text-[11px]">
|
|
||||||
거래 화면 집중을 위해 기본은 접힌 상태입니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{/* UI 흐름: 차트 토글 버튼 -> onToggleChart 호출 -> TradeDashboardContent의 상태 변경 -> 차트 wrapper 높이 반영 */}
|
{/* UI 흐름: 토글 클릭 -> onToggleChart -> 상위 상태 변경 -> 차트 표시/숨김 */}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onToggleChart}
|
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}
|
aria-expanded={isChartVisible}
|
||||||
>
|
>
|
||||||
{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>
|
</Button>
|
||||||
@@ -84,28 +77,28 @@ export function DashboardLayout({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"overflow-hidden border-t border-border/70 transition-[max-height,opacity] duration-200 dark:border-brand-800/45",
|
"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-[56vh] opacity-100" : "max-h-0 opacity-0",
|
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}
|
{chart}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ========== ORDERBOOK + ORDER SECTION ========== */}
|
{/* ========== BOTTOM: ORDERBOOK + ORDER AREA ========== */}
|
||||||
<div className="flex flex-1 min-h-0 flex-col xl:flex-row xl:overflow-hidden">
|
<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">
|
||||||
<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="flex min-h-0 flex-col border-b border-border dark:border-brand-800/45 xl:border-b-0 xl:border-r">
|
||||||
<div className="h-[390px] min-h-0 sm:h-[430px] xl:h-full">
|
<div className="min-h-0 xl:h-full xl:min-h-0">
|
||||||
{orderBook}
|
{orderBook}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
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 type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||||
import { StockLineChart } from "@/features/trade/components/chart/StockLineChart";
|
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 { DashboardLayout } from "@/features/trade/components/layout/DashboardLayout";
|
||||||
import { OrderForm } from "@/features/trade/components/order/OrderForm";
|
import { OrderForm } from "@/features/trade/components/order/OrderForm";
|
||||||
import { OrderBook } from "@/features/trade/components/orderbook/OrderBook";
|
import { OrderBook } from "@/features/trade/components/orderbook/OrderBook";
|
||||||
@@ -14,36 +14,32 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
interface TradeDashboardContentProps {
|
interface TradeDashboardContentProps {
|
||||||
selectedStock: DashboardStockItem | null;
|
selectedStock: DashboardStockItem | null;
|
||||||
|
matchedHolding?: DashboardHoldingItem | null;
|
||||||
verifiedCredentials: KisRuntimeCredentials | null;
|
verifiedCredentials: KisRuntimeCredentials | null;
|
||||||
latestTick: DashboardRealtimeTradeTick | null;
|
latestTick: DashboardRealtimeTradeTick | null;
|
||||||
recentTradeTicks: DashboardRealtimeTradeTick[];
|
recentTradeTicks: DashboardRealtimeTradeTick[];
|
||||||
orderBook: DashboardStockOrderBookResponse | null;
|
orderBook: DashboardStockOrderBookResponse | null;
|
||||||
isOrderBookLoading: boolean;
|
isOrderBookLoading: boolean;
|
||||||
referencePrice?: number;
|
referencePrice?: number;
|
||||||
currentPrice?: number;
|
|
||||||
change?: number;
|
|
||||||
changeRate?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 트레이드 본문(헤더/차트/호가/주문)을 조합해서 렌더링합니다.
|
* @description 트레이드 본문(차트/체결+호가/주문)을 조합하여 렌더링합니다.
|
||||||
* @see features/trade/components/TradeContainer.tsx TradeContainer가 화면 조합 코드를 단순화하기 위해 사용합니다.
|
* @see features/trade/components/TradeContainer.tsx - TradeDashboardContent 렌더링 (selectedStock, verifiedCredentials 등 전달)
|
||||||
* @see features/trade/components/layout/DashboardLayout.tsx 실제 4분할 레이아웃은 DashboardLayout에서 처리합니다.
|
* @see features/trade/components/layout/DashboardLayout.tsx - 3열 레이아웃(차트 | 체결+호가 | 매도)을 처리합니다.
|
||||||
*/
|
*/
|
||||||
export function TradeDashboardContent({
|
export function TradeDashboardContent({
|
||||||
selectedStock,
|
selectedStock,
|
||||||
|
matchedHolding,
|
||||||
verifiedCredentials,
|
verifiedCredentials,
|
||||||
latestTick,
|
latestTick,
|
||||||
recentTradeTicks,
|
recentTradeTicks,
|
||||||
orderBook,
|
orderBook,
|
||||||
isOrderBookLoading,
|
isOrderBookLoading,
|
||||||
referencePrice,
|
referencePrice,
|
||||||
currentPrice,
|
|
||||||
change,
|
|
||||||
changeRate,
|
|
||||||
}: TradeDashboardContentProps) {
|
}: TradeDashboardContentProps) {
|
||||||
// [State] 차트 영역 보임/숨김 상태
|
// [State] 차트 영역 보임/숨김 - 요청사항 반영: 모바일에서도 기본 표시
|
||||||
const [isChartVisible, setIsChartVisible] = useState(false);
|
const [isChartVisible, setIsChartVisible] = useState(true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -54,21 +50,6 @@ export function TradeDashboardContent({
|
|||||||
>
|
>
|
||||||
{/* ========== DASHBOARD LAYOUT ========== */}
|
{/* ========== DASHBOARD LAYOUT ========== */}
|
||||||
<DashboardLayout
|
<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={
|
chart={
|
||||||
selectedStock ? (
|
selectedStock ? (
|
||||||
<div className="p-0 h-full flex flex-col">
|
<div className="p-0 h-full flex flex-col">
|
||||||
@@ -95,7 +76,12 @@ export function TradeDashboardContent({
|
|||||||
isLoading={isOrderBookLoading}
|
isLoading={isOrderBookLoading}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
orderForm={<OrderForm stock={selectedStock ?? undefined} />}
|
orderForm={
|
||||||
|
<OrderForm
|
||||||
|
stock={selectedStock ?? undefined}
|
||||||
|
matchedHolding={matchedHolding}
|
||||||
|
/>
|
||||||
|
}
|
||||||
isChartVisible={isChartVisible}
|
isChartVisible={isChartVisible}
|
||||||
onToggleChart={() => setIsChartVisible((prev) => !prev)}
|
onToggleChart={() => setIsChartVisible((prev) => !prev)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -9,28 +12,35 @@ import type {
|
|||||||
DashboardOrderSide,
|
DashboardOrderSide,
|
||||||
DashboardStockItem,
|
DashboardStockItem,
|
||||||
} from "@/features/trade/types/trade.types";
|
} from "@/features/trade/types/trade.types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface OrderFormProps {
|
interface OrderFormProps {
|
||||||
stock?: DashboardStockItem;
|
stock?: DashboardStockItem;
|
||||||
|
matchedHolding?: DashboardHoldingItem | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 대시보드 주문 패널에서 매수/매도 주문을 입력하고 전송합니다.
|
* @description 대시보드 주문 패널에서 매수/매도 주문을 입력하고 전송합니다.
|
||||||
* @see features/trade/hooks/useOrder.ts placeOrder - 주문 API 호출
|
* @see features/trade/hooks/useOrder.ts - placeOrder 주문 API 호출
|
||||||
* @see features/trade/components/TradeContainer.tsx OrderForm - 우측 주문 패널 렌더링
|
* @see features/trade/components/layout/TradeDashboardContent.tsx - orderForm prop으로 DashboardLayout에 전달
|
||||||
*/
|
*/
|
||||||
export function OrderForm({ stock }: OrderFormProps) {
|
export function OrderForm({ stock, matchedHolding }: OrderFormProps) {
|
||||||
const verifiedCredentials = useKisRuntimeStore(
|
const verifiedCredentials = useKisRuntimeStore(
|
||||||
(state) => state.verifiedCredentials,
|
(state) => state.verifiedCredentials,
|
||||||
);
|
);
|
||||||
const { placeOrder, isLoading, error } = useOrder();
|
const { placeOrder, isLoading, error } = useOrder();
|
||||||
|
|
||||||
// ========== FORM STATE ==========
|
// ========== FORM STATE ==========
|
||||||
const [price, setPrice] = useState<string>(stock?.currentPrice.toString() || "");
|
const [price, setPrice] = useState<string>(
|
||||||
|
stock?.currentPrice.toString() || "",
|
||||||
|
);
|
||||||
const [quantity, setQuantity] = useState<string>("");
|
const [quantity, setQuantity] = useState<string>("");
|
||||||
const [activeTab, setActiveTab] = useState<"buy" | "sell">("buy");
|
const [activeTab, setActiveTab] = useState<"buy" | "sell">("buy");
|
||||||
|
|
||||||
// ========== ORDER HANDLER ==========
|
// ========== ORDER HANDLER ==========
|
||||||
|
/**
|
||||||
|
* UI 흐름: 매수하기/매도하기 버튼 클릭 -> handleOrder -> placeOrder API 호출 -> 주문번호 반환 -> alert
|
||||||
|
*/
|
||||||
const handleOrder = async (side: DashboardOrderSide) => {
|
const handleOrder = async (side: DashboardOrderSide) => {
|
||||||
if (!stock || !verifiedCredentials) return;
|
if (!stock || !verifiedCredentials) return;
|
||||||
|
|
||||||
@@ -79,34 +89,67 @@ export function OrderForm({ stock }: OrderFormProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isMarketDataAvailable = Boolean(stock);
|
const isMarketDataAvailable = Boolean(stock);
|
||||||
|
const isBuy = activeTab === "buy";
|
||||||
|
|
||||||
return (
|
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
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={(value) => setActiveTab(value as "buy" | "sell")}
|
onValueChange={(value) => setActiveTab(value as "buy" | "sell")}
|
||||||
className="flex h-full w-full flex-col"
|
className="flex h-full w-full flex-col"
|
||||||
>
|
>
|
||||||
{/* ========== ORDER SIDE TABS ========== */}
|
{/* ========== 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
|
<TabsTrigger
|
||||||
value="buy"
|
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>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="sell"
|
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>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</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 ========== */}
|
{/* ========== BUY TAB ========== */}
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value="buy"
|
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
|
<OrderInputs
|
||||||
type="buy"
|
type="buy"
|
||||||
@@ -120,19 +163,26 @@ export function OrderForm({ stock }: OrderFormProps) {
|
|||||||
errorMessage={error}
|
errorMessage={error}
|
||||||
/>
|
/>
|
||||||
<PercentButtons onSelect={setPercent} />
|
<PercentButtons onSelect={setPercent} />
|
||||||
|
<div className="mt-auto space-y-2.5 sm:space-y-3">
|
||||||
|
<HoldingInfoPanel holding={matchedHolding} />
|
||||||
<Button
|
<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"
|
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}
|
disabled={isLoading || !isMarketDataAvailable}
|
||||||
onClick={() => handleOrder("buy")}
|
onClick={() => handleOrder("buy")}
|
||||||
>
|
>
|
||||||
{isLoading ? <Loader2 className="mr-2 animate-spin" /> : "매수하기"}
|
{isLoading ? (
|
||||||
|
<Loader2 className="mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"매수하기"
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ========== SELL TAB ========== */}
|
{/* ========== SELL TAB ========== */}
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value="sell"
|
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
|
<OrderInputs
|
||||||
type="sell"
|
type="sell"
|
||||||
@@ -146,13 +196,20 @@ export function OrderForm({ stock }: OrderFormProps) {
|
|||||||
errorMessage={error}
|
errorMessage={error}
|
||||||
/>
|
/>
|
||||||
<PercentButtons onSelect={setPercent} />
|
<PercentButtons onSelect={setPercent} />
|
||||||
|
<div className="mt-auto space-y-2.5 sm:space-y-3">
|
||||||
|
<HoldingInfoPanel holding={matchedHolding} />
|
||||||
<Button
|
<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"
|
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}
|
disabled={isLoading || !isMarketDataAvailable}
|
||||||
onClick={() => handleOrder("sell")}
|
onClick={() => handleOrder("sell")}
|
||||||
>
|
>
|
||||||
{isLoading ? <Loader2 className="mr-2 animate-spin" /> : "매도하기"}
|
{isLoading ? (
|
||||||
|
<Loader2 className="mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"매도하기"
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,7 +218,7 @@ export function OrderForm({ stock }: OrderFormProps) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 주문 입력 영역(가격/수량/총액)을 렌더링합니다.
|
* @description 주문 입력 영역(가격/수량/총액)을 렌더링합니다.
|
||||||
* @see features/trade/components/order/OrderForm.tsx OrderForm - 매수/매도 탭에서 공용 호출
|
* @see features/trade/components/order/OrderForm.tsx - OrderForm 매수/매도 탭에서 공용 호출
|
||||||
*/
|
*/
|
||||||
function OrderInputs({
|
function OrderInputs({
|
||||||
type,
|
type,
|
||||||
@@ -184,25 +241,36 @@ function OrderInputs({
|
|||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
errorMessage: string | null;
|
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 (
|
return (
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-2 sm:space-y-2.5">
|
||||||
<div className="flex justify-between text-xs text-muted-foreground">
|
{/* 주문 가능 */}
|
||||||
<span>주문가능</span>
|
<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>- {type === "buy" ? "KRW" : "주"}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
{hasError && (
|
{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}
|
{errorMessage}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 가격 입력 */}
|
||||||
<div className="grid grid-cols-4 items-center gap-2">
|
<div className="grid grid-cols-4 items-center gap-2">
|
||||||
<span className="text-xs font-medium sm:text-sm">
|
<span className={labelClass}>
|
||||||
{type === "buy" ? "매수가격" : "매도가격"}
|
{type === "buy" ? "매수가격" : "매도가격"}
|
||||||
</span>
|
</span>
|
||||||
<Input
|
<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"
|
placeholder="0"
|
||||||
value={price}
|
value={price}
|
||||||
onChange={(e) => setPrice(e.target.value)}
|
onChange={(e) => setPrice(e.target.value)}
|
||||||
@@ -210,10 +278,11 @@ function OrderInputs({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 수량 입력 */}
|
||||||
<div className="grid grid-cols-4 items-center gap-2">
|
<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
|
<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"
|
placeholder="0"
|
||||||
value={quantity}
|
value={quantity}
|
||||||
onChange={(e) => setQuantity(e.target.value)}
|
onChange={(e) => setQuantity(e.target.value)}
|
||||||
@@ -221,13 +290,15 @@ function OrderInputs({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 총액 */}
|
||||||
<div className="grid grid-cols-4 items-center gap-2">
|
<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
|
<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"
|
className={cn(inputClass, "bg-muted/40 dark:bg-black/20")}
|
||||||
value={totalPrice.toLocaleString()}
|
value={totalPrice > 0 ? `${totalPrice.toLocaleString()}원` : ""}
|
||||||
readOnly
|
readOnly
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
placeholder="0원"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,17 +307,17 @@ function OrderInputs({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 주문 비율(10/25/50/100%) 단축 버튼을 표시합니다.
|
* @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 }) {
|
function PercentButtons({ onSelect }: { onSelect: (pct: string) => void }) {
|
||||||
return (
|
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) => (
|
{["10%", "25%", "50%", "100%"].map((pct) => (
|
||||||
<Button
|
<Button
|
||||||
key={pct}
|
key={pct}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
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)}
|
onClick={() => onSelect(pct)}
|
||||||
>
|
>
|
||||||
{pct}
|
{pct}
|
||||||
@@ -255,3 +326,80 @@ function PercentButtons({ onSelect }: { onSelect: (pct: string) => void }) {
|
|||||||
</div>
|
</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";
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ interface BookRow {
|
|||||||
* @description 실시간 호가 레벨 데이터가 실제 값을 포함하는지 검사합니다.
|
* @description 실시간 호가 레벨 데이터가 실제 값을 포함하는지 검사합니다.
|
||||||
* @see features/trade/components/orderbook/OrderBook.tsx levels 계산에서 WS 호가 유효성 판단에 사용합니다.
|
* @see features/trade/components/orderbook/OrderBook.tsx levels 계산에서 WS 호가 유효성 판단에 사용합니다.
|
||||||
*/
|
*/
|
||||||
function hasOrderBookLevelData(levels: DashboardStockOrderBookResponse["levels"]) {
|
function hasOrderBookLevelData(
|
||||||
|
levels: DashboardStockOrderBookResponse["levels"],
|
||||||
|
) {
|
||||||
return levels.some(
|
return levels.some(
|
||||||
(level) =>
|
(level) =>
|
||||||
level.askPrice > 0 ||
|
level.askPrice > 0 ||
|
||||||
@@ -45,7 +47,9 @@ function hasOrderBookLevelData(levels: DashboardStockOrderBookResponse["levels"]
|
|||||||
* @description H0STOAA0 미수신 상황에서 체결(H0UNCNT0) 데이터로 최소 1호가를 구성합니다.
|
* @description H0STOAA0 미수신 상황에서 체결(H0UNCNT0) 데이터로 최소 1호가를 구성합니다.
|
||||||
* @see features/trade/utils/kisRealtimeUtils.ts parseKisRealtimeTickBatch 체결 데이터에서 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) return [] as DashboardStockOrderBookResponse["levels"];
|
||||||
if (latestTick.askPrice1 <= 0 && latestTick.bidPrice1 <= 0) {
|
if (latestTick.askPrice1 <= 0 && latestTick.bidPrice1 <= 0) {
|
||||||
return [] as DashboardStockOrderBookResponse["levels"];
|
return [] as DashboardStockOrderBookResponse["levels"];
|
||||||
@@ -292,6 +296,8 @@ export function OrderBook({
|
|||||||
|
|
||||||
const askMax = Math.max(1, ...askRows.map((r) => r.size));
|
const askMax = Math.max(1, ...askRows.map((r) => r.size));
|
||||||
const bidMax = Math.max(1, ...bidRows.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;
|
const bestAsk = levels.find((l) => l.askPrice > 0)?.askPrice ?? 0;
|
||||||
@@ -332,10 +338,10 @@ export function OrderBook({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<TabsList variant="line" className="w-full justify-start">
|
||||||
<TabsTrigger value="normal" className="px-3">
|
<TabsTrigger value="normal" className="px-3">
|
||||||
일반호가
|
일반호가
|
||||||
@@ -351,63 +357,52 @@ export function OrderBook({
|
|||||||
|
|
||||||
{/* ── 일반호가 탭 ── */}
|
{/* ── 일반호가 탭 ── */}
|
||||||
<TabsContent value="normal" className="min-h-0 flex-1">
|
<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">
|
<div className="flex min-h-0 flex-col xl:border-r dark:border-brand-800/45">
|
||||||
{isTickFallbackActive && (
|
{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">
|
<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`)
|
시간외 전용 호가(`H0STOAA0`) 미수신 상태입니다.
|
||||||
1호가 기준으로 표시 중입니다.
|
체결(`H0UNCNT0`) 1호가 기준으로 표시 중입니다.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<BookHeader />
|
<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} />
|
<BookSideRows rows={askRows} side="ask" maxSize={askMax} />
|
||||||
|
<CurrentPriceBar
|
||||||
{/* 중앙 바: 현재 체결가 */}
|
latestPrice={latestPrice}
|
||||||
<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">
|
basePrice={basePrice}
|
||||||
<div className="px-2 text-right text-[10px] font-medium text-muted-foreground dark:text-brand-100/72">
|
bestAsk={bestAsk}
|
||||||
{totalAsk > 0 ? fmt(totalAsk) : ""}
|
totalAsk={totalAsk}
|
||||||
</div>
|
totalBid={totalBid}
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 매수호가 */}
|
|
||||||
<BookSideRows rows={bidRows} side="bid" maxSize={bidMax} />
|
<BookSideRows rows={bidRows} side="bid" maxSize={bidMax} />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</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">
|
<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">
|
||||||
<TradeTape ticks={recentTicks} />
|
<div className="h-full min-h-0">
|
||||||
|
<TradeTape ticks={recentTicks} maxRows={10} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 우측 요약 패널 */}
|
{/* 실시간 정보 영역 */}
|
||||||
<div className="hidden xl:block min-h-0">
|
<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
|
<SummaryPanel
|
||||||
orderBook={orderBook}
|
orderBook={orderBook}
|
||||||
latestTick={latestTick}
|
latestTick={latestTick}
|
||||||
@@ -418,6 +413,7 @@ export function OrderBook({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</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() {
|
function BookHeader() {
|
||||||
return (
|
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="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">매도잔량</div>
|
<div className="flex items-center justify-end px-2 text-red-600/80 dark:text-red-400/80">
|
||||||
<div className="flex items-center justify-center border-x">호가</div>
|
매도잔량
|
||||||
<div className="flex items-center justify-start px-2">매수잔량</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -474,8 +532,8 @@ function BookSideRows({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
isAsk
|
isAsk
|
||||||
? "bg-red-50/20 dark:bg-red-950/18"
|
? "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-blue-50/55 dark:bg-blue-950/22",
|
: "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) => {
|
{rows.map((row, i) => {
|
||||||
@@ -486,9 +544,9 @@ function BookSideRows({
|
|||||||
<div
|
<div
|
||||||
key={`${side}-${row.price}-${i}`}
|
key={`${side}-${row.price}-${i}`}
|
||||||
className={cn(
|
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 &&
|
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
|
<span
|
||||||
className={
|
className={cn(
|
||||||
isAsk ? "text-red-600" : "text-blue-600 dark:text-blue-400"
|
"text-[12px] xl:text-[13px]",
|
||||||
}
|
isAsk ? "text-red-600" : "text-blue-600 dark:text-blue-400",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{row.price > 0 ? fmt(row.price) : "-"}
|
{row.price > 0 ? fmt(row.price) : "-"}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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),
|
getChangeToneClass(row.changeValue),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{row.changeValue === null ? "-" : fmtSignedChange(row.changeValue)}
|
{row.changeValue === null
|
||||||
|
? "-"
|
||||||
|
: fmtSignedChange(row.changeValue)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -582,71 +643,80 @@ function SummaryPanel({
|
|||||||
latestTick?.isExpected && (orderBook?.anticipatedVolume ?? 0) > 0
|
latestTick?.isExpected && (orderBook?.anticipatedVolume ?? 0) > 0
|
||||||
? (orderBook?.anticipatedVolume ?? 0)
|
? (orderBook?.anticipatedVolume ?? 0)
|
||||||
: (latestTick?.tradeVolume ?? orderBook?.anticipatedVolume ?? 0);
|
: (latestTick?.tradeVolume ?? orderBook?.anticipatedVolume ?? 0);
|
||||||
|
const summaryItems: SummaryMetric[] = [
|
||||||
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">
|
label: "실시간",
|
||||||
<Row
|
value: orderBook || latestTick ? "연결됨" : "끊김",
|
||||||
label="실시간"
|
tone: orderBook || latestTick ? "bid" : undefined,
|
||||||
value={orderBook || latestTick ? "연결됨" : "끊김"}
|
},
|
||||||
tone={orderBook || latestTick ? "bid" : undefined}
|
{ label: "거래량", value: fmt(displayTradeVolume) },
|
||||||
/>
|
{
|
||||||
<Row
|
label: "누적거래량",
|
||||||
label="거래량"
|
value: fmt(
|
||||||
value={fmt(displayTradeVolume)}
|
|
||||||
/>
|
|
||||||
<Row
|
|
||||||
label="누적거래량"
|
|
||||||
value={fmt(
|
|
||||||
latestTick?.accumulatedVolume ?? orderBook?.accumulatedVolume ?? 0,
|
latestTick?.accumulatedVolume ?? orderBook?.accumulatedVolume ?? 0,
|
||||||
)}
|
),
|
||||||
/>
|
},
|
||||||
<Row
|
{
|
||||||
label="체결강도"
|
label: "체결강도",
|
||||||
value={
|
value: latestTick
|
||||||
latestTick
|
|
||||||
? `${latestTick.tradeStrength.toFixed(2)}%`
|
? `${latestTick.tradeStrength.toFixed(2)}%`
|
||||||
: orderBook?.anticipatedChangeRate !== undefined
|
: orderBook?.anticipatedChangeRate !== undefined
|
||||||
? `${orderBook.anticipatedChangeRate.toFixed(2)}%`
|
? `${orderBook.anticipatedChangeRate.toFixed(2)}%`
|
||||||
: "-"
|
: "-",
|
||||||
}
|
},
|
||||||
/>
|
{ label: "예상체결가", value: fmt(orderBook?.anticipatedPrice ?? 0) },
|
||||||
<Row label="예상체결가" value={fmt(orderBook?.anticipatedPrice ?? 0)} />
|
{
|
||||||
<Row
|
label: "매도1호가",
|
||||||
label="매도1호가"
|
value: latestTick ? fmt(latestTick.askPrice1) : "-",
|
||||||
value={latestTick ? fmt(latestTick.askPrice1) : "-"}
|
tone: "ask",
|
||||||
tone="ask"
|
},
|
||||||
/>
|
{
|
||||||
<Row
|
label: "매수1호가",
|
||||||
label="매수1호가"
|
value: latestTick ? fmt(latestTick.bidPrice1) : "-",
|
||||||
value={latestTick ? fmt(latestTick.bidPrice1) : "-"}
|
tone: "bid",
|
||||||
tone="bid"
|
},
|
||||||
/>
|
{
|
||||||
<Row
|
label: "순매수체결",
|
||||||
label="매수체결"
|
value: latestTick ? fmt(latestTick.netBuyExecutionCount) : "-",
|
||||||
value={latestTick ? fmt(latestTick.buyExecutionCount) : "-"}
|
},
|
||||||
/>
|
{ label: "총 매도잔량", value: fmt(totalAsk), tone: "ask" },
|
||||||
<Row
|
{ label: "총 매수잔량", value: fmt(totalBid), tone: "bid" },
|
||||||
label="매도체결"
|
{ label: "스프레드", value: fmt(spread) },
|
||||||
value={latestTick ? fmt(latestTick.sellExecutionCount) : "-"}
|
{
|
||||||
/>
|
label: "수급 불균형",
|
||||||
<Row
|
value: `${imbalance >= 0 ? "+" : ""}${imbalance.toFixed(2)}%`,
|
||||||
label="순매수체결"
|
tone: imbalance >= 0 ? "bid" : "ask",
|
||||||
value={latestTick ? fmt(latestTick.netBuyExecutionCount) : "-"}
|
},
|
||||||
/>
|
];
|
||||||
<Row label="총 매도잔량" value={fmt(totalAsk)} tone="ask" />
|
|
||||||
<Row label="총 매수잔량" value={fmt(totalBid)} tone="bid" />
|
return (
|
||||||
<Row label="스프레드" value={fmt(spread)} />
|
<div className="h-full min-w-0 bg-muted/10 p-2 text-xs dark:bg-brand-900/30">
|
||||||
<Row
|
<div className="grid grid-cols-2 gap-1 xl:h-full xl:grid-cols-1 xl:grid-rows-12">
|
||||||
label="수급 불균형"
|
{summaryItems.map((item) => (
|
||||||
value={`${imbalance >= 0 ? "+" : ""}${imbalance.toFixed(2)}%`}
|
<SummaryMetricCell
|
||||||
tone={imbalance >= 0 ? "bid" : "ask"}
|
key={item.label}
|
||||||
|
label={item.label}
|
||||||
|
value={item.value}
|
||||||
|
tone={item.tone}
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 요약 패널 단일 행 */
|
interface SummaryMetric {
|
||||||
function Row({
|
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,
|
label,
|
||||||
value,
|
value,
|
||||||
tone,
|
tone,
|
||||||
@@ -656,13 +726,13 @@ function Row({
|
|||||||
tone?: "ask" | "bid";
|
tone?: "ask" | "bid";
|
||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<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="min-w-0 truncate text-muted-foreground dark:text-brand-100/70">
|
<span className="truncate text-[11px] text-muted-foreground dark:text-brand-100/70">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 font-medium tabular-nums",
|
"shrink-0 text-xs font-semibold tabular-nums",
|
||||||
tone === "ask" && "text-red-600",
|
tone === "ask" && "text-red-600",
|
||||||
tone === "bid" && "text-blue-600 dark:text-blue-400",
|
tone === "bid" && "text-blue-600 dark:text-blue-400",
|
||||||
)}
|
)}
|
||||||
@@ -679,10 +749,10 @@ function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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"
|
side === "ask"
|
||||||
? "right-1 bg-red-200/50 dark:bg-red-800/40"
|
? "right-0.5 bg-red-300/55 dark:bg-red-700/50"
|
||||||
: "left-1 bg-blue-200/55 dark:bg-blue-500/35",
|
: "left-0.5 bg-blue-300/60 dark:bg-blue-600/45",
|
||||||
)}
|
)}
|
||||||
style={{ width: `${ratio}%` }}
|
style={{ width: `${ratio}%` }}
|
||||||
/>
|
/>
|
||||||
@@ -690,24 +760,25 @@ function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 체결 목록 (Trade Tape) */
|
/** 체결 목록 (Trade Tape) */
|
||||||
function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
|
function TradeTape({
|
||||||
return (
|
ticks,
|
||||||
<div className="flex h-full min-h-0 flex-col bg-background dark:bg-brand-900/20">
|
maxRows,
|
||||||
<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 items-center">체결시각</div>
|
ticks: DashboardRealtimeTradeTick[];
|
||||||
<div className="flex items-center justify-end">체결가</div>
|
maxRows?: number;
|
||||||
<div className="flex items-center justify-end">체결량</div>
|
}) {
|
||||||
<div className="flex items-center justify-end">체결강도</div>
|
const visibleTicks = typeof maxRows === "number" ? ticks.slice(0, maxRows) : ticks;
|
||||||
</div>
|
const shouldUseScrollableList = typeof maxRows !== "number";
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
|
||||||
|
const tapeRows = (
|
||||||
<div>
|
<div>
|
||||||
{ticks.length === 0 && (
|
{visibleTicks.length === 0 && (
|
||||||
<div className="flex min-h-[160px] items-center justify-center text-xs text-muted-foreground dark:text-brand-100/70">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{ticks.map((t, i) => {
|
{visibleTicks.map((t, i) => {
|
||||||
const olderTick = ticks[i + 1];
|
const olderTick = visibleTicks[i + 1];
|
||||||
const executionSide = resolveTickExecutionSide(t, olderTick);
|
const executionSide = resolveTickExecutionSide(t, olderTick);
|
||||||
// UI 흐름: 체결목록 UI -> resolveTickExecutionSide(현재/이전 틱) -> 색상 class 결정 -> 체결량 렌더 반영
|
// UI 흐름: 체결목록 UI -> resolveTickExecutionSide(현재/이전 틱) -> 색상 class 결정 -> 체결량 렌더 반영
|
||||||
const volumeToneClass =
|
const volumeToneClass =
|
||||||
@@ -720,7 +791,7 @@ function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${t.tickTime}-${t.price}-${i}`}
|
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"
|
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">
|
<div className="flex items-center tabular-nums">
|
||||||
{fmtTime(t.tickTime)}
|
{fmtTime(t.tickTime)}
|
||||||
@@ -728,7 +799,10 @@ function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-end tabular-nums",
|
"flex items-center justify-end tabular-nums",
|
||||||
getChangeToneClass(t.change, "text-foreground dark:text-brand-50"),
|
getChangeToneClass(
|
||||||
|
t.change,
|
||||||
|
"text-foreground dark:text-brand-50",
|
||||||
|
),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{fmt(t.price)}
|
{fmt(t.price)}
|
||||||
@@ -741,14 +815,24 @@ function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
|
|||||||
>
|
>
|
||||||
{fmt(t.tradeVolume)}
|
{fmt(t.tradeVolume)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end tabular-nums">
|
|
||||||
{t.tradeStrength.toFixed(2)}%
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
{shouldUseScrollableList ? (
|
||||||
|
<ScrollArea className="min-h-0 flex-1">{tapeRows}</ScrollArea>
|
||||||
|
) : (
|
||||||
|
tapeRows
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function StockSearchForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={onSubmit} className="flex gap-2">
|
<form onSubmit={onSubmit} className="flex items-center gap-2">
|
||||||
{/* ========== SEARCH INPUT ========== */}
|
{/* ========== SEARCH INPUT ========== */}
|
||||||
<div className="relative flex-1">
|
<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" />
|
<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}
|
value={keyword}
|
||||||
onChange={(e) => onKeywordChange(e.target.value)}
|
onChange={(e) => onKeywordChange(e.target.value)}
|
||||||
onFocus={onInputFocus}
|
onFocus={onInputFocus}
|
||||||
placeholder="종목명 또는 종목코드(6자리)를 입력하세요."
|
placeholder="종목명 또는 코드 검색"
|
||||||
autoComplete="off"
|
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 && (
|
{keyword && (
|
||||||
<button
|
<button
|
||||||
@@ -57,7 +57,11 @@ export function StockSearchForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ========== SUBMIT BUTTON ========== */}
|
{/* ========== 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 ? "검색 중..." : "검색"}
|
{isLoading ? "검색 중..." : "검색"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -3,16 +3,22 @@ import { StockSearchForm } from "@/features/trade/components/search/StockSearchF
|
|||||||
import { StockSearchHistory } from "@/features/trade/components/search/StockSearchHistory";
|
import { StockSearchHistory } from "@/features/trade/components/search/StockSearchHistory";
|
||||||
import { StockSearchResults } from "@/features/trade/components/search/StockSearchResults";
|
import { StockSearchResults } from "@/features/trade/components/search/StockSearchResults";
|
||||||
import type {
|
import type {
|
||||||
|
DashboardStockItem,
|
||||||
DashboardStockSearchHistoryItem,
|
DashboardStockSearchHistoryItem,
|
||||||
DashboardStockSearchItem,
|
DashboardStockSearchItem,
|
||||||
} from "@/features/trade/types/trade.types";
|
} from "@/features/trade/types/trade.types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface TradeSearchSectionProps {
|
interface TradeSearchSectionProps {
|
||||||
canSearch: boolean;
|
canSearch: boolean;
|
||||||
isSearchPanelOpen: boolean;
|
isSearchPanelOpen: boolean;
|
||||||
isSearching: boolean;
|
isSearching: boolean;
|
||||||
keyword: string;
|
keyword: string;
|
||||||
|
selectedStock: DashboardStockItem | null;
|
||||||
selectedSymbol?: string;
|
selectedSymbol?: string;
|
||||||
|
currentPrice?: number;
|
||||||
|
change?: number;
|
||||||
|
changeRate?: number;
|
||||||
searchResults: DashboardStockSearchItem[];
|
searchResults: DashboardStockSearchItem[];
|
||||||
searchHistory: DashboardStockSearchHistoryItem[];
|
searchHistory: DashboardStockSearchHistoryItem[];
|
||||||
searchShellRef: MutableRefObject<HTMLDivElement | null>;
|
searchShellRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
@@ -27,16 +33,20 @@ interface TradeSearchSectionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 트레이드 화면 상단의 검색 입력/결과/히스토리 드롭다운 영역을 렌더링합니다.
|
* @description 트레이드 화면 상단의 검색 입력/결과/종목 요약 통합 영역을 렌더링합니다.
|
||||||
* @see features/trade/components/TradeContainer.tsx TradeContainer에서 검색 섹션을 분리해 렌더 복잡도를 줄입니다.
|
* @summary UI 흐름: TradeContainer -> TradeSearchSection -> (검색 입력/선택) + (선택 종목 실시간 요약) 반영
|
||||||
* @see features/trade/hooks/useTradeSearchPanel.ts 패널 열림/닫힘 및 포커스 핸들러를 전달받습니다.
|
* @see features/trade/components/TradeContainer.tsx - 검색 상태/선택 종목 실시간 데이터를 전달합니다.
|
||||||
*/
|
*/
|
||||||
export function TradeSearchSection({
|
export function TradeSearchSection({
|
||||||
canSearch,
|
canSearch,
|
||||||
isSearchPanelOpen,
|
isSearchPanelOpen,
|
||||||
isSearching,
|
isSearching,
|
||||||
keyword,
|
keyword,
|
||||||
|
selectedStock,
|
||||||
selectedSymbol,
|
selectedSymbol,
|
||||||
|
currentPrice,
|
||||||
|
change,
|
||||||
|
changeRate,
|
||||||
searchResults,
|
searchResults,
|
||||||
searchHistory,
|
searchHistory,
|
||||||
searchShellRef,
|
searchShellRef,
|
||||||
@@ -50,13 +60,15 @@ export function TradeSearchSection({
|
|||||||
onClearHistory,
|
onClearHistory,
|
||||||
}: TradeSearchSectionProps) {
|
}: TradeSearchSectionProps) {
|
||||||
return (
|
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">
|
<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 ========== */}
|
{/* ========== SEARCH SHELL ========== */}
|
||||||
<div
|
<div
|
||||||
ref={searchShellRef}
|
ref={searchShellRef}
|
||||||
onBlurCapture={onSearchShellBlur}
|
onBlurCapture={onSearchShellBlur}
|
||||||
onKeyDownCapture={onSearchShellKeyDown}
|
onKeyDownCapture={onSearchShellKeyDown}
|
||||||
className="relative mx-auto max-w-2xl"
|
className="relative min-w-0 flex-1 md:max-w-[480px]"
|
||||||
>
|
>
|
||||||
<StockSearchForm
|
<StockSearchForm
|
||||||
keyword={keyword}
|
keyword={keyword}
|
||||||
@@ -96,6 +108,128 @@ export function TradeSearchSection({
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ export function useOrderbookSubscription({
|
|||||||
marketSession,
|
marketSession,
|
||||||
onOrderBookMessage,
|
onOrderBookMessage,
|
||||||
}: UseOrderbookSubscriptionParams) {
|
}: UseOrderbookSubscriptionParams) {
|
||||||
const { subscribe, connect } = useKisWebSocketStore();
|
const subscribeRef = useRef(useKisWebSocketStore.getState().subscribe);
|
||||||
|
const connectRef = useRef(useKisWebSocketStore.getState().connect);
|
||||||
const onOrderBookMessageRef = useRef(onOrderBookMessage);
|
const onOrderBookMessageRef = useRef(onOrderBookMessage);
|
||||||
const activeOrderBookTrIdRef = useRef<string | null>(null);
|
const activeOrderBookTrIdRef = useRef<string | null>(null);
|
||||||
const activeOrderBookTrUpdatedAtRef = useRef(0);
|
const activeOrderBookTrUpdatedAtRef = useRef(0);
|
||||||
@@ -47,7 +48,7 @@ export function useOrderbookSubscription({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!symbol || !isVerified || !credentials) return;
|
if (!symbol || !isVerified || !credentials) return;
|
||||||
|
|
||||||
connect();
|
connectRef.current();
|
||||||
|
|
||||||
const trIds = resolveOrderBookTrIds(
|
const trIds = resolveOrderBookTrIds(
|
||||||
credentials.tradingEnv,
|
credentials.tradingEnv,
|
||||||
@@ -83,7 +84,9 @@ export function useOrderbookSubscription({
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const trId of trIds) {
|
for (const trId of trIds) {
|
||||||
unsubscribers.push(subscribe(trId, symbol, handleOrderBookMessage));
|
unsubscribers.push(
|
||||||
|
subscribeRef.current(trId, symbol, handleOrderBookMessage),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -91,5 +94,5 @@ export function useOrderbookSubscription({
|
|||||||
activeOrderBookTrIdRef.current = null;
|
activeOrderBookTrIdRef.current = null;
|
||||||
activeOrderBookTrUpdatedAtRef.current = 0;
|
activeOrderBookTrUpdatedAtRef.current = 0;
|
||||||
};
|
};
|
||||||
}, [symbol, market, isVerified, credentials, marketSession, connect, subscribe]);
|
}, [symbol, market, isVerified, credentials, marketSession]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ export function useTradeTickSubscription({
|
|||||||
const activeTradeTrIdRef = useRef<string | null>(null);
|
const activeTradeTrIdRef = useRef<string | null>(null);
|
||||||
const activeTradeTrUpdatedAtRef = useRef(0);
|
const activeTradeTrUpdatedAtRef = useRef(0);
|
||||||
|
|
||||||
const { subscribe, connect } = useKisWebSocketStore();
|
const subscribeRef = useRef(useKisWebSocketStore.getState().subscribe);
|
||||||
|
const connectRef = useRef(useKisWebSocketStore.getState().connect);
|
||||||
const onTickRef = useRef(onTick);
|
const onTickRef = useRef(onTick);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -73,7 +74,7 @@ export function useTradeTickSubscription({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!symbol || !isVerified || !credentials) return;
|
if (!symbol || !isVerified || !credentials) return;
|
||||||
|
|
||||||
connect();
|
connectRef.current();
|
||||||
|
|
||||||
const trIds = resolveTradeTrIds(credentials.tradingEnv, marketSession);
|
const trIds = resolveTradeTrIds(credentials.tradingEnv, marketSession);
|
||||||
const unsubscribers: Array<() => void> = [];
|
const unsubscribers: Array<() => void> = [];
|
||||||
@@ -148,13 +149,15 @@ export function useTradeTickSubscription({
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const trId of trIds) {
|
for (const trId of trIds) {
|
||||||
unsubscribers.push(subscribe(trId, symbol, handleTradeMessage));
|
unsubscribers.push(
|
||||||
|
subscribeRef.current(trId, symbol, handleTradeMessage),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribers.forEach((unsub) => unsub());
|
unsubscribers.forEach((unsub) => unsub());
|
||||||
};
|
};
|
||||||
}, [symbol, isVerified, credentials, marketSession, connect, subscribe]);
|
}, [symbol, isVerified, credentials, marketSession]);
|
||||||
|
|
||||||
return { latestTick, recentTradeTicks, lastTickAt };
|
return { latestTick, recentTradeTicks, lastTickAt };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user