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:
60
backend/app/api/orderbook.py
Normal file
60
backend/app/api/orderbook.py
Normal file
@@ -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,
|
||||||
|
}
|
||||||
@@ -357,6 +357,95 @@ def fetch_minute_range(
|
|||||||
return sorted(accumulated.values(), key=lambda r: r["ts"])
|
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]:
|
def ping() -> dict[str, Any]:
|
||||||
"""토큰 발급만 시도해서 키 유효성 확인."""
|
"""토큰 발급만 시도해서 키 유효성 확인."""
|
||||||
if not _has_keys():
|
if not _has_keys():
|
||||||
|
|||||||
@@ -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.markets import router as markets_router
|
||||||
from app.api.metrics import router as metrics_router
|
from app.api.metrics import router as metrics_router
|
||||||
from app.api.news import router as news_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.predict import router as predict_router
|
||||||
from app.api.refresh import router as refresh_router
|
from app.api.refresh import router as refresh_router
|
||||||
from app.api.symbols import router as symbols_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(news_router)
|
||||||
app.include_router(markets_router)
|
app.include_router(markets_router)
|
||||||
app.include_router(themes_router)
|
app.include_router(themes_router)
|
||||||
|
app.include_router(orderbook_router)
|
||||||
|
|
||||||
|
|
||||||
def _resolved_device() -> str:
|
def _resolved_device() -> str:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { DisclosuresPanel } from "../../components/DisclosuresPanel";
|
|||||||
import { MetricsPanel } from "../../components/MetricsPanel";
|
import { MetricsPanel } from "../../components/MetricsPanel";
|
||||||
import { PeerComparePanel } from "../../components/PeerComparePanel";
|
import { PeerComparePanel } from "../../components/PeerComparePanel";
|
||||||
import { NewsList } from "../../components/NewsList";
|
import { NewsList } from "../../components/NewsList";
|
||||||
|
import { OrderbookPanel } from "../../components/OrderbookPanel";
|
||||||
import { PeriodReturns } from "../../components/PeriodReturns";
|
import { PeriodReturns } from "../../components/PeriodReturns";
|
||||||
import { PeriodTabs, periodSpec, type Period } from "../../components/PeriodTabs";
|
import { PeriodTabs, periodSpec, type Period } from "../../components/PeriodTabs";
|
||||||
import { PredictionPanel } from "../../components/PredictionPanel";
|
import { PredictionPanel } from "../../components/PredictionPanel";
|
||||||
@@ -153,6 +154,7 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SymbolSidebar code={code} />
|
<SymbolSidebar code={code} />
|
||||||
|
<OrderbookPanel code={code} />
|
||||||
<AlertsPanel code={code} name={chart?.name} current={current} />
|
<AlertsPanel code={code} name={chart?.name} current={current} />
|
||||||
<RelatedStocks code={code} />
|
<RelatedStocks code={code} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
186
web/components/OrderbookPanel.tsx
Normal file
186
web/components/OrderbookPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -248,6 +248,27 @@ export type IndicesResponse = {
|
|||||||
indices: IndexSeries[];
|
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<T>(path: string, init?: RequestInit): Promise<T> {
|
async function getJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
@@ -311,4 +332,6 @@ export const api = {
|
|||||||
getJson<PeerCompareResponse>(
|
getJson<PeerCompareResponse>(
|
||||||
`/api/themes/peer/${encodeURIComponent(code)}?window=${window}&limit=${limit}`,
|
`/api/themes/peer/${encodeURIComponent(code)}?window=${window}&limit=${limit}`,
|
||||||
),
|
),
|
||||||
|
orderbook: (code: string) =>
|
||||||
|
getJson<OrderbookResponse>(`/api/orderbook/${encodeURIComponent(code)}`),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user