사이드바 6 패널 중 마지막 남은 비-collapsible 패널이던 OrderbookPanel 을 공용 Collapsible 로 통일. 이제 SymbolSidebar/PriceTargets 제외 모든 사이드 패널이 같은 토글/영속 패턴. sticky 충돌 해소: - 기존 sticky 스타일은 OrderbookPanel 자체 outer div 에 직접 박혀 있었음. Collapsible 로 감싸면 outer 가 바뀌므로 Collapsible 의 className prop 으로 sticky 톤(bg-zinc-900/80 backdrop-blur)을 sticky=true 일 때만 주입. - 접혔을 때: 헤더 한 줄(호가 + ts) 만 sticky top-4 로 상단에 떠 있음 → 차트/지표 영역 길게 스크롤 중에도 호가 헤더 접근 가능, 한 번 클릭이면 본문 펴짐. 펼쳤을 때: 본문 포함 전체 sticky. - 내부 p-3 패딩은 제거 — Collapsible 의 px-4/pb-4/pt-3 본문 패딩이 카드 안쪽 여백을 대체. defaultOpen=true: - 호가는 사이드바에서 정보 밀도/시의성 모두 가장 높은 패널 → 진입 즉시 펴진 상태가 자연스러움. RelatedStocks(밀도 낮음) 만 false. storageKey="orderbook": panel-open:orderbook 으로 영속. 검증: - npx tsc --noEmit clean - npx next lint "✔ No ESLint warnings or errors"
201 lines
7.5 KiB
TypeScript
201 lines
7.5 KiB
TypeScript
"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<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]);
|
|
|
|
// 호가는 사이드바에서 정보 밀도/시의성 모두 가장 높음 → 기본 펼침 유지.
|
|
// 사용자가 접고 싶다면 헤더 클릭 한 번 + localStorage 영속.
|
|
return (
|
|
<Collapsible
|
|
title="호가"
|
|
subtitle={data?.ts ? `${data.ts} · 10초 갱신` : "10초 갱신"}
|
|
storageKey="orderbook"
|
|
className={
|
|
sticky
|
|
? "sticky top-4 rounded-md border border-zinc-800 bg-zinc-900/80 backdrop-blur"
|
|
: "rounded-md border border-zinc-800 bg-zinc-900/40"
|
|
}
|
|
>
|
|
{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} />}
|
|
</Collapsible>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|