From fed8ff287eed25d98e7fa66000b505f2d9bf83db Mon Sep 17 00:00:00 2001 From: claude-owner Date: Thu, 28 May 2026 19:13:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(orderbook):=2010=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=ED=98=B8=EA=B0=80=20=ED=8C=A8=EB=84=90=20(KIS=20asking-price?= =?UTF-8?q?=20=EC=8A=A4=EB=83=85=EC=83=B7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 문서 정책). --- backend/app/api/orderbook.py | 60 ++++++++++ backend/app/fetch/kis.py | 89 ++++++++++++++ backend/app/main.py | 2 + web/app/[code]/page.tsx | 2 + web/components/OrderbookPanel.tsx | 186 ++++++++++++++++++++++++++++++ web/lib/api.ts | 23 ++++ 6 files changed, 362 insertions(+) create mode 100644 backend/app/api/orderbook.py create mode 100644 web/components/OrderbookPanel.tsx diff --git a/backend/app/api/orderbook.py b/backend/app/api/orderbook.py new file mode 100644 index 0000000..cfba4f1 --- /dev/null +++ b/backend/app/api/orderbook.py @@ -0,0 +1,60 @@ +"""호가(orderbook) API — KIS 10단계 호가 스냅샷. + +장중에 호출하면 호가 + 잔량 + 현재가가 한 번의 KIS 호출로 따라온다. 실시간 ws 가 +아니라 폴링 친화 (프론트가 5~10s 주기로 재호출) 설계 — 표시 전용. +""" +from __future__ import annotations + +from fastapi import APIRouter, HTTPException +from sqlalchemy import text + +from app.db.connection import get_engine +from app.fetch.kis import SkippedMissingKey, fetch_orderbook + +router = APIRouter(prefix="/api/orderbook", tags=["orderbook"]) + + +@router.get("/{code}") +def get_orderbook(code: str) -> dict: + """code 의 10단계 호가 스냅샷. + + - symbols 에 없는 코드는 404. + - KIS 키 미설정이면 status="skipped_missing_key" + 빈 호가 (프론트에서 안내). + - KIS 호출 실패는 502. + """ + eng = get_engine() + with eng.connect() as conn: + sym = conn.execute( + text("SELECT code, name, market FROM symbols WHERE code = :c"), + {"c": code}, + ).first() + if not sym: + raise HTTPException(status_code=404, detail=f"unknown code: {code}") + + try: + snap = fetch_orderbook(code) + except SkippedMissingKey: + return { + "code": sym[0], + "name": sym[1], + "market": sym[2], + "status": "skipped_missing_key", + "ts": None, + "current": None, + "prev_close_diff": None, + "prev_close_pct": None, + "asks": [], + "bids": [], + "ask_total": 0, + "bid_total": 0, + } + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=502, detail=f"kis orderbook failed: {exc}") from exc + + return { + "code": sym[0], + "name": sym[1], + "market": sym[2], + "status": "ok", + **snap, + } diff --git a/backend/app/fetch/kis.py b/backend/app/fetch/kis.py index 6a72723..c78648f 100644 --- a/backend/app/fetch/kis.py +++ b/backend/app/fetch/kis.py @@ -357,6 +357,95 @@ def fetch_minute_range( return sorted(accumulated.values(), key=lambda r: r["ts"]) +@retry( + stop=stop_after_attempt(2), + wait=wait_exponential(multiplier=1, min=1, max=4), + retry=retry_if_exception_type((httpx.HTTPError, httpx.TimeoutException)), + reraise=True, +) +def fetch_orderbook(code: str) -> dict[str, Any]: + """10단계 호가 + 잔량 + 현재가 스냅샷 (read-only). + + KIS `inquire-asking-price-exp-ccn` (TR FHKST01010200) 호출. 실시간 ws 가 아니라 + HTTP 요청 시점의 호가 스냅샷이므로 프론트에서 5~10초 폴링이 자연스럽다. + + Returns: + { + "ts": "HH:MM:SS" (KIS 가 준 aspr_acpt_hour), + "current": float | None, # stck_prpr + "prev_close_diff": float | None, + "prev_close_pct": float | None, + "asks": [{level, price, qty}] (level 1..10, 1=가장 낮은 매도호가), + "bids": [{level, price, qty}] (level 1..10, 1=가장 높은 매수호가), + "ask_total": int, + "bid_total": int, + } + """ + if not _has_keys(): + raise SkippedMissingKey("kis app_key/secret missing") + url = f"{KIS_BASE}/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn" + params = { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": code, + } + with httpx.Client(timeout=10.0) as cli: + resp = cli.get(url, headers=_headers("FHKST01010200"), params=params) + resp.raise_for_status() + data = resp.json() + if data.get("rt_cd") != "0": + raise RuntimeError(f"kis error: {data.get('msg1')} (rt_cd={data.get('rt_cd')})") + + o1 = (data.get("output1") or {}) if isinstance(data.get("output1"), dict) else {} + o2 = (data.get("output2") or {}) if isinstance(data.get("output2"), dict) else {} + + def _f(d: dict[str, Any], k: str) -> float | None: + v = d.get(k) + if v in (None, ""): + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + def _i(d: dict[str, Any], k: str) -> int: + v = d.get(k) + if v in (None, ""): + return 0 + try: + return int(float(v)) + except (TypeError, ValueError): + return 0 + + asks = [] + bids = [] + for lv in range(1, 11): + ap = _f(o1, f"askp{lv}") + aq = _i(o1, f"askp_rsqn{lv}") + bp = _f(o1, f"bidp{lv}") + bq = _i(o1, f"bidp_rsqn{lv}") + if ap is not None and ap > 0: + asks.append({"level": lv, "price": ap, "qty": aq}) + if bp is not None and bp > 0: + bids.append({"level": lv, "price": bp, "qty": bq}) + + raw_hour = o1.get("aspr_acpt_hour") or "" + if isinstance(raw_hour, str) and len(raw_hour) == 6: + ts = f"{raw_hour[0:2]}:{raw_hour[2:4]}:{raw_hour[4:6]}" + else: + ts = None + + return { + "ts": ts, + "current": _f(o2, "stck_prpr"), + "prev_close_diff": _f(o2, "prdy_vrss"), + "prev_close_pct": _f(o2, "prdy_ctrt"), + "asks": asks, + "bids": bids, + "ask_total": _i(o1, "total_askp_rsqn"), + "bid_total": _i(o1, "total_bidp_rsqn"), + } + + def ping() -> dict[str, Any]: """토큰 발급만 시도해서 키 유효성 확인.""" if not _has_keys(): diff --git a/backend/app/main.py b/backend/app/main.py index 6d0b5a3..daf4a33 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,6 +12,7 @@ from app.api.chart import router as chart_router from app.api.markets import router as markets_router from app.api.metrics import router as metrics_router from app.api.news import router as news_router +from app.api.orderbook import router as orderbook_router from app.api.predict import router as predict_router from app.api.refresh import router as refresh_router from app.api.symbols import router as symbols_router @@ -106,6 +107,7 @@ app.include_router(metrics_router) app.include_router(news_router) app.include_router(markets_router) app.include_router(themes_router) +app.include_router(orderbook_router) def _resolved_device() -> str: diff --git a/web/app/[code]/page.tsx b/web/app/[code]/page.tsx index 37b2e07..6fd0694 100644 --- a/web/app/[code]/page.tsx +++ b/web/app/[code]/page.tsx @@ -8,6 +8,7 @@ import { DisclosuresPanel } from "../../components/DisclosuresPanel"; import { MetricsPanel } from "../../components/MetricsPanel"; import { PeerComparePanel } from "../../components/PeerComparePanel"; import { NewsList } from "../../components/NewsList"; +import { OrderbookPanel } from "../../components/OrderbookPanel"; import { PeriodReturns } from "../../components/PeriodReturns"; import { PeriodTabs, periodSpec, type Period } from "../../components/PeriodTabs"; import { PredictionPanel } from "../../components/PredictionPanel"; @@ -153,6 +154,7 @@ export default function CodePage({ params }: { params: { code: string } }) {
+
diff --git a/web/components/OrderbookPanel.tsx b/web/components/OrderbookPanel.tsx new file mode 100644 index 0000000..b61b691 --- /dev/null +++ b/web/components/OrderbookPanel.tsx @@ -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(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} +
  • + ); +} diff --git a/web/lib/api.ts b/web/lib/api.ts index b1ccba7..a5ee534 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -248,6 +248,27 @@ export type IndicesResponse = { indices: IndexSeries[]; }; +export type OrderbookLevel = { + level: number; + price: number; + qty: number; +}; + +export type OrderbookResponse = { + code: string; + name: string; + market: string; + status: "ok" | "skipped_missing_key"; + ts: string | null; + current: number | null; + prev_close_diff: number | null; + prev_close_pct: number | null; + asks: OrderbookLevel[]; + bids: OrderbookLevel[]; + ask_total: number; + bid_total: number; +}; + async function getJson(path: string, init?: RequestInit): Promise { const res = await fetch(`${API_BASE}${path}`, { ...init, @@ -311,4 +332,6 @@ export const api = { getJson( `/api/themes/peer/${encodeURIComponent(code)}?window=${window}&limit=${limit}`, ), + orderbook: (code: string) => + getJson(`/api/orderbook/${encodeURIComponent(code)}`), };