import type { DashboardRealtimeTradeTick, DashboardStockOrderBookResponse, } from "@/features/trade/types/trade.types"; type OrderBookLevels = DashboardStockOrderBookResponse["levels"]; export interface BookRow { price: number; size: number; changeValue: number | null; isHighlighted: boolean; } /** * @description 실시간 호가 레벨 데이터가 실제 값을 포함하는지 검사합니다. * @see features/trade/components/orderbook/OrderBook.tsx levels 계산에서 WS 호가 유효성 판단에 사용합니다. */ export function hasOrderBookLevelData(levels: OrderBookLevels) { return levels.some( (level) => level.askPrice > 0 || level.bidPrice > 0 || level.askSize > 0 || level.bidSize > 0, ); } /** * @description H0STOAA0 미수신 상황에서 체결(H0UNCNT0) 데이터로 최소 1호가를 구성합니다. * @see features/trade/utils/kisRealtimeUtils.ts parseKisRealtimeTickBatch 체결 데이터에서 1호가/잔량/총잔량을 파싱합니다. */ export function buildFallbackLevelsFromTick( latestTick: DashboardRealtimeTradeTick | null, ) { if (!latestTick) return [] as OrderBookLevels; if (latestTick.askPrice1 <= 0 && latestTick.bidPrice1 <= 0) { return [] as OrderBookLevels; } return [ { askPrice: latestTick.askPrice1, bidPrice: latestTick.bidPrice1, askSize: Math.max(latestTick.askSize1, 0), bidSize: Math.max(latestTick.bidSize1, 0), }, ] satisfies OrderBookLevels; } /** 천단위 구분 포맷 */ export function fmt(v: number) { return Number.isFinite(v) ? v.toLocaleString("ko-KR") : "0"; } /** 부호 포함 퍼센트 */ export function fmtPct(v: number) { return `${v > 0 ? "+" : ""}${v.toFixed(2)}%`; } /** 등락률 계산 */ export function pctChange(price: number, base: number) { return base > 0 ? ((price - base) / base) * 100 : 0; } /** * @description 증감 숫자를 부호 포함 문자열로 포맷합니다. * @see features/trade/components/orderbook/orderbook-sections.tsx BookSideRows */ export function fmtSignedChange(v: number) { if (!Number.isFinite(v)) return "-"; if (v > 0) return `+${fmt(v)}`; if (v < 0) return `-${fmt(Math.abs(v))}`; return "0"; } /** * @description 증감값에 따라 색상 톤 클래스를 반환합니다. * @see features/trade/components/orderbook/orderbook-sections.tsx BookSideRows */ export function getChangeToneClass( changeValue: number | null, neutralClass = "text-muted-foreground", ) { if (changeValue === null) { return neutralClass; } if (changeValue > 0) { return "text-red-500"; } if (changeValue < 0) { return "text-blue-600 dark:text-blue-400"; } return neutralClass; } /** 체결 시각 포맷 */ export function fmtTime(hms: string) { if (!hms || hms.length !== 6) return "--:--:--"; return `${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}`; } /** * @description 체결 틱 데이터에서 체결 주도 방향(매수/매도/중립)을 계산합니다. * @see features/trade/components/orderbook/orderbook-sections.tsx TradeTape 체결량 글자색 결정에 사용합니다. */ export function resolveTickExecutionSide( tick: DashboardRealtimeTradeTick, olderTick?: DashboardRealtimeTradeTick, ) { const executionClassCode = (tick.executionClassCode ?? "").trim(); if (executionClassCode === "1" || executionClassCode === "2") { return "buy" as const; } if (executionClassCode === "4" || executionClassCode === "5") { return "sell" as const; } if (olderTick) { const netBuyDelta = tick.netBuyExecutionCount - olderTick.netBuyExecutionCount; if (netBuyDelta > 0) return "buy" as const; if (netBuyDelta < 0) return "sell" as const; const buyCountDelta = tick.buyExecutionCount - olderTick.buyExecutionCount; const sellCountDelta = tick.sellExecutionCount - olderTick.sellExecutionCount; if (buyCountDelta > sellCountDelta) return "buy" as const; if (buyCountDelta < sellCountDelta) return "sell" as const; } if (tick.askPrice1 > 0 && tick.bidPrice1 > 0) { if (tick.price >= tick.askPrice1 && tick.price > tick.bidPrice1) { return "buy" as const; } if (tick.price <= tick.bidPrice1 && tick.price < tick.askPrice1) { return "sell" as const; } } if (tick.tradeStrength > 100) return "buy" as const; if (tick.tradeStrength < 100) return "sell" as const; return "neutral" as const; } /** * @description 호가 레벨을 화면 렌더링용 행 모델로 변환합니다. * @summary UI 흐름: OrderBook(levels/basePrice/latestPrice) -> buildBookRows -> BookSideRows -> 가격/증감 반영 * @see features/trade/components/orderbook/OrderBook.tsx OrderBook askRows/bidRows 계산 */ export function buildBookRows({ levels, side, basePrice, latestPrice, }: { levels: OrderBookLevels; side: "ask" | "bid"; basePrice: number; latestPrice: number; }) { const normalizedLevels = side === "ask" ? [...levels].reverse() : levels; return normalizedLevels.map((level) => { const price = side === "ask" ? level.askPrice : level.bidPrice; const size = side === "ask" ? level.askSize : level.bidSize; const changeValue = resolvePriceChange(price, basePrice); return { price, size: Math.max(size, 0), changeValue, isHighlighted: latestPrice > 0 && price === latestPrice, } satisfies BookRow; }); } /** * @description 호가/체결 증감 표시용 기준가(전일종가)를 결정합니다. * @summary UI 흐름: OrderBook props -> resolveReferencePrice -> 호가행/체결가 증감 계산 반영 * @see features/trade/components/orderbook/OrderBook.tsx basePrice 계산 */ export function resolveReferencePrice({ referencePrice, latestTick, }: { referencePrice?: number; latestTick: DashboardRealtimeTradeTick | null; }) { if ((referencePrice ?? 0) > 0) { return referencePrice!; } if (latestTick?.price && Number.isFinite(latestTick.change)) { const derivedPrevClose = latestTick.price - latestTick.change; if (derivedPrevClose > 0) { return derivedPrevClose; } } return 0; } function resolvePriceChange(price: number, basePrice: number) { if (price <= 0 || basePrice <= 0) { return null; } return price - basePrice; }