차트 수정
This commit is contained in:
@@ -192,21 +192,12 @@ export const useKisWebSocketStore = create<KisWebSocketState>((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<RealtimeCallback>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -31,28 +31,28 @@ export function StockHeader({
|
||||
: "text-foreground";
|
||||
|
||||
return (
|
||||
<div className="bg-white px-3 py-2 dark:bg-brand-900/22 sm:px-4 sm:py-3">
|
||||
<div className="bg-white px-3 py-1.5 dark:bg-brand-900/22 sm:px-4 sm:py-2">
|
||||
{/* ========== STOCK SUMMARY ========== */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-lg font-bold leading-tight text-foreground dark:text-brand-50 sm:text-xl">
|
||||
<h1 className="truncate text-base font-bold leading-tight text-foreground dark:text-brand-50 sm:text-lg">
|
||||
{stock.name}
|
||||
</h1>
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground dark:text-brand-100/70 sm:text-sm">
|
||||
<span className="mt-0.5 block text-[11px] text-muted-foreground dark:text-brand-100/70 sm:text-xs">
|
||||
{stock.symbol}/{stock.market}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={cn("shrink-0 text-right", colorClass)}>
|
||||
<span className="block text-2xl font-bold tracking-tight">{price}</span>
|
||||
<span className="text-xs font-medium sm:text-sm">
|
||||
<span className="block text-xl font-bold tracking-tight sm:text-2xl">{price}</span>
|
||||
<span className="text-[11px] font-medium sm:text-xs">
|
||||
{changeRate}% <span className="ml-1 text-[11px] sm:text-xs">{change}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== STATS ========== */}
|
||||
<div className="mt-2 grid grid-cols-3 gap-2 text-xs md:hidden">
|
||||
<div className="mt-1.5 grid grid-cols-3 gap-2 text-xs md:hidden">
|
||||
<div className="rounded-md bg-muted/40 px-2 py-1.5 dark:border dark:border-brand-800/45 dark:bg-brand-900/25">
|
||||
<p className="text-[11px] text-muted-foreground dark:text-brand-100/70">고가</p>
|
||||
<p className="font-medium text-red-500">{high || "--"}</p>
|
||||
@@ -67,10 +67,10 @@ export function StockHeader({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="mt-2 md:hidden" />
|
||||
<Separator className="mt-1.5 md:hidden" />
|
||||
|
||||
{/* ========== DESKTOP STATS ========== */}
|
||||
<div className="hidden items-center justify-end gap-6 pt-1 text-sm md:flex">
|
||||
<div className="hidden items-center justify-end gap-5 pt-1 text-sm md:flex">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-muted-foreground text-xs dark:text-brand-100/70">고가</span>
|
||||
<span className="font-medium text-red-500">{high || "--"}</span>
|
||||
|
||||
@@ -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 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 flex-col",
|
||||
// Mobile: Allow content to flow naturally with spacing
|
||||
"overflow-visible pb-4 gap-4",
|
||||
// Desktop: Internal scrolling, horizontal layout, no page spacing
|
||||
"xl:overflow-hidden xl:flex-row xl:pb-0 xl:gap-0",
|
||||
"flex-1 min-h-0 overflow-y-auto",
|
||||
"xl:overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{/* Left Column: Chart & Info */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col border-border dark:border-brand-800/45",
|
||||
// Mobile: Fixed height for chart to ensure visibility
|
||||
"h-[320px] flex-none border-b sm:h-[360px]",
|
||||
// Desktop: Fill remaining space, remove bottom border, add right border
|
||||
"xl:flex-1 xl:h-auto xl:min-h-0 xl:min-w-0 xl:border-b-0 xl:border-r",
|
||||
)}
|
||||
<div className="flex min-h-full flex-col xl:h-full xl:min-h-0">
|
||||
{/* ========== CHART SECTION ========== */}
|
||||
<section className="flex-none border-b border-border dark:border-brand-800/45">
|
||||
<div className="flex items-center justify-between gap-2 bg-muted/20 px-3 py-1.5 dark:bg-brand-900/30 sm:px-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold text-foreground dark:text-brand-50 sm:text-sm">
|
||||
실시간 차트
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground dark:text-brand-100/70 sm:text-[11px]">
|
||||
거래 화면 집중을 위해 기본은 접힌 상태입니다.
|
||||
</p>
|
||||
</div>
|
||||
{/* UI 흐름: 차트 토글 버튼 -> onToggleChart 호출 -> TradeDashboardContent의 상태 변경 -> 차트 wrapper 높이 반영 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onToggleChart}
|
||||
className="h-7 gap-1 border-brand-200 bg-white px-2 text-[11px] text-brand-700 hover:bg-brand-50 dark:border-brand-700/55 dark:bg-brand-900/35 dark:text-brand-100 dark:hover:bg-brand-800/35 sm:h-8 sm:px-3 sm:text-xs"
|
||||
aria-expanded={isChartVisible}
|
||||
>
|
||||
<div className="flex-1 min-h-0">{chart}</div>
|
||||
{/* Future: Transaction History / Market Depth can go here */}
|
||||
{isChartVisible ? (
|
||||
<>
|
||||
차트 숨기기 <ChevronUp className="h-3.5 w-3.5" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
차트 보이기 <ChevronDown className="h-3.5 w-3.5" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Order Book & Order Form */}
|
||||
<div className="flex min-h-0 w-full flex-none flex-col bg-background dark:bg-brand-900/12 xl:w-[460px] xl:pr-2 2xl:w-[500px]">
|
||||
{/* Top: Order Book (Hoga) */}
|
||||
<div className="h-[390px] flex-none overflow-hidden border-t border-border dark:border-brand-800/45 sm:h-[430px] xl:min-h-0 xl:flex-1 xl:h-auto xl:border-t-0 xl:border-b">
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden border-t border-border/70 transition-[max-height,opacity] duration-200 dark:border-brand-800/45",
|
||||
isChartVisible ? "max-h-[56vh] opacity-100" : "max-h-0 opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="h-[34vh] min-h-[280px] w-full sm:h-[40vh] xl:h-[34vh] 2xl:h-[38vh]">
|
||||
{chart}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== ORDERBOOK + ORDER SECTION ========== */}
|
||||
<div className="flex flex-1 min-h-0 flex-col xl:flex-row xl:overflow-hidden">
|
||||
<section className="flex min-h-0 flex-col border-b border-border dark:border-brand-800/45 xl:flex-1 xl:border-b-0 xl:border-r">
|
||||
<div className="h-[390px] min-h-0 sm:h-[430px] xl:h-full">
|
||||
{orderBook}
|
||||
</div>
|
||||
{/* Bottom: Order Form */}
|
||||
<div className="flex-none h-auto sm:h-auto xl:h-[380px]">
|
||||
{orderForm}
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-0 flex-col bg-background dark:bg-brand-900/12 xl:w-[430px] 2xl:w-[470px]">
|
||||
<div className="min-h-[320px] xl:h-full">{orderForm}</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -93,6 +97,8 @@ export function TradeDashboardContent({
|
||||
/>
|
||||
}
|
||||
orderForm={<OrderForm stock={selectedStock ?? undefined} />}
|
||||
isChartVisible={isChartVisible}
|
||||
onToggleChart={() => setIsChartVisible((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isLoading && !orderBook) return <OrderBookSkeleton />;
|
||||
if (!orderBook) {
|
||||
if (isLoading && !orderBook && fallbackLevelsFromTick.length === 0) {
|
||||
return <OrderBookSkeleton />;
|
||||
}
|
||||
if (!orderBook && fallbackLevelsFromTick.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
호가 정보를 가져오지 못했습니다.
|
||||
@@ -210,12 +262,17 @@ export function OrderBook({
|
||||
|
||||
{/* ── 일반호가 탭 ── */}
|
||||
<TabsContent value="normal" className="min-h-0 flex-1">
|
||||
<div className="block h-full min-h-0 border-t dark:border-brand-800/45 xl:grid xl:grid-rows-[1fr_190px] xl:overflow-hidden">
|
||||
<div className="block min-h-0 xl:grid xl:grid-cols-[minmax(0,1fr)_168px] xl:overflow-hidden">
|
||||
<div className="flex h-full min-h-0 flex-col border-t dark:border-brand-800/45 xl:grid xl:grid-cols-[minmax(0,1fr)_320px_168px] xl:overflow-hidden">
|
||||
{/* 호가 테이블 */}
|
||||
<div className="min-h-0 xl:border-r dark:border-brand-800/45">
|
||||
<div className="flex min-h-0 flex-col xl:border-r dark:border-brand-800/45">
|
||||
{isTickFallbackActive && (
|
||||
<div className="border-b border-amber-200 bg-amber-50 px-2 py-1 text-[11px] text-amber-700 dark:border-amber-700/40 dark:bg-amber-900/20 dark:text-amber-200">
|
||||
시간외 전용 호가(`H0STOAA0`) 미수신 상태입니다. 체결(`H0UNCNT0`)
|
||||
1호가 기준으로 표시 중입니다.
|
||||
</div>
|
||||
)}
|
||||
<BookHeader />
|
||||
<ScrollArea className="h-[320px] sm:h-[360px] xl:h-[calc(100%-32px)] [&>[data-slot=scroll-area-scrollbar]]:hidden xl:[&>[data-slot=scroll-area-scrollbar]]:flex">
|
||||
<ScrollArea className="min-h-0 flex-1 [&>[data-slot=scroll-area-scrollbar]]:hidden xl:[&>[data-slot=scroll-area-scrollbar]]:flex">
|
||||
{/* 매도호가 */}
|
||||
<BookSideRows rows={askRows} side="ask" maxSize={askMax} />
|
||||
|
||||
@@ -255,8 +312,13 @@ export function OrderBook({
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* 체결 목록: 데스크톱에서는 호가 오른쪽, 모바일에서는 아래 */}
|
||||
<div className="min-h-[220px] border-t border-border/60 dark:border-brand-800/45 xl:min-h-0 xl:border-t-0 xl:border-r">
|
||||
<TradeTape ticks={recentTicks} />
|
||||
</div>
|
||||
|
||||
{/* 우측 요약 패널 */}
|
||||
<div className="hidden xl:block">
|
||||
<div className="hidden xl:block min-h-0">
|
||||
<SummaryPanel
|
||||
orderBook={orderBook}
|
||||
latestTick={latestTick}
|
||||
@@ -267,12 +329,6 @@ export function OrderBook({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 체결 목록 */}
|
||||
<div className="hidden xl:block">
|
||||
<TradeTape ticks={recentTicks} />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── 누적호가 탭 ── */}
|
||||
@@ -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({
|
||||
<div className="min-w-0 border-l bg-muted/10 p-2 text-[11px] dark:border-brand-800/45 dark:bg-brand-900/30">
|
||||
<Row
|
||||
label="실시간"
|
||||
value={orderBook ? "연결됨" : "끊김"}
|
||||
tone={orderBook ? "bid" : undefined}
|
||||
value={orderBook || latestTick ? "연결됨" : "끊김"}
|
||||
tone={orderBook || latestTick ? "bid" : undefined}
|
||||
/>
|
||||
<Row
|
||||
label="거래량"
|
||||
value={fmt(latestTick?.tradeVolume ?? orderBook.anticipatedVolume ?? 0)}
|
||||
value={fmt(latestTick?.tradeVolume ?? orderBook?.anticipatedVolume ?? 0)}
|
||||
/>
|
||||
<Row
|
||||
label="누적거래량"
|
||||
value={fmt(
|
||||
latestTick?.accumulatedVolume ?? orderBook.accumulatedVolume ?? 0,
|
||||
latestTick?.accumulatedVolume ?? orderBook?.accumulatedVolume ?? 0,
|
||||
)}
|
||||
/>
|
||||
<Row
|
||||
@@ -459,12 +515,12 @@ function SummaryPanel({
|
||||
value={
|
||||
latestTick
|
||||
? `${latestTick.tradeStrength.toFixed(2)}%`
|
||||
: orderBook.anticipatedChangeRate !== undefined
|
||||
: orderBook?.anticipatedChangeRate !== undefined
|
||||
? `${orderBook.anticipatedChangeRate.toFixed(2)}%`
|
||||
: "-"
|
||||
}
|
||||
/>
|
||||
<Row label="예상체결가" value={fmt(orderBook.anticipatedPrice ?? 0)} />
|
||||
<Row label="예상체결가" value={fmt(orderBook?.anticipatedPrice ?? 0)} />
|
||||
<Row
|
||||
label="매도1호가"
|
||||
value={latestTick ? fmt(latestTick.askPrice1) : "-"}
|
||||
@@ -546,17 +602,17 @@ function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
|
||||
/** 체결 목록 (Trade Tape) */
|
||||
function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
|
||||
return (
|
||||
<div className="border-t bg-background dark:border-brand-800/45 dark:bg-brand-900/20">
|
||||
<div className="flex h-full min-h-0 flex-col bg-background dark:bg-brand-900/20">
|
||||
<div className="grid h-7 grid-cols-4 border-b bg-muted/20 px-2 text-[11px] font-medium text-muted-foreground dark:border-brand-800/45 dark:bg-brand-900/35 dark:text-brand-100/78">
|
||||
<div className="flex items-center">체결시각</div>
|
||||
<div className="flex items-center justify-end">체결가</div>
|
||||
<div className="flex items-center justify-end">체결량</div>
|
||||
<div className="flex items-center justify-end">체결강도</div>
|
||||
</div>
|
||||
<ScrollArea className="h-[162px]">
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div>
|
||||
{ticks.length === 0 && (
|
||||
<div className="flex h-[160px] items-center justify-center text-xs text-muted-foreground dark:text-brand-100/70">
|
||||
<div className="flex min-h-[160px] items-center justify-center text-xs text-muted-foreground dark:text-brand-100/70">
|
||||
체결 데이터가 아직 없습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -50,7 +50,7 @@ export function TradeSearchSection({
|
||||
onClearHistory,
|
||||
}: TradeSearchSectionProps) {
|
||||
return (
|
||||
<div className="z-30 flex-none border-b bg-background/95 p-4 backdrop-blur-sm dark:border-brand-800/45 dark:bg-brand-900/22">
|
||||
<div className="z-30 flex-none border-b bg-background/95 px-3 py-2 backdrop-blur-sm dark:border-brand-800/45 dark:bg-brand-900/22 sm:px-4">
|
||||
{/* ========== SEARCH SHELL ========== */}
|
||||
<div
|
||||
ref={searchShellRef}
|
||||
|
||||
@@ -150,6 +150,10 @@ export interface DashboardRealtimeTradeTick {
|
||||
tradeStrength: number;
|
||||
askPrice1: number;
|
||||
bidPrice1: number;
|
||||
askSize1: number;
|
||||
bidSize1: number;
|
||||
totalAskSize: number;
|
||||
totalBidSize: number;
|
||||
sellExecutionCount: number;
|
||||
buyExecutionCount: number;
|
||||
netBuyExecutionCount: number;
|
||||
|
||||
@@ -36,6 +36,10 @@ const TICK_FIELD_INDEX = {
|
||||
bidPrice1: 11,
|
||||
tradeVolume: 12,
|
||||
accumulatedVolume: 13,
|
||||
askSize1: 36,
|
||||
bidSize1: 37,
|
||||
totalAskSize: 38,
|
||||
totalBidSize: 39,
|
||||
sellExecutionCount: 15,
|
||||
buyExecutionCount: 16,
|
||||
netBuyExecutionCount: 17,
|
||||
@@ -50,6 +54,7 @@ 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[];
|
||||
@@ -117,6 +122,10 @@ export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
|
||||
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,
|
||||
@@ -191,15 +200,25 @@ export function resolveOrderBookTrIds(
|
||||
) {
|
||||
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]);
|
||||
return uniqueTrIds([
|
||||
ORDERBOOK_TR_ID_OVERTIME,
|
||||
ORDERBOOK_TR_ID_TOTAL,
|
||||
ORDERBOOK_TR_ID,
|
||||
]);
|
||||
}
|
||||
|
||||
if (session === "afterCloseFixedPrice") {
|
||||
return uniqueTrIds([ORDERBOOK_TR_ID, ORDERBOOK_TR_ID_OVERTIME]);
|
||||
return uniqueTrIds([
|
||||
ORDERBOOK_TR_ID_TOTAL,
|
||||
ORDERBOOK_TR_ID,
|
||||
ORDERBOOK_TR_ID_OVERTIME,
|
||||
]);
|
||||
}
|
||||
|
||||
return uniqueTrIds([ORDERBOOK_TR_ID]);
|
||||
return uniqueTrIds([ORDERBOOK_TR_ID, ORDERBOOK_TR_ID_TOTAL]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,71 +261,35 @@ export function parseKisRealtimeOrderbook(
|
||||
}
|
||||
|
||||
const values = parts[3].split("^");
|
||||
// 시간외(H0STOAA0)는 문서 버전에 따라 9레벨/10레벨이 혼재할 수 있어
|
||||
// payload 길이로 레벨 수를 동적으로 판별합니다.
|
||||
const levelCount = trId === "H0STOAA0" ? (values.length >= 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)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user