"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"; type Props = { code: string; }; const POLL_MS = 10_000; export function OrderbookPanel({ code }: 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]); return (
호가 {data?.ts ? `${data.ts} · 10초 갱신` : "10초 갱신"}
{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}
  • ); }