211 lines
6.2 KiB
TypeScript
211 lines
6.2 KiB
TypeScript
|
|
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;
|
||
|
|
}
|