422 lines
13 KiB
TypeScript
422 lines
13 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";
|
|
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);
|
|
|
|
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,
|
|
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,
|
|
) {
|
|
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,
|
|
]);
|
|
}
|
|
|
|
return uniqueTrIds([ORDERBOOK_TR_ID, ORDERBOOK_TR_ID_TOTAL]);
|
|
}
|
|
|
|
/**
|
|
* @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 parsedByNineLevels = parseOrderBookByLevelCount(
|
|
values,
|
|
normalizedExpected,
|
|
9,
|
|
);
|
|
const parsedByTenLevels = parseOrderBookByLevelCount(
|
|
values,
|
|
normalizedExpected,
|
|
10,
|
|
);
|
|
|
|
const candidates = [parsedByNineLevels, parsedByTenLevels].filter(
|
|
(item): item is DashboardStockOrderBookResponse => item !== null,
|
|
);
|
|
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;
|
|
|
|
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)
|
|
);
|
|
}
|