feat(orderbook): 10단계 호가 패널 (KIS asking-price 스냅샷)

backend:
- fetch/kis.py: fetch_orderbook(code) — KIS FHKST01010200 (inquire-asking-price-exp-ccn)
  호출. output1 의 askp1~10/bidp1~10 + 잔량 + 합계, output2 의 현재가/전일대비 파싱.
- api/orderbook.py: GET /api/orderbook/{code}. symbols 매칭 안 되면 404, KIS 키 없으면
  skipped_missing_key 상태로 빈 호가 반환 (502 와 구분).
- main.py: orderbook_router 등록.

frontend:
- lib/api.ts: OrderbookResponse 타입 + api.orderbook(code).
- components/OrderbookPanel.tsx: 매도 10단계(위→아래 가격 내림차순) / 현재가(굵게, 전일대비)
  / 매수 10단계. 잔량 막대는 사이드별 max 정규화로 셀 안에서 자라남. ask=파랑, bid=빨강
  (한국 거래소 관행). 10초 폴링.
- app/[code]/page.tsx: 사이드바에 SymbolSidebar 다음으로 OrderbookPanel 마운트.

실시간 ws 가 아니라 HTTP 폴링이라 KIS rate-limit 친화적. 표시 전용 — 주문/체결 endpoint 는
일절 import 하지 않음 (kis.py 문서 정책).
This commit is contained in:
claude-owner
2026-05-28 19:13:35 +09:00
parent 1a6d953d17
commit fed8ff287e
6 changed files with 362 additions and 0 deletions

View File

@@ -0,0 +1,186 @@
"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<OrderbookResponse | null>(null);
const [err, setErr] = useState<string | null>(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 (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-xs font-medium text-zinc-300"></span>
<span className="text-[10px] text-zinc-500">
{data?.ts ? `${data.ts} · 10초 갱신` : "10초 갱신"}
</span>
</div>
{loading && !data && (
<div className="py-6 text-center text-[11px] text-zinc-500"> </div>
)}
{err && !data && (
<div className="rounded border border-rose-800/40 bg-rose-900/10 p-2 text-[11px] text-rose-300">
: {err}
</div>
)}
{data?.status === "skipped_missing_key" && (
<div className="rounded border border-amber-800/40 bg-amber-900/10 p-2 text-[11px] text-amber-200">
KIS API .
</div>
)}
{data && data.status === "ok" && <OrderbookTable data={data} />}
</div>
);
}
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 (
<div className="space-y-1">
<ul className="space-y-0.5">
{asksDesc.map((lv) => (
<Row key={`a${lv.level}`} lv={lv} max={askMax} side="ask" />
))}
</ul>
<div className="my-1 flex items-center justify-between rounded bg-zinc-800/60 px-2 py-1.5">
<span className="text-[10px] text-zinc-400"></span>
<div className="flex items-baseline gap-2">
<span className="text-base font-semibold tabular-nums text-zinc-50">
{data.current != null ? data.current.toLocaleString() : "—"}
</span>
{data.prev_close_diff != null && (
<span className={`text-[11px] tabular-nums ${tone}`}>
{change > 0 ? "+" : ""}
{change.toLocaleString()}
{data.prev_close_pct != null && (
<span className="ml-0.5">
({data.prev_close_pct > 0 ? "+" : ""}
{data.prev_close_pct.toFixed(2)}%)
</span>
)}
</span>
)}
</div>
</div>
<ul className="space-y-0.5">
{bids.map((lv) => (
<Row key={`b${lv.level}`} lv={lv} max={bidMax} side="bid" />
))}
</ul>
<div className="mt-1.5 flex items-center justify-between border-t border-zinc-800 pt-1.5 text-[10px] text-zinc-500">
<span> {data.ask_total.toLocaleString()}</span>
<span> {data.bid_total.toLocaleString()}</span>
</div>
</div>
);
}
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 = (
<span className={`tabular-nums ${priceColor}`}>{lv.price.toLocaleString()}</span>
);
const qtyCell = (
<span className="relative inline-block w-full overflow-hidden rounded-sm py-0.5">
<span
className={`absolute inset-y-0 ${side === "ask" ? "right-0" : "left-0"} ${barColor}`}
style={{ width: `${pct}%` }}
aria-hidden
/>
<span
className={`relative z-10 block tabular-nums text-zinc-300 ${
side === "ask" ? "text-right" : "text-left"
} px-1.5`}
>
{lv.qty.toLocaleString()}
</span>
</span>
);
return (
<li className="grid grid-cols-[12px_64px_1fr] items-center gap-1.5 text-[11px]">
<span className="text-[9px] text-zinc-600">{lv.level}</span>
<span className="text-right">{priceCell}</span>
{qtyCell}
</li>
);
}