실시간 웹소켓 리팩토링
This commit is contained in:
@@ -56,6 +56,18 @@ 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[];
|
||||
|
||||
@@ -66,7 +78,10 @@ export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
|
||||
const isExecutedTick = EXECUTED_REALTIME_TRADE_TR_IDS.has(receivedTrId);
|
||||
const isExpectedTick = EXPECTED_REALTIME_TRADE_TR_IDS.has(receivedTrId);
|
||||
|
||||
if (!isExecutedTick || isExpectedTick) {
|
||||
// 정규 체결(TR)뿐 아니라 시간외/동시호가 예상체결(TR)도 틱 화면에 반영합니다.
|
||||
// UI 흐름: useTradeTickSubscription -> resolveTradeTrIds(세션별 TR) -> parseKisRealtimeTickBatch
|
||||
// 시간외 구간(예: H0STOAC0 only 수신)에서 틱이 비는 문제를 방지합니다.
|
||||
if (!isExecutedTick && !isExpectedTick) {
|
||||
return [] as DashboardRealtimeTradeTick[];
|
||||
}
|
||||
|
||||
@@ -110,6 +125,8 @@ export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
|
||||
|
||||
ticks.push({
|
||||
symbol: normalizedExpected,
|
||||
trId: receivedTrId,
|
||||
isExpected: isExpectedTick,
|
||||
tickTime: readString(values, base + TICK_FIELD_INDEX.tickTime),
|
||||
price,
|
||||
change,
|
||||
@@ -197,6 +214,7 @@ export function resolveTradeTrIds(
|
||||
export function resolveOrderBookTrIds(
|
||||
env: "real" | "mock",
|
||||
session: DomesticKisSession,
|
||||
market?: "KOSPI" | "KOSDAQ",
|
||||
) {
|
||||
if (env === "mock") return [ORDERBOOK_TR_ID];
|
||||
|
||||
@@ -218,9 +236,60 @@ export function resolveOrderBookTrIds(
|
||||
]);
|
||||
}
|
||||
|
||||
// 통합장(통합호가) 값이 체결앱과 더 잘 맞는 케이스가 있어
|
||||
// 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 호가 패킷이 실제 표시 가능한 값(호가/잔량/총잔량)을 포함하는지 확인합니다.
|
||||
*/
|
||||
@@ -270,20 +339,31 @@ export function parseKisRealtimeOrderbook(
|
||||
// 시간외(H0STOAA0)는 문서 버전에 따라 9호가/10호가가 혼재할 수 있어
|
||||
// 두 형식을 모두 시도한 뒤 의미 있는 데이터 점수가 더 높은 결과를 선택합니다.
|
||||
if (trId === "H0STOAA0") {
|
||||
const parsedByNineLevels = parseOrderBookByLevelCount(
|
||||
values,
|
||||
normalizedExpected,
|
||||
9,
|
||||
);
|
||||
const parsedByTenLevels = parseOrderBookByLevelCount(
|
||||
values,
|
||||
normalizedExpected,
|
||||
10,
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = [parsedByNineLevels, parsedByTenLevels].filter(
|
||||
(item): item is DashboardStockOrderBookResponse => item !== null,
|
||||
);
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
return pickBestOrderBookPayload(candidates);
|
||||
@@ -411,11 +491,30 @@ function scoreOrderBookPayload(payload: DashboardStockOrderBookResponse) {
|
||||
level.bidSize > 0,
|
||||
).length;
|
||||
|
||||
return (
|
||||
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)
|
||||
);
|
||||
((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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user