"use client"; // 호가창 — 10단계 매도/매수 호가 + 잔량. Toss 톤의 한 줄 한 줄 막대 그래프. // /api/orderbook/{code} 폴링 (10s). 키 없으면 안내 카드. // // 표시 규칙: // - 위쪽: 매도호가 10개 (가장 낮은 askp1 이 맨 아래, 즉 현재가에 인접) // → 그래서 화면상 askp10 → askp1 (위에서 아래로 가격 내림차순) 으로 렌더 // - 가운데 굵게: 현재가 + 전일대비 + % // - 아래쪽: 매수호가 10개 (가장 높은 bidp1 이 맨 위, bidp10 이 맨 아래) // - 잔량 막대: 매도/매수 각각의 단계별 최대잔량 기준 비율로 색상 막대. // - 색상: 매도=파랑(하락 톤), 매수=빨강(상승 톤) — 한국 거래소 관행. import { useEffect, useState } from "react"; import { api, type OrderbookLevel, type OrderbookResponse } from "../lib/api"; import { Collapsible } from "./Collapsible"; type Props = { code: string; // 1D(10분봉) 모드일 때만 sticky 로 띄움 — 종목 페이지가 길어 스크롤해도 매수/매도 호가가 // 시야에 남도록. 일봉 이상에선 호가의 실시간 가치가 낮아 일반 흐름으로 두는 게 사이드바 // 다른 패널(PriceTargets/AlertsPanel/InvestmentNote/RelatedStocks) 의 위치 안정성을 해치지 // 않음. // // sticky + collapsible 조합: Collapsible 의 outer 래퍼 자체에 sticky 를 걸어두면 접혔을 // 때는 헤더 한 줄(호가 + ts) 만 상단에 떠 있고, 펼치면 본문 포함 전체가 시야에 남는다. // — 사용자가 차트/지표 영역을 길게 스크롤하다 호가 보고 싶을 때 늘 접근 가능. sticky?: boolean; }; const POLL_MS = 10_000; export function OrderbookPanel({ code, sticky = false }: Props) { const [data, setData] = useState(null); const [err, setErr] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { let alive = true; const load = () => { api .orderbook(code) .then((r) => { if (!alive) return; setData(r); setErr(null); setLoading(false); }) .catch((e) => { if (!alive) return; setErr(e instanceof Error ? e.message : String(e)); setLoading(false); }); }; load(); const h = window.setInterval(load, POLL_MS); return () => { alive = false; window.clearInterval(h); }; }, [code]); // 호가는 사이드바에서 정보 밀도/시의성 모두 가장 높음 → 기본 펼침 유지. // 사용자가 접고 싶다면 헤더 클릭 한 번 + localStorage 영속. return ( {loading && !data && (
불러오는 중…
)} {err && !data && (
호가 로딩 실패: {err}
)} {data?.status === "skipped_missing_key" && (
KIS API 키가 설정되지 않아 호가를 표시할 수 없습니다.
)} {data && data.status === "ok" && }
); } function OrderbookTable({ data }: { data: OrderbookResponse }) { // KIS 가 항상 10단계를 다 주진 않을 수 있어 정렬 강제. // 매도: level 오름차순 (1=가장 낮은 매도) → 화면은 level 내림차순으로 표시. // 매수: level 오름차순 (1=가장 높은 매수) → 화면은 level 오름차순. const asks = [...data.asks].sort((a, b) => a.level - b.level); const bids = [...data.bids].sort((a, b) => a.level - b.level); const asksDesc = [...asks].reverse(); // 잔량 막대 정규화 — 매도/매수 통틀어 max 로 정규화하면 한쪽이 항상 미니 막대로 나옴. // 사이드별 max 로 각자 정규화. const askMax = Math.max(1, ...asks.map((x) => x.qty)); const bidMax = Math.max(1, ...bids.map((x) => x.qty)); const change = data.prev_close_diff ?? 0; const tone = change > 0 ? "text-rose-400" : change < 0 ? "text-sky-400" : "text-zinc-400"; return (
    {asksDesc.map((lv) => ( ))}
현재가
{data.current != null ? data.current.toLocaleString() : "—"} {data.prev_close_diff != null && ( {change > 0 ? "+" : ""} {change.toLocaleString()} {data.prev_close_pct != null && ( ({data.prev_close_pct > 0 ? "+" : ""} {data.prev_close_pct.toFixed(2)}%) )} )}
    {bids.map((lv) => ( ))}
매도 합 {data.ask_total.toLocaleString()} 매수 합 {data.bid_total.toLocaleString()}
); } function Row({ lv, max, side, }: { lv: OrderbookLevel; max: number; side: "ask" | "bid"; }) { const pct = Math.min(100, (lv.qty / max) * 100); // ask=파랑(하락 톤), bid=빨강(상승 톤). 잔량 막대는 잔량 셀 안에서 자라남: // - ask 행: 가격(좌) · 잔량(우) — 막대는 잔량 셀 오른쪽 → 왼쪽 (수치쪽으로) 자라남 // - bid 행: 잔량(좌) · 가격(우) — 막대는 잔량 셀 왼쪽 → 오른쪽 자라남 const barColor = side === "ask" ? "bg-sky-500/20" : "bg-rose-500/20"; const priceColor = side === "ask" ? "text-sky-300" : "text-rose-300"; const priceCell = ( {lv.price.toLocaleString()} ); const qtyCell = ( {lv.qty.toLocaleString()} ); return (
  • {lv.level} {priceCell} {qtyCell}
  • ); }