From 276ef09d89dd60fffc86b44e638ed8d1df148ccf Mon Sep 17 00:00:00 2001 From: "jihoon87.lee" Date: Fri, 13 Feb 2026 16:41:10 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B0=A8=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kis-realtime/stores/kisWebSocketStore.ts | 79 +++++-- .../trade/components/header/StockHeader.tsx | 16 +- .../components/layout/DashboardLayout.tsx | 94 +++++--- .../layout/TradeDashboardContent.tsx | 6 + .../trade/components/orderbook/OrderBook.tsx | 196 +++++++++++------ .../components/search/TradeSearchSection.tsx | 2 +- features/trade/types/trade.types.ts | 4 + features/trade/utils/kisRealtimeUtils.ts | 201 +++++++++++++----- 8 files changed, 421 insertions(+), 177 deletions(-) diff --git a/features/kis-realtime/stores/kisWebSocketStore.ts b/features/kis-realtime/stores/kisWebSocketStore.ts index 309e495..cfe6cc0 100644 --- a/features/kis-realtime/stores/kisWebSocketStore.ts +++ b/features/kis-realtime/stores/kisWebSocketStore.ts @@ -192,21 +192,12 @@ export const useKisWebSocketStore = create((set, get) => ({ const parts = data.split("|"); if (parts.length >= 4) { const trId = parts[1]; - // 데이터 부분 (마지막 부분)에서 종목코드를 찾아야 함. - // 하지만 응답에는 종목코드가 명시적으로 없는 경우가 많음 (순서로 추론). - // 다행히 KIS API는 요청했던 TR_ID와 수신된 데이터의 호가/체결 데이터를 매핑해야 함. - // 여기서는 모든 구독자에게 브로드캐스트하는 방식을 사용 (TR_ID 기준). - - // 더 정확한 라우팅을 위해: - // 실시간 체결/호가 데이터에는 종목코드가 포함되어 있음. - // 체결(H0STCNT0): data.split("^")[0] (유가증권 단축종목코드) const body = parts[3]; const values = body.split("^"); - const symbol = values[0]; // 대부분 첫 번째 필드가 종목코드 + const symbol = values[0] ?? ""; - const key = `${trId}|${symbol}`; - const callbacks = subscribers.get(key); - callbacks?.forEach((cb) => cb(data)); + // UI 흐름: 소켓 수신 -> TR/심볼 정규화 매칭 -> 해당 구독 콜백 실행 -> 훅 파서(parseKisRealtime*) -> 화면 반영 + dispatchRealtimeMessageToSubscribers(trId, symbol, data); } } }; @@ -384,3 +375,67 @@ function waitForSocketClose(target: WebSocket, timeoutMs = 2_000) { target.addEventListener("error", onError); }); } + +/** + * @description 실시간 데이터(TR/종목코드)와 등록된 구독자를 매칭해 콜백을 실행합니다. + * 종목코드 접두(prefix) 차이(A005930/J005930 등)와 구독 심볼 형식 차이를 허용합니다. + * @param trId 수신 TR ID + * @param rawSymbol 수신 데이터의 원본 종목코드 + * @param payload 웹소켓 원문 메시지 + * @see features/trade/hooks/useTradeTickSubscription.ts 체결 구독 콜백 + * @see features/trade/hooks/useOrderbookSubscription.ts 호가 구독 콜백 + */ +function dispatchRealtimeMessageToSubscribers( + trId: string, + rawSymbol: string, + payload: string, +) { + const callbackSet = new Set(); + const normalizedIncomingSymbol = normalizeRealtimeSymbol(rawSymbol); + + // 1) 정확히 일치하는 key 우선 + const exactKey = `${trId}|${rawSymbol}`; + subscribers.get(exactKey)?.forEach((callback) => callbackSet.add(callback)); + + // 2) 숫자 6자리 기준(정규화)으로 일치하는 key 매칭 + subscribers.forEach((callbacks, key) => { + const [subscribedTrId, subscribedSymbol = ""] = key.split("|"); + if (subscribedTrId !== trId) return; + if (!normalizedIncomingSymbol) return; + + const normalizedSubscribedSymbol = normalizeRealtimeSymbol(subscribedSymbol); + if (!normalizedSubscribedSymbol) return; + if (normalizedIncomingSymbol !== normalizedSubscribedSymbol) return; + + callbacks.forEach((callback) => callbackSet.add(callback)); + }); + + // 3) 심볼 매칭이 실패한 경우에도 같은 TR 전체 콜백으로 안전 fallback + if (callbackSet.size === 0) { + subscribers.forEach((callbacks, key) => { + const [subscribedTrId] = key.split("|"); + if (subscribedTrId !== trId) return; + callbacks.forEach((callback) => callbackSet.add(callback)); + }); + } + + callbackSet.forEach((callback) => callback(payload)); +} + +/** + * @description 실시간 종목코드를 비교 가능한 6자리 숫자 코드로 정규화합니다. + * @param value 원본 종목코드 (예: 005930, A005930) + * @returns 정규화된 6자리 코드. 파싱 불가 시 원본 trim 값 반환 + * @see features/kis-realtime/stores/kisWebSocketStore.ts dispatchRealtimeMessageToSubscribers + */ +function normalizeRealtimeSymbol(value: string) { + const trimmed = value.trim(); + if (!trimmed) return ""; + + const digits = trimmed.replace(/\D/g, ""); + if (digits.length >= 6) { + return digits.slice(-6); + } + + return trimmed; +} diff --git a/features/trade/components/header/StockHeader.tsx b/features/trade/components/header/StockHeader.tsx index 8de02da..357f131 100644 --- a/features/trade/components/header/StockHeader.tsx +++ b/features/trade/components/header/StockHeader.tsx @@ -31,28 +31,28 @@ export function StockHeader({ : "text-foreground"; return ( -
+
{/* ========== STOCK SUMMARY ========== */}
-

+

{stock.name}

- + {stock.symbol}/{stock.market}
- {price} - + {price} + {changeRate}% {change}
{/* ========== STATS ========== */} -
+

고가

{high || "--"}

@@ -67,10 +67,10 @@ export function StockHeader({
- + {/* ========== DESKTOP STATS ========== */} -
+
고가 {high || "--"} diff --git a/features/trade/components/layout/DashboardLayout.tsx b/features/trade/components/layout/DashboardLayout.tsx index 4b87dbf..dc03ed8 100644 --- a/features/trade/components/layout/DashboardLayout.tsx +++ b/features/trade/components/layout/DashboardLayout.tsx @@ -1,4 +1,6 @@ import { ReactNode } from "react"; +import { ChevronDown, ChevronUp } from "lucide-react"; +import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; interface DashboardLayoutProps { @@ -6,14 +8,22 @@ interface DashboardLayoutProps { chart: ReactNode; orderBook: ReactNode; orderForm: ReactNode; + isChartVisible: boolean; + onToggleChart: () => void; className?: string; } +/** + * @description 트레이드 본문 레이아웃을 구성합니다. 상단 차트 영역은 보임/숨김 토글을 지원합니다. + * @see features/trade/components/layout/TradeDashboardContent.tsx 상위 컴포넌트에서 차트 토글 상태를 관리하고 본 레이아웃에 전달합니다. + */ export function DashboardLayout({ header, chart, orderBook, orderForm, + isChartVisible, + onToggleChart, className, }: DashboardLayoutProps) { return ( @@ -35,36 +45,66 @@ export function DashboardLayout({ {/* 2. Main Content Area */}
- {/* Left Column: Chart & Info */} -
-
{chart}
- {/* Future: Transaction History / Market Depth can go here */} -
+
+ {/* ========== CHART SECTION ========== */} +
+
+
+

+ 실시간 차트 +

+

+ 거래 화면 집중을 위해 기본은 접힌 상태입니다. +

+
+ {/* UI 흐름: 차트 토글 버튼 -> onToggleChart 호출 -> TradeDashboardContent의 상태 변경 -> 차트 wrapper 높이 반영 */} + +
- {/* Right Column: Order Book & Order Form */} -
- {/* Top: Order Book (Hoga) */} -
- {orderBook} -
- {/* Bottom: Order Form */} -
- {orderForm} +
+
+ {chart} +
+
+
+ + {/* ========== ORDERBOOK + ORDER SECTION ========== */} +
+
+
+ {orderBook} +
+
+ +
+
{orderForm}
+
diff --git a/features/trade/components/layout/TradeDashboardContent.tsx b/features/trade/components/layout/TradeDashboardContent.tsx index d07e3c5..89a95c9 100644 --- a/features/trade/components/layout/TradeDashboardContent.tsx +++ b/features/trade/components/layout/TradeDashboardContent.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; import { StockLineChart } from "@/features/trade/components/chart/StockLineChart"; import { StockHeader } from "@/features/trade/components/header/StockHeader"; @@ -41,6 +42,9 @@ export function TradeDashboardContent({ change, changeRate, }: TradeDashboardContentProps) { + // [State] 차트 영역 보임/숨김 상태 + const [isChartVisible, setIsChartVisible] = useState(false); + return (
} orderForm={} + isChartVisible={isChartVisible} + onToggleChart={() => setIsChartVisible((prev) => !prev)} />
); diff --git a/features/trade/components/orderbook/OrderBook.tsx b/features/trade/components/orderbook/OrderBook.tsx index c1c3bed..2561f7f 100644 --- a/features/trade/components/orderbook/OrderBook.tsx +++ b/features/trade/components/orderbook/OrderBook.tsx @@ -28,6 +28,40 @@ interface BookRow { isHighlighted: boolean; } +/** + * @description 실시간 호가 레벨 데이터가 실제 값을 포함하는지 검사합니다. + * @see features/trade/components/orderbook/OrderBook.tsx levels 계산에서 WS 호가 유효성 판단에 사용합니다. + */ +function hasOrderBookLevelData(levels: DashboardStockOrderBookResponse["levels"]) { + 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호가/잔량/총잔량을 파싱합니다. + */ +function buildFallbackLevelsFromTick(latestTick: DashboardRealtimeTradeTick | null) { + if (!latestTick) return [] as DashboardStockOrderBookResponse["levels"]; + if (latestTick.askPrice1 <= 0 && latestTick.bidPrice1 <= 0) { + return [] as DashboardStockOrderBookResponse["levels"]; + } + + return [ + { + askPrice: latestTick.askPrice1, + bidPrice: latestTick.bidPrice1, + askSize: Math.max(latestTick.askSize1, 0), + bidSize: Math.max(latestTick.bidSize1, 0), + }, + ]; +} + // ─── 유틸리티 함수 ────────────────────────────────────── /** 천단위 구분 포맷 */ @@ -111,7 +145,17 @@ export function OrderBook({ orderBook, isLoading, }: OrderBookProps) { - const levels = useMemo(() => orderBook?.levels ?? [], [orderBook]); + const realtimeLevels = useMemo(() => orderBook?.levels ?? [], [orderBook]); + const fallbackLevelsFromTick = useMemo( + () => buildFallbackLevelsFromTick(latestTick), + [latestTick], + ); + const levels = useMemo(() => { + if (hasOrderBookLevelData(realtimeLevels)) return realtimeLevels; + return fallbackLevelsFromTick; + }, [fallbackLevelsFromTick, realtimeLevels]); + const isTickFallbackActive = + !hasOrderBookLevelData(realtimeLevels) && fallbackLevelsFromTick.length > 0; // 체결가: tick에서 우선, 없으면 0 const latestPrice = @@ -164,8 +208,14 @@ export function OrderBook({ const bestAsk = levels.find((l) => l.askPrice > 0)?.askPrice ?? 0; const bestBid = levels.find((l) => l.bidPrice > 0)?.bidPrice ?? 0; const spread = bestAsk > 0 && bestBid > 0 ? bestAsk - bestBid : 0; - const totalAsk = orderBook?.totalAskSize ?? 0; - const totalBid = orderBook?.totalBidSize ?? 0; + const totalAsk = + orderBook?.totalAskSize && orderBook.totalAskSize > 0 + ? orderBook.totalAskSize + : (latestTick?.totalAskSize ?? 0); + const totalBid = + orderBook?.totalBidSize && orderBook.totalBidSize > 0 + ? orderBook.totalBidSize + : (latestTick?.totalBidSize ?? 0); const imbalance = totalAsk + totalBid > 0 ? ((totalBid - totalAsk) / (totalAsk + totalBid)) * 100 @@ -181,8 +231,10 @@ export function OrderBook({
); } - if (isLoading && !orderBook) return ; - if (!orderBook) { + if (isLoading && !orderBook && fallbackLevelsFromTick.length === 0) { + return ; + } + if (!orderBook && fallbackLevelsFromTick.length === 0) { return (
호가 정보를 가져오지 못했습니다. @@ -210,68 +262,72 @@ export function OrderBook({ {/* ── 일반호가 탭 ── */} -
-
- {/* 호가 테이블 */} -
- - - {/* 매도호가 */} - +
+ {/* 호가 테이블 */} +
+ {isTickFallbackActive && ( +
+ 시간외 전용 호가(`H0STOAA0`) 미수신 상태입니다. 체결(`H0UNCNT0`) + 1호가 기준으로 표시 중입니다. +
+ )} + + + {/* 매도호가 */} + - {/* 중앙 바: 현재 체결가 */} -
-
- {totalAsk > 0 ? fmt(totalAsk) : ""} -
-
- - {latestPrice > 0 - ? fmt(latestPrice) - : bestAsk > 0 - ? fmt(bestAsk) - : "-"} - - {latestPrice > 0 && basePrice > 0 && ( - = basePrice - ? "text-red-500" - : "text-blue-600 dark:text-blue-400", - )} - > - {fmtPct(pctChange(latestPrice, basePrice))} - - )} -
-
- {totalBid > 0 ? fmt(totalBid) : ""} -
+ {/* 중앙 바: 현재 체결가 */} +
+
+ {totalAsk > 0 ? fmt(totalAsk) : ""}
+
+ + {latestPrice > 0 + ? fmt(latestPrice) + : bestAsk > 0 + ? fmt(bestAsk) + : "-"} + + {latestPrice > 0 && basePrice > 0 && ( + = basePrice + ? "text-red-500" + : "text-blue-600 dark:text-blue-400", + )} + > + {fmtPct(pctChange(latestPrice, basePrice))} + + )} +
+
+ {totalBid > 0 ? fmt(totalBid) : ""} +
+
- {/* 매수호가 */} - - -
- - {/* 우측 요약 패널 */} -
- -
+ {/* 매수호가 */} + +
- {/* 체결 목록 */} -
+ {/* 체결 목록: 데스크톱에서는 호가 오른쪽, 모바일에서는 아래 */} +
+ + {/* 우측 요약 패널 */} +
+ +
@@ -430,7 +486,7 @@ function SummaryPanel({ totalAsk, totalBid, }: { - orderBook: DashboardStockOrderBookResponse; + orderBook: DashboardStockOrderBookResponse | null; latestTick: DashboardRealtimeTradeTick | null; spread: number; imbalance: number; @@ -441,17 +497,17 @@ function SummaryPanel({
- + +
체결시각
체결가
체결량
체결강도
- +
{ticks.length === 0 && ( -
+
체결 데이터가 아직 없습니다.
)} diff --git a/features/trade/components/search/TradeSearchSection.tsx b/features/trade/components/search/TradeSearchSection.tsx index 4f5f35e..4868680 100644 --- a/features/trade/components/search/TradeSearchSection.tsx +++ b/features/trade/components/search/TradeSearchSection.tsx @@ -50,7 +50,7 @@ export function TradeSearchSection({ onClearHistory, }: TradeSearchSectionProps) { return ( -
+
{/* ========== SEARCH SHELL ========== */}
= 56 ? 10 : 9) : 10; const symbol = values[0]?.trim() ?? ""; const normalizedSymbol = normalizeDomesticSymbol(symbol); const normalizedExpected = normalizeDomesticSymbol(expectedSymbol); if (normalizedSymbol !== normalizedExpected) return null; - const askPriceStart = 3; - const bidPriceStart = askPriceStart + levelCount; - const askSizeStart = bidPriceStart + levelCount; - const bidSizeStart = askSizeStart + levelCount; - const totalAskIndex = bidSizeStart + levelCount; - const totalBidIndex = totalAskIndex + 1; - const overtimeTotalAskIndex = totalBidIndex + 1; - const overtimeTotalBidIndex = overtimeTotalAskIndex + 1; - const anticipatedPriceIndex = overtimeTotalBidIndex + 1; - const anticipatedVolumeIndex = anticipatedPriceIndex + 1; - const anticipatedTotalVolumeIndex = anticipatedPriceIndex + 2; - const anticipatedChangeIndex = anticipatedPriceIndex + 3; - const anticipatedChangeSignIndex = anticipatedPriceIndex + 4; - const anticipatedChangeRateIndex = anticipatedPriceIndex + 5; - const accumulatedVolumeIndex = anticipatedPriceIndex + 6; - const totalAskDeltaIndex = anticipatedPriceIndex + 7; - const totalBidDeltaIndex = anticipatedPriceIndex + 8; - const minFieldLength = totalBidDeltaIndex + 1; + // 시간외(H0STOAA0)는 문서 버전에 따라 9호가/10호가가 혼재할 수 있어 + // 두 형식을 모두 시도한 뒤 의미 있는 데이터 점수가 더 높은 결과를 선택합니다. + if (trId === "H0STOAA0") { + const parsedByNineLevels = parseOrderBookByLevelCount( + values, + normalizedExpected, + 9, + ); + const parsedByTenLevels = parseOrderBookByLevelCount( + values, + normalizedExpected, + 10, + ); - if (values.length < minFieldLength) return null; + const candidates = [parsedByNineLevels, parsedByTenLevels].filter( + (item): item is DashboardStockOrderBookResponse => item !== null, + ); + if (candidates.length === 0) return null; - const realtimeLevels = Array.from({ length: levelCount }, (_, i) => ({ - askPrice: readNumber(values, askPriceStart + i), - bidPrice: readNumber(values, bidPriceStart + i), - askSize: readNumber(values, askSizeStart + i), - bidSize: readNumber(values, bidSizeStart + i), - })); + return pickBestOrderBookPayload(candidates); + } - const regularTotalAskSize = readNumber(values, totalAskIndex); - const regularTotalBidSize = readNumber(values, totalBidIndex); - const overtimeTotalAskSize = readNumber(values, overtimeTotalAskIndex); - const overtimeTotalBidSize = readNumber(values, overtimeTotalBidIndex); - - return { - symbol: normalizedExpected, - // 장후 시간외에서는 일반 총잔량이 0이고 OVTM 총잔량만 채워지는 경우가 있습니다. - totalAskSize: - regularTotalAskSize > 0 ? regularTotalAskSize : overtimeTotalAskSize, - totalBidSize: - regularTotalBidSize > 0 ? regularTotalBidSize : overtimeTotalBidSize, - businessHour: readString(values, 1), - hourClassCode: readString(values, 2), - anticipatedPrice: readNumber(values, anticipatedPriceIndex), - anticipatedVolume: readNumber(values, anticipatedVolumeIndex), - anticipatedTotalVolume: readNumber(values, anticipatedTotalVolumeIndex), - anticipatedChange: readNumber(values, anticipatedChangeIndex), - anticipatedChangeSign: readString(values, anticipatedChangeSignIndex), - anticipatedChangeRate: readNumber(values, anticipatedChangeRateIndex), - accumulatedVolume: readNumber(values, accumulatedVolumeIndex), - totalAskSizeDelta: readNumber(values, totalAskDeltaIndex), - totalBidSizeDelta: readNumber(values, totalBidDeltaIndex), - levels: realtimeLevels, - source: "REALTIME", - tradingEnv: "real", - fetchedAt: new Date().toISOString(), - }; + return parseOrderBookByLevelCount(values, normalizedExpected, 10); } /** @@ -336,3 +319,103 @@ function readNumber(values: string[], index: number) { function uniqueTrIds(ids: string[]) { return [...new Set(ids)]; } + +/** + * @description levelCount(9/10)에 맞춰 호가 payload를 파싱합니다. + * @see features/trade/utils/kisRealtimeUtils.ts parseKisRealtimeOrderbook 시간외/정규장 파서에서 공통 사용합니다. + */ +function parseOrderBookByLevelCount( + values: string[], + symbol: string, + levelCount: 9 | 10, +): DashboardStockOrderBookResponse | null { + const askPriceStart = 3; + const bidPriceStart = askPriceStart + levelCount; + const askSizeStart = bidPriceStart + levelCount; + const bidSizeStart = askSizeStart + levelCount; + const totalAskIndex = bidSizeStart + levelCount; + const totalBidIndex = totalAskIndex + 1; + const overtimeTotalAskIndex = totalBidIndex + 1; + const overtimeTotalBidIndex = overtimeTotalAskIndex + 1; + const anticipatedPriceIndex = overtimeTotalBidIndex + 1; + const anticipatedVolumeIndex = anticipatedPriceIndex + 1; + const anticipatedTotalVolumeIndex = anticipatedPriceIndex + 2; + const anticipatedChangeIndex = anticipatedPriceIndex + 3; + const anticipatedChangeSignIndex = anticipatedPriceIndex + 4; + const anticipatedChangeRateIndex = anticipatedPriceIndex + 5; + const accumulatedVolumeIndex = anticipatedPriceIndex + 6; + const totalAskDeltaIndex = anticipatedPriceIndex + 7; + const totalBidDeltaIndex = anticipatedPriceIndex + 8; + const minFieldLength = totalBidDeltaIndex + 1; + + if (values.length < minFieldLength) return null; + + const levels = Array.from({ length: levelCount }, (_, index) => ({ + askPrice: readNumber(values, askPriceStart + index), + bidPrice: readNumber(values, bidPriceStart + index), + askSize: readNumber(values, askSizeStart + index), + bidSize: readNumber(values, bidSizeStart + index), + })); + + const regularTotalAskSize = readNumber(values, totalAskIndex); + const regularTotalBidSize = readNumber(values, totalBidIndex); + const overtimeTotalAskSize = readNumber(values, overtimeTotalAskIndex); + const overtimeTotalBidSize = readNumber(values, overtimeTotalBidIndex); + + return { + symbol, + totalAskSize: + regularTotalAskSize > 0 ? regularTotalAskSize : overtimeTotalAskSize, + totalBidSize: + regularTotalBidSize > 0 ? regularTotalBidSize : overtimeTotalBidSize, + businessHour: readString(values, 1), + hourClassCode: readString(values, 2), + anticipatedPrice: readNumber(values, anticipatedPriceIndex), + anticipatedVolume: readNumber(values, anticipatedVolumeIndex), + anticipatedTotalVolume: readNumber(values, anticipatedTotalVolumeIndex), + anticipatedChange: readNumber(values, anticipatedChangeIndex), + anticipatedChangeSign: readString(values, anticipatedChangeSignIndex), + anticipatedChangeRate: readNumber(values, anticipatedChangeRateIndex), + accumulatedVolume: readNumber(values, accumulatedVolumeIndex), + totalAskSizeDelta: readNumber(values, totalAskDeltaIndex), + totalBidSizeDelta: readNumber(values, totalBidDeltaIndex), + levels, + source: "REALTIME", + tradingEnv: "real", + fetchedAt: new Date().toISOString(), + }; +} + +/** + * @description 복수 파싱 결과 중 실제 표시 값이 풍부한 payload를 선택합니다. + * @see features/trade/utils/kisRealtimeUtils.ts parseKisRealtimeOrderbook 시간외 9/10호가 자동 선택에 사용합니다. + */ +function pickBestOrderBookPayload( + candidates: DashboardStockOrderBookResponse[], +) { + return [...candidates].sort((left, right) => { + return scoreOrderBookPayload(right) - scoreOrderBookPayload(left); + })[0]; +} + +/** + * @description 호가 payload가 실제로 얼마나 유효한지 점수화합니다. + * @see features/trade/utils/kisRealtimeUtils.ts pickBestOrderBookPayload 시간외 파서 후보 비교용입니다. + */ +function scoreOrderBookPayload(payload: DashboardStockOrderBookResponse) { + const nonZeroLevels = payload.levels.filter( + (level) => + level.askPrice > 0 || + level.bidPrice > 0 || + level.askSize > 0 || + level.bidSize > 0, + ).length; + + return ( + nonZeroLevels * 10 + + (payload.totalAskSize > 0 ? 4 : 0) + + (payload.totalBidSize > 0 ? 4 : 0) + + ((payload.anticipatedPrice ?? 0) > 0 ? 2 : 0) + + ((payload.accumulatedVolume ?? 0) > 0 ? 1 : 0) + ); +}