Files
auto-trade/features/trade/utils/kisRealtimeUtils.ts

521 lines
17 KiB
TypeScript

import type {
DashboardRealtimeTradeTick,
DashboardStockOrderBookResponse,
} from "@/features/trade/types/trade.types";
import {
shouldUseAfterHoursSinglePriceTr,
shouldUseExpectedExecutionTr,
type DomesticKisSession,
} from "@/lib/kis/domestic-market-session";
const REALTIME_SIGN_NEGATIVE = new Set(["4", "5"]);
const EXECUTED_REALTIME_TRADE_TR_IDS = new Set([
"H0STCNT0",
"H0STOUP0",
"H0UNCNT0",
"H0NXCNT0",
]);
const EXPECTED_REALTIME_TRADE_TR_IDS = new Set([
"H0STANC0",
"H0STOAC0",
"H0UNANC0",
"H0NXANC0",
]);
const TICK_FIELD_INDEX = {
symbol: 0,
tickTime: 1,
price: 2,
sign: 3,
change: 4,
changeRate: 5,
open: 7,
high: 8,
low: 9,
askPrice1: 10,
bidPrice1: 11,
tradeVolume: 12,
accumulatedVolume: 13,
askSize1: 36,
bidSize1: 37,
totalAskSize: 38,
totalBidSize: 39,
sellExecutionCount: 15,
buyExecutionCount: 16,
netBuyExecutionCount: 17,
tradeStrength: 18,
executionClassCode: 21,
} as const;
const TRADE_TR_ID = "H0STCNT0";
const TRADE_TR_ID_EXPECTED = "H0STANC0";
const TRADE_TR_ID_OVERTIME = "H0STOUP0";
const TRADE_TR_ID_OVERTIME_EXPECTED = "H0STOAC0";
const TRADE_TR_ID_TOTAL = "H0UNCNT0";
const TRADE_TR_ID_TOTAL_EXPECTED = "H0UNANC0";
const ORDERBOOK_TR_ID = "H0STASP0";
const ORDERBOOK_TR_ID_TOTAL = "H0UNASP0";
const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0";
const DEFAULT_REALTIME_TR_STALE_MS = 3_000;
const OVERTIME_ORDERBOOK_MIN_FIELDS_9 = 52;
const OVERTIME_ORDERBOOK_MIN_FIELDS_10 = 56;
interface RealtimeTrPriorityDecisionParams {
incomingTrId: string;
preferredTrIds: string[];
activeTrId: string | null;
activeTrUpdatedAtMs: number;
nowMs?: number;
staleAfterMs?: number;
}
export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
if (!/^([01])\|/.test(raw)) return [] as DashboardRealtimeTradeTick[];
const parts = raw.split("|");
if (parts.length < 4) return [] as DashboardRealtimeTradeTick[];
const receivedTrId = parts[1];
const isExecutedTick = EXECUTED_REALTIME_TRADE_TR_IDS.has(receivedTrId);
const isExpectedTick = EXPECTED_REALTIME_TRADE_TR_IDS.has(receivedTrId);
// 정규 체결(TR)뿐 아니라 시간외/동시호가 예상체결(TR)도 틱 화면에 반영합니다.
// UI 흐름: useTradeTickSubscription -> resolveTradeTrIds(세션별 TR) -> parseKisRealtimeTickBatch
// 시간외 구간(예: H0STOAC0 only 수신)에서 틱이 비는 문제를 방지합니다.
if (!isExecutedTick && !isExpectedTick) {
return [] as DashboardRealtimeTradeTick[];
}
const tickCount = Number(parts[2] ?? "1");
const values = parts[3].split("^");
if (values.length === 0) return [] as DashboardRealtimeTradeTick[];
const parsedCount =
Number.isInteger(tickCount) && tickCount > 0 ? tickCount : 1;
const fieldsPerTick = Math.floor(values.length / parsedCount);
if (fieldsPerTick <= TICK_FIELD_INDEX.tradeStrength) {
return [] as DashboardRealtimeTradeTick[];
}
const normalizedExpected = normalizeDomesticSymbol(expectedSymbol);
const ticks: DashboardRealtimeTradeTick[] = [];
for (let index = 0; index < parsedCount; index++) {
const base = index * fieldsPerTick;
const symbol = readString(values, base + TICK_FIELD_INDEX.symbol);
const normalizedSymbol = normalizeDomesticSymbol(symbol);
if (normalizedSymbol !== normalizedExpected) {
continue;
}
const price = readNumber(values, base + TICK_FIELD_INDEX.price);
if (!Number.isFinite(price) || price <= 0) continue;
const sign = readString(values, base + TICK_FIELD_INDEX.sign);
const rawChange = readNumber(values, base + TICK_FIELD_INDEX.change);
const rawChangeRate = readNumber(
values,
base + TICK_FIELD_INDEX.changeRate,
);
const change = REALTIME_SIGN_NEGATIVE.has(sign)
? -Math.abs(rawChange)
: rawChange;
const changeRate = REALTIME_SIGN_NEGATIVE.has(sign)
? -Math.abs(rawChangeRate)
: rawChangeRate;
ticks.push({
symbol: normalizedExpected,
trId: receivedTrId,
isExpected: isExpectedTick,
tickTime: readString(values, base + TICK_FIELD_INDEX.tickTime),
price,
change,
changeRate,
tradeVolume: readNumber(values, base + TICK_FIELD_INDEX.tradeVolume),
accumulatedVolume: readNumber(
values,
base + TICK_FIELD_INDEX.accumulatedVolume,
),
tradeStrength: readNumber(values, base + TICK_FIELD_INDEX.tradeStrength),
askPrice1: readNumber(values, base + TICK_FIELD_INDEX.askPrice1),
bidPrice1: readNumber(values, base + TICK_FIELD_INDEX.bidPrice1),
askSize1: readNumber(values, base + TICK_FIELD_INDEX.askSize1),
bidSize1: readNumber(values, base + TICK_FIELD_INDEX.bidSize1),
totalAskSize: readNumber(values, base + TICK_FIELD_INDEX.totalAskSize),
totalBidSize: readNumber(values, base + TICK_FIELD_INDEX.totalBidSize),
sellExecutionCount: readNumber(
values,
base + TICK_FIELD_INDEX.sellExecutionCount,
),
buyExecutionCount: readNumber(
values,
base + TICK_FIELD_INDEX.buyExecutionCount,
),
netBuyExecutionCount: readNumber(
values,
base + TICK_FIELD_INDEX.netBuyExecutionCount,
),
executionClassCode: readString(
values,
base + TICK_FIELD_INDEX.executionClassCode,
),
open: readNumber(values, base + TICK_FIELD_INDEX.open),
high: readNumber(values, base + TICK_FIELD_INDEX.high),
low: readNumber(values, base + TICK_FIELD_INDEX.low),
});
}
return ticks;
}
/**
* @description 장 구간/시장별 누락을 줄이기 위해 TR ID를 우선순위 배열로 반환합니다.
*/
export function resolveTradeTrIds(
env: "real" | "mock",
session: DomesticKisSession,
) {
if (env === "mock") return [TRADE_TR_ID];
if (shouldUseAfterHoursSinglePriceTr(session)) {
return uniqueTrIds([
TRADE_TR_ID_OVERTIME,
TRADE_TR_ID_OVERTIME_EXPECTED,
TRADE_TR_ID_TOTAL,
TRADE_TR_ID_TOTAL_EXPECTED,
]);
}
if (shouldUseExpectedExecutionTr(session)) {
return uniqueTrIds([
TRADE_TR_ID_EXPECTED,
TRADE_TR_ID_TOTAL_EXPECTED,
TRADE_TR_ID,
TRADE_TR_ID_TOTAL,
]);
}
if (session === "afterCloseFixedPrice") {
return uniqueTrIds([
TRADE_TR_ID,
TRADE_TR_ID_TOTAL,
TRADE_TR_ID_OVERTIME,
TRADE_TR_ID_OVERTIME_EXPECTED,
TRADE_TR_ID_TOTAL_EXPECTED,
]);
}
return uniqueTrIds([TRADE_TR_ID, TRADE_TR_ID_TOTAL]);
}
/**
* @description 장 구간별 호가 TR ID 후보를 우선순위 배열로 반환합니다.
*/
export function resolveOrderBookTrIds(
env: "real" | "mock",
session: DomesticKisSession,
market?: "KOSPI" | "KOSDAQ",
) {
if (env === "mock") return [ORDERBOOK_TR_ID];
// 시간외(16:00~18:00): 공식 문서 TR(H0STOAA0)을 최우선 구독하고
// 누락 대비용으로 통합/정규 TR을 뒤에 둡니다.
if (shouldUseAfterHoursSinglePriceTr(session)) {
return uniqueTrIds([
ORDERBOOK_TR_ID_OVERTIME,
ORDERBOOK_TR_ID_TOTAL,
ORDERBOOK_TR_ID,
]);
}
if (session === "afterCloseFixedPrice") {
return uniqueTrIds([
ORDERBOOK_TR_ID_TOTAL,
ORDERBOOK_TR_ID,
ORDERBOOK_TR_ID_OVERTIME,
]);
}
// 통합장(통합호가) 값이 체결앱과 더 잘 맞는 케이스가 있어
// KOSPI는 통합(H0UNASP0) 우선, KOSDAQ은 KRX(H0STASP0) 우선으로 둡니다.
if (market === "KOSPI") {
return uniqueTrIds([ORDERBOOK_TR_ID_TOTAL, ORDERBOOK_TR_ID]);
}
return uniqueTrIds([ORDERBOOK_TR_ID, ORDERBOOK_TR_ID_TOTAL]);
}
/**
* @description 웹소켓 원문에서 TR ID만 빠르게 추출합니다.
* @param raw KIS 웹소켓 원문
* @returns TR ID. 포맷이 다르면 null
* @see features/trade/hooks/useTradeTickSubscription.ts handleTradeMessage 소스 우선순위 필터
* @see features/trade/hooks/useOrderbookSubscription.ts handleOrderBookMessage 소스 우선순위 필터
*/
export function extractKisRealtimeTrId(raw: string) {
if (!/^([01])\|/.test(raw)) return null;
const parts = raw.split("|", 3);
const trId = parts[1]?.trim();
return trId ? trId : null;
}
/**
* @description 다중 TR 구독 시 우선순위/유휴시간 기반으로 현재 메시지를 반영할지 결정합니다.
* @remarks 높은 우선순위 TR(ST 계열)은 즉시 승격하고, 현재 소스가 일정 시간 멈췄을 때만 하위 TR(UN 계열)로 폴백합니다.
* @see features/trade/hooks/useTradeTickSubscription.ts handleTradeMessage 체결 소스 고정 로직
* @see features/trade/hooks/useOrderbookSubscription.ts handleOrderBookMessage 호가 소스 고정 로직
*/
export function shouldAcceptRealtimeMessageByPriority({
incomingTrId,
preferredTrIds,
activeTrId,
activeTrUpdatedAtMs,
nowMs = Date.now(),
staleAfterMs = DEFAULT_REALTIME_TR_STALE_MS,
}: RealtimeTrPriorityDecisionParams) {
const incomingPriority = preferredTrIds.indexOf(incomingTrId);
if (incomingPriority < 0) return false;
if (!activeTrId) return true;
if (incomingTrId === activeTrId) return true;
const activePriority = preferredTrIds.indexOf(activeTrId);
if (activePriority < 0) return true;
if (incomingPriority < activePriority) {
return true;
}
const isActiveStale = nowMs - activeTrUpdatedAtMs > staleAfterMs;
return isActiveStale;
}
/**
* @description 호가 패킷이 실제 표시 가능한 값(호가/잔량/총잔량)을 포함하는지 확인합니다.
*/
export function hasMeaningfulOrderBookPayload(
data: DashboardStockOrderBookResponse,
) {
const hasLevelData = data.levels.some(
(level) =>
level.askPrice > 0 ||
level.bidPrice > 0 ||
level.askSize > 0 ||
level.bidSize > 0,
);
const hasSummaryData =
data.totalAskSize > 0 ||
data.totalBidSize > 0 ||
(data.anticipatedPrice ?? 0) > 0 ||
(data.accumulatedVolume ?? 0) > 0;
return hasLevelData || hasSummaryData;
}
/**
* @description KIS 실시간 호가(H0STASP0/H0UNASP0/H0STOAA0)를 OrderBook 구조로 파싱합니다.
*/
export function parseKisRealtimeOrderbook(
raw: string,
expectedSymbol: string,
): DashboardStockOrderBookResponse | null {
if (!/^([01])\|/.test(raw)) return null;
const parts = raw.split("|");
if (parts.length < 4) return null;
const trId = parts[1];
if (trId !== "H0STASP0" && trId !== "H0UNASP0" && trId !== "H0STOAA0") {
return null;
}
const values = parts[3].split("^");
const symbol = values[0]?.trim() ?? "";
const normalizedSymbol = normalizeDomesticSymbol(symbol);
const normalizedExpected = normalizeDomesticSymbol(expectedSymbol);
if (normalizedSymbol !== normalizedExpected) return null;
// 시간외(H0STOAA0)는 문서 버전에 따라 9호가/10호가가 혼재할 수 있어
// 두 형식을 모두 시도한 뒤 의미 있는 데이터 점수가 더 높은 결과를 선택합니다.
if (trId === "H0STOAA0") {
const candidates: DashboardStockOrderBookResponse[] = [];
// 길이가 충분하면 10호가를 우선 시도합니다.
if (values.length >= OVERTIME_ORDERBOOK_MIN_FIELDS_10) {
const parsedByTenLevels = parseOrderBookByLevelCount(
values,
normalizedExpected,
10,
);
if (parsedByTenLevels) {
candidates.push(parsedByTenLevels);
}
}
if (values.length >= OVERTIME_ORDERBOOK_MIN_FIELDS_9) {
const parsedByNineLevels = parseOrderBookByLevelCount(
values,
normalizedExpected,
9,
);
if (parsedByNineLevels) {
candidates.push(parsedByNineLevels);
}
}
if (candidates.length === 0) return null;
return pickBestOrderBookPayload(candidates);
}
return parseOrderBookByLevelCount(values, normalizedExpected, 10);
}
/**
* @description 국내 종목코드 비교를 위해 접두 문자를 제거하고 6자리 코드로 정규화합니다.
*/
function normalizeDomesticSymbol(value: string) {
const trimmed = value.trim();
const digits = trimmed.replace(/\D/g, "");
if (digits.length >= 6) {
return digits.slice(-6);
}
return trimmed;
}
function readString(values: string[], index: number) {
return (values[index] ?? "").trim();
}
function readNumber(values: string[], index: number) {
const raw = readString(values, index).replaceAll(",", "");
const value = Number(raw);
return Number.isFinite(value) ? value : 0;
}
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;
let score =
nonZeroLevels * 10 +
(payload.totalAskSize > 0 ? 4 : 0) +
(payload.totalBidSize > 0 ? 4 : 0) +
((payload.anticipatedPrice ?? 0) > 0 ? 2 : 0) +
((payload.accumulatedVolume ?? 0) > 0 ? 1 : 0);
// 시간외 9/10호가 파서 후보 중 잘못 정렬된 결과를 배제하기 위한 가격 밴드 검증입니다.
// (예: anticipatedPrice가 총잔량값으로 잘못 파싱되는 케이스)
const ladderPrices = payload.levels.flatMap((level) => [
level.askPrice,
level.bidPrice,
]);
const positivePrices = ladderPrices.filter((price) => price > 0);
const anticipatedPrice = payload.anticipatedPrice ?? 0;
if (anticipatedPrice > 0 && positivePrices.length > 0) {
const minPrice = Math.min(...positivePrices);
const maxPrice = Math.max(...positivePrices);
const isInReasonableBand =
anticipatedPrice >= minPrice * 0.7 && anticipatedPrice <= maxPrice * 1.3;
score += isInReasonableBand ? 12 : -80;
}
return score;
}