차트 수정
This commit is contained in:
6
.gemini/settings.json
Normal file
6
.gemini/settings.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"tools": {
|
||||
"approvalMode": "auto_edit",
|
||||
"allowed": ["run_shell_command"]
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
hasKisConfig,
|
||||
normalizeTradingEnv,
|
||||
} from "@/lib/kis/config";
|
||||
import {
|
||||
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
|
||||
parseDomesticKisSession,
|
||||
} from "@/lib/kis/domestic-market-session";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/orderbook/route.ts
|
||||
@@ -38,15 +42,22 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await getDomesticOrderBook(symbol, credentials);
|
||||
const sessionOverride = readSessionOverrideFromHeaders(request.headers);
|
||||
const raw = await getDomesticOrderBook(symbol, credentials, {
|
||||
sessionOverride,
|
||||
});
|
||||
|
||||
const levels = Array.from({ length: 10 }, (_, i) => {
|
||||
const idx = i + 1;
|
||||
return {
|
||||
askPrice: readOrderBookNumber(raw, `askp${idx}`),
|
||||
bidPrice: readOrderBookNumber(raw, `bidp${idx}`),
|
||||
askSize: readOrderBookNumber(raw, `askp_rsqn${idx}`),
|
||||
bidSize: readOrderBookNumber(raw, `bidp_rsqn${idx}`),
|
||||
askPrice: readOrderBookNumber(raw, `askp${idx}`, `ovtm_untp_askp${idx}`),
|
||||
bidPrice: readOrderBookNumber(raw, `bidp${idx}`, `ovtm_untp_bidp${idx}`),
|
||||
askSize: readOrderBookNumber(
|
||||
raw,
|
||||
`askp_rsqn${idx}`,
|
||||
`ovtm_untp_askp_rsqn${idx}`,
|
||||
),
|
||||
bidSize: readOrderBookNumber(raw, ...resolveBidSizeKeys(idx)),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -54,8 +65,20 @@ export async function GET(request: NextRequest) {
|
||||
symbol,
|
||||
source: "kis",
|
||||
levels,
|
||||
totalAskSize: readOrderBookNumber(raw, "total_askp_rsqn"),
|
||||
totalBidSize: readOrderBookNumber(raw, "total_bidp_rsqn"),
|
||||
totalAskSize: readOrderBookNumber(
|
||||
raw,
|
||||
"total_askp_rsqn",
|
||||
"ovtm_untp_total_askp_rsqn",
|
||||
"ovtm_total_askp_rsqn",
|
||||
),
|
||||
totalBidSize: readOrderBookNumber(
|
||||
raw,
|
||||
"total_bidp_rsqn",
|
||||
"ovtm_untp_total_bidp_rsqn",
|
||||
"ovtm_total_bidp_rsqn",
|
||||
),
|
||||
businessHour: readOrderBookString(raw, "bsop_hour", "ovtm_untp_last_hour"),
|
||||
hourClassCode: readOrderBookString(raw, "hour_cls_code"),
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
@@ -88,15 +111,19 @@ function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
|
||||
};
|
||||
}
|
||||
|
||||
function readSessionOverrideFromHeaders(headers: Headers) {
|
||||
if (process.env.NODE_ENV === "production") return null;
|
||||
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);
|
||||
return parseDomesticKisSession(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 호가 응답 필드를 대소문자 모두 허용해 숫자로 읽습니다.
|
||||
* @see app/api/kis/domestic/orderbook/route.ts GET에서 output/output1 키 차이 방어 로직으로 사용합니다.
|
||||
*/
|
||||
function readOrderBookNumber(raw: KisDomesticOrderBookOutput, key: string) {
|
||||
function readOrderBookNumber(raw: KisDomesticOrderBookOutput, ...keys: string[]) {
|
||||
const record = raw as Record<string, unknown>;
|
||||
const direct = record[key];
|
||||
const upper = record[key.toUpperCase()];
|
||||
const value = direct ?? upper ?? "0";
|
||||
const value = resolveOrderBookValue(record, keys) ?? "0";
|
||||
const normalized =
|
||||
typeof value === "string"
|
||||
? value.replaceAll(",", "").trim()
|
||||
@@ -104,3 +131,35 @@ function readOrderBookNumber(raw: KisDomesticOrderBookOutput, key: string) {
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 호가 응답 필드를 문자열로 읽습니다.
|
||||
* @see app/api/kis/domestic/orderbook/route.ts GET 응답 생성 시 businessHour/hourClassCode 추출
|
||||
*/
|
||||
function readOrderBookString(raw: KisDomesticOrderBookOutput, ...keys: string[]) {
|
||||
const record = raw as Record<string, unknown>;
|
||||
const value = resolveOrderBookValue(record, keys);
|
||||
if (value === undefined || value === null) return undefined;
|
||||
const text = String(value).trim();
|
||||
return text.length > 0 ? text : undefined;
|
||||
}
|
||||
|
||||
function resolveOrderBookValue(record: Record<string, unknown>, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
const direct = record[key];
|
||||
if (direct !== undefined && direct !== null) return direct;
|
||||
|
||||
const upper = record[key.toUpperCase()];
|
||||
if (upper !== undefined && upper !== null) return upper;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveBidSizeKeys(index: number) {
|
||||
if (index === 2) {
|
||||
return [`bidp_rsqn${index}`, `ovtm_untp_bidp_rsqn${index}`, "ovtm_untp_bidp_rsqn"];
|
||||
}
|
||||
|
||||
return [`bidp_rsqn${index}`, `ovtm_untp_bidp_rsqn${index}`];
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import type { KisCredentialInput } from "@/lib/kis/config";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import { getDomesticOverview } from "@/lib/kis/domestic";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
|
||||
parseDomesticKisSession,
|
||||
} from "@/lib/kis/domestic-market-session";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/overview/route.ts
|
||||
@@ -38,7 +42,13 @@ export async function GET(request: NextRequest) {
|
||||
const fallbackMeta = KOREAN_STOCK_INDEX.find((item) => item.symbol === symbol);
|
||||
|
||||
try {
|
||||
const overview = await getDomesticOverview(symbol, fallbackMeta, credentials);
|
||||
const sessionOverride = readSessionOverrideFromHeaders(request.headers);
|
||||
const overview = await getDomesticOverview(
|
||||
symbol,
|
||||
fallbackMeta,
|
||||
credentials,
|
||||
{ sessionOverride },
|
||||
);
|
||||
|
||||
const response: DashboardStockOverviewResponse = {
|
||||
stock: overview.stock,
|
||||
@@ -76,3 +86,9 @@ function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
|
||||
tradingEnv,
|
||||
};
|
||||
}
|
||||
|
||||
function readSessionOverrideFromHeaders(headers: Headers) {
|
||||
if (process.env.NODE_ENV === "production") return null;
|
||||
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);
|
||||
return parseDomesticKisSession(raw);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ import type {
|
||||
DashboardStockOverviewResponse,
|
||||
DashboardStockSearchResponse,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import {
|
||||
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
|
||||
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
|
||||
parseDomesticKisSession,
|
||||
} from "@/lib/kis/domestic-market-session";
|
||||
|
||||
/**
|
||||
* 종목 검색 API 호출
|
||||
@@ -51,11 +56,7 @@ export async function fetchStockOverview(
|
||||
`/api/kis/domestic/overview?symbol=${encodeURIComponent(symbol)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-kis-app-key": credentials.appKey,
|
||||
"x-kis-app-secret": credentials.appSecret,
|
||||
"x-kis-trading-env": credentials.tradingEnv,
|
||||
},
|
||||
headers: buildKisRequestHeaders(credentials),
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
@@ -87,12 +88,8 @@ export async function fetchStockOrderBook(
|
||||
`/api/kis/domestic/orderbook?symbol=${encodeURIComponent(symbol)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-kis-app-key": credentials.appKey,
|
||||
"x-kis-app-secret": credentials.appSecret,
|
||||
"x-kis-trading-env": credentials.tradingEnv,
|
||||
},
|
||||
cache: "no-store", // 호가는 실시간성이 중요하므로 항상 최신 데이터 조회
|
||||
headers: buildKisRequestHeaders(credentials),
|
||||
cache: "no-store",
|
||||
signal,
|
||||
},
|
||||
);
|
||||
@@ -127,11 +124,7 @@ export async function fetchStockChart(
|
||||
|
||||
const response = await fetch(`/api/kis/domestic/chart?${query.toString()}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-kis-app-key": credentials.appKey,
|
||||
"x-kis-app-secret": credentials.appSecret,
|
||||
"x-kis-trading-env": credentials.tradingEnv,
|
||||
},
|
||||
headers: buildKisRequestHeaders(credentials),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
@@ -159,12 +152,7 @@ export async function fetchOrderCash(
|
||||
): Promise<DashboardStockCashOrderResponse> {
|
||||
const response = await fetch("/api/kis/domestic/order-cash", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-kis-app-key": credentials.appKey,
|
||||
"x-kis-app-secret": credentials.appSecret,
|
||||
"x-kis-trading-env": credentials.tradingEnv,
|
||||
},
|
||||
headers: buildKisRequestHeaders(credentials, { jsonContentType: true }),
|
||||
body: JSON.stringify(request),
|
||||
cache: "no-store",
|
||||
});
|
||||
@@ -177,3 +165,38 @@ export async function fetchOrderCash(
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function buildKisRequestHeaders(
|
||||
credentials: KisRuntimeCredentials,
|
||||
options?: { jsonContentType?: boolean },
|
||||
) {
|
||||
const headers: Record<string, string> = {
|
||||
"x-kis-app-key": credentials.appKey,
|
||||
"x-kis-app-secret": credentials.appSecret,
|
||||
"x-kis-trading-env": credentials.tradingEnv,
|
||||
};
|
||||
|
||||
if (options?.jsonContentType) {
|
||||
headers["content-type"] = "application/json";
|
||||
}
|
||||
|
||||
const sessionOverride = readSessionOverrideForDev();
|
||||
if (sessionOverride) {
|
||||
headers[DOMESTIC_KIS_SESSION_OVERRIDE_HEADER] = sessionOverride;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function readSessionOverrideForDev() {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(
|
||||
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
|
||||
);
|
||||
return parseDomesticKisSession(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -72,8 +72,7 @@ export function DashboardContainer() {
|
||||
);
|
||||
|
||||
// 1. Trade WebSocket (체결 + 호가 통합)
|
||||
const { latestTick, realtimeCandles, recentTradeTicks } =
|
||||
useKisTradeWebSocket(
|
||||
const { latestTick, recentTradeTicks } = useKisTradeWebSocket(
|
||||
selectedStock?.symbol,
|
||||
verifiedCredentials,
|
||||
isKisVerified,
|
||||
@@ -303,12 +302,9 @@ export function DashboardContainer() {
|
||||
<div className="p-0 h-full flex flex-col">
|
||||
<StockLineChart
|
||||
symbol={selectedStock.symbol}
|
||||
candles={
|
||||
realtimeCandles.length > 0
|
||||
? realtimeCandles
|
||||
: selectedStock.candles
|
||||
}
|
||||
candles={selectedStock.candles}
|
||||
credentials={verifiedCredentials}
|
||||
latestTick={latestTick}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -16,24 +16,28 @@ import { fetchStockChart } from "@/features/dashboard/apis/kis-stock.api";
|
||||
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import type {
|
||||
DashboardChartTimeframe,
|
||||
DashboardRealtimeTradeTick,
|
||||
StockCandlePoint,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type ChartBar,
|
||||
convertCandleToBar,
|
||||
formatKstCrosshairTime,
|
||||
formatKstTickMark,
|
||||
formatPrice,
|
||||
formatSignedPercent,
|
||||
isMinuteTimeframe,
|
||||
mergeBars,
|
||||
normalizeCandles,
|
||||
toRealtimeTickBar,
|
||||
upsertRealtimeBar,
|
||||
} from "./chart-utils";
|
||||
|
||||
const UP_COLOR = "#ef4444";
|
||||
const DOWN_COLOR = "#2563eb";
|
||||
const MINUTE_SYNC_INTERVAL_MS = 5000;
|
||||
const REALTIME_STALE_THRESHOLD_MS = 12000;
|
||||
|
||||
// 분봉 드롭다운 옵션
|
||||
const MINUTE_TIMEFRAMES: Array<{
|
||||
value: DashboardChartTimeframe;
|
||||
label: string;
|
||||
@@ -43,7 +47,6 @@ const MINUTE_TIMEFRAMES: Array<{
|
||||
{ value: "1h", label: "1시간" },
|
||||
];
|
||||
|
||||
// 일봉 이상 개별 버튼
|
||||
const PERIOD_TIMEFRAMES: Array<{
|
||||
value: DashboardChartTimeframe;
|
||||
label: string;
|
||||
@@ -52,23 +55,23 @@ const PERIOD_TIMEFRAMES: Array<{
|
||||
{ value: "1w", label: "주" },
|
||||
];
|
||||
|
||||
// ChartBar 타입은 chart-utils.ts에서 import
|
||||
|
||||
interface StockLineChartProps {
|
||||
symbol?: string;
|
||||
candles: StockCandlePoint[];
|
||||
credentials?: KisRuntimeCredentials | null;
|
||||
latestTick?: DashboardRealtimeTradeTick | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description TradingView 스타일 캔들 차트 + 거래량 + 무한 과거 로딩
|
||||
* @see https://tradingview.github.io/lightweight-charts/tutorials/demos/realtime-updates
|
||||
* @see https://tradingview.github.io/lightweight-charts/tutorials/demos/infinite-history
|
||||
* @description TradingView 스타일 캔들 차트를 렌더링하고, timeframe별 KIS 차트 API를 조회합니다.
|
||||
* @see features/dashboard/apis/kis-stock.api.ts fetchStockChart
|
||||
* @see lib/kis/domestic.ts getDomesticChart
|
||||
*/
|
||||
export function StockLineChart({
|
||||
symbol,
|
||||
candles,
|
||||
credentials,
|
||||
latestTick,
|
||||
}: StockLineChartProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
@@ -83,11 +86,14 @@ export function StockLineChart({
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [isChartReady, setIsChartReady] = useState(false);
|
||||
const lastRealtimeKeyRef = useRef<string>("");
|
||||
const lastRealtimeAppliedAtRef = useRef(0);
|
||||
|
||||
// 복수 이벤트에서 중복 로드를 막기 위한 ref 상태
|
||||
const loadingMoreRef = useRef(false);
|
||||
const loadMoreHandlerRef = useRef<() => Promise<void>>(async () => {});
|
||||
const initialLoadCompleteRef = useRef(false);
|
||||
|
||||
// candles prop을 ref로 관리하여 useEffect 디펜던시에서 제거 (무한 페칭 방지)
|
||||
// API 오류 시 fallback 용도로 유지
|
||||
const latestCandlesRef = useRef(candles);
|
||||
useEffect(() => {
|
||||
latestCandlesRef.current = candles;
|
||||
@@ -97,8 +103,10 @@ export function StockLineChart({
|
||||
const prevClose = bars.length > 1 ? (bars.at(-2)?.close ?? 0) : 0;
|
||||
const change = latest ? latest.close - prevClose : 0;
|
||||
const changeRate = prevClose > 0 ? (change / prevClose) * 100 : 0;
|
||||
|
||||
const renderableBars = useMemo(() => {
|
||||
const dedup = new Map<number, ChartBar>();
|
||||
|
||||
for (const bar of bars) {
|
||||
if (
|
||||
!Number.isFinite(bar.time) ||
|
||||
@@ -110,23 +118,25 @@ export function StockLineChart({
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
dedup.set(bar.time, bar);
|
||||
}
|
||||
|
||||
return [...dedup.values()].sort((a, b) => a.time - b.time);
|
||||
}, [bars]);
|
||||
|
||||
/**
|
||||
* @description lightweight-charts 시리즈 데이터에 안전한 OHLCV 값을 주입합니다.
|
||||
* @see features/dashboard/components/chart/StockLineChart.tsx renderableBars useMemo
|
||||
*/
|
||||
const setSeriesData = useCallback((nextBars: ChartBar[]) => {
|
||||
const candleSeries = candleSeriesRef.current;
|
||||
const volumeSeries = volumeSeriesRef.current;
|
||||
if (!candleSeries || !volumeSeries) return;
|
||||
|
||||
// lightweight-charts는 시간 오름차순/유효 숫자 조건이 깨지면 렌더를 멈출 수 있어
|
||||
// 전달 직전 데이터를 한 번 더 정리합니다.
|
||||
const safeBars = nextBars;
|
||||
|
||||
try {
|
||||
candleSeries.setData(
|
||||
safeBars.map((bar) => ({
|
||||
nextBars.map((bar) => ({
|
||||
time: bar.time,
|
||||
open: bar.open,
|
||||
high: bar.high,
|
||||
@@ -136,7 +146,7 @@ export function StockLineChart({
|
||||
);
|
||||
|
||||
volumeSeries.setData(
|
||||
safeBars.map((bar) => ({
|
||||
nextBars.map((bar) => ({
|
||||
time: bar.time,
|
||||
value: Number.isFinite(bar.volume) ? bar.volume : 0,
|
||||
color:
|
||||
@@ -150,14 +160,16 @@ export function StockLineChart({
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* @description 좌측 스크롤 시 cursor 기반 과거 캔들을 추가 로드합니다.
|
||||
* @see lib/kis/domestic.ts getDomesticChart cursor
|
||||
*/
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
// 분봉은 당일 데이터만 제공되므로 과거 로딩 불가
|
||||
if (isMinuteTimeframe(timeframe)) return;
|
||||
if (!symbol || !credentials || !nextCursor || loadingMoreRef.current)
|
||||
return;
|
||||
if (!symbol || !credentials || !nextCursor || loadingMoreRef.current) return;
|
||||
|
||||
loadingMoreRef.current = true;
|
||||
setIsLoadingMore(true);
|
||||
|
||||
try {
|
||||
const response = await fetchStockChart(
|
||||
symbol,
|
||||
@@ -165,15 +177,16 @@ export function StockLineChart({
|
||||
credentials,
|
||||
nextCursor,
|
||||
);
|
||||
const older = normalizeCandles(response.candles, timeframe);
|
||||
setBars((prev) => mergeBars(older, prev));
|
||||
|
||||
const olderBars = normalizeCandles(response.candles, timeframe);
|
||||
setBars((prev) => mergeBars(olderBars, prev));
|
||||
setNextCursor(response.hasMore ? response.nextCursor : null);
|
||||
} catch (error) {
|
||||
const msg =
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "과거 차트 조회에 실패했습니다.";
|
||||
toast.error(msg);
|
||||
: "과거 차트 데이터를 불러오지 못했습니다.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
loadingMoreRef.current = false;
|
||||
setIsLoadingMore(false);
|
||||
@@ -184,16 +197,18 @@ export function StockLineChart({
|
||||
loadMoreHandlerRef.current = handleLoadMore;
|
||||
}, [handleLoadMore]);
|
||||
|
||||
useEffect(() => {
|
||||
lastRealtimeKeyRef.current = "";
|
||||
lastRealtimeAppliedAtRef.current = 0;
|
||||
}, [symbol, timeframe]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || chartRef.current) return;
|
||||
|
||||
const initialWidth = Math.max(container.clientWidth, 320);
|
||||
const initialHeight = Math.max(container.clientHeight, 340);
|
||||
|
||||
const chart = createChart(container, {
|
||||
width: initialWidth,
|
||||
height: initialHeight,
|
||||
width: Math.max(container.clientWidth, 320),
|
||||
height: Math.max(container.clientHeight, 340),
|
||||
layout: {
|
||||
background: { type: ColorType.Solid, color: "#ffffff" },
|
||||
textColor: "#475569",
|
||||
@@ -201,6 +216,7 @@ export function StockLineChart({
|
||||
},
|
||||
localization: {
|
||||
locale: "ko-KR",
|
||||
timeFormatter: formatKstCrosshairTime,
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: "#e2e8f0",
|
||||
@@ -222,6 +238,7 @@ export function StockLineChart({
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
rightOffset: 2,
|
||||
tickMarkFormatter: formatKstTickMark,
|
||||
},
|
||||
handleScroll: {
|
||||
mouseWheel: true,
|
||||
@@ -260,19 +277,15 @@ export function StockLineChart({
|
||||
borderVisible: false,
|
||||
});
|
||||
|
||||
// 스크롤 디바운스 타이머
|
||||
let scrollTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
let scrollTimeout: number | undefined;
|
||||
chart.timeScale().subscribeVisibleLogicalRangeChange((range) => {
|
||||
if (!range) return;
|
||||
if (!range || !initialLoadCompleteRef.current) return;
|
||||
if (range.from >= 10) return;
|
||||
|
||||
// 분봉은 당일 데이터만 제공되므로 무한 스크롤 비활성화
|
||||
if (range.from < 10 && initialLoadCompleteRef.current) {
|
||||
if (scrollTimeout) clearTimeout(scrollTimeout);
|
||||
scrollTimeout = setTimeout(() => {
|
||||
if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout);
|
||||
scrollTimeout = window.setTimeout(() => {
|
||||
void loadMoreHandlerRef.current();
|
||||
}, 300);
|
||||
}
|
||||
}, 250);
|
||||
});
|
||||
|
||||
chartRef.current = chart;
|
||||
@@ -281,20 +294,22 @@ export function StockLineChart({
|
||||
setIsChartReady(true);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const nextWidth = Math.max(container.clientWidth, 320);
|
||||
const nextHeight = Math.max(container.clientHeight, 340);
|
||||
chart.resize(nextWidth, nextHeight);
|
||||
chart.resize(
|
||||
Math.max(container.clientWidth, 320),
|
||||
Math.max(container.clientHeight, 340),
|
||||
);
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
// 첫 렌더 직후 부모 레이아웃 계산이 끝난 시점에 한 번 더 사이즈를 맞춥니다.
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
const nextWidth = Math.max(container.clientWidth, 320);
|
||||
const nextHeight = Math.max(container.clientHeight, 340);
|
||||
chart.resize(nextWidth, nextHeight);
|
||||
chart.resize(
|
||||
Math.max(container.clientWidth, 320),
|
||||
Math.max(container.clientHeight, 340),
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout);
|
||||
window.cancelAnimationFrame(rafId);
|
||||
resizeObserver.disconnect();
|
||||
chart.remove();
|
||||
@@ -307,6 +322,8 @@ export function StockLineChart({
|
||||
|
||||
useEffect(() => {
|
||||
if (symbol && credentials) return;
|
||||
|
||||
// 인증 전/종목 미선택 상태는 overview 캔들로 fallback
|
||||
setBars(normalizeCandles(candles, "1d"));
|
||||
setNextCursor(null);
|
||||
}, [candles, credentials, symbol]);
|
||||
@@ -314,33 +331,56 @@ export function StockLineChart({
|
||||
useEffect(() => {
|
||||
if (!symbol || !credentials) return;
|
||||
|
||||
// 초기 로딩 보호 플래그 초기화 (타임프레임/종목 변경 시)
|
||||
initialLoadCompleteRef.current = false;
|
||||
|
||||
let disposed = false;
|
||||
|
||||
const load = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetchStockChart(symbol, timeframe, credentials);
|
||||
const firstPage = await fetchStockChart(symbol, timeframe, credentials);
|
||||
if (disposed) return;
|
||||
|
||||
const normalized = normalizeCandles(response.candles, timeframe);
|
||||
setBars(normalized);
|
||||
setNextCursor(response.hasMore ? response.nextCursor : null);
|
||||
let mergedBars = normalizeCandles(firstPage.candles, timeframe);
|
||||
let resolvedNextCursor = firstPage.hasMore ? firstPage.nextCursor : null;
|
||||
|
||||
// 초기 로딩 완료 후 500ms 지연 후 무한 스크롤 활성화
|
||||
// (fitContent 후 range가 0이 되어 즉시 트리거되는 것 방지)
|
||||
setTimeout(() => {
|
||||
if (!disposed) {
|
||||
initialLoadCompleteRef.current = true;
|
||||
// 분봉은 기본 2페이지를 붙여서 "당일만 보이는" 느낌을 줄입니다.
|
||||
if (
|
||||
isMinuteTimeframe(timeframe) &&
|
||||
firstPage.hasMore &&
|
||||
firstPage.nextCursor
|
||||
) {
|
||||
try {
|
||||
const secondPage = await fetchStockChart(
|
||||
symbol,
|
||||
timeframe,
|
||||
credentials,
|
||||
firstPage.nextCursor,
|
||||
);
|
||||
|
||||
const olderBars = normalizeCandles(secondPage.candles, timeframe);
|
||||
mergedBars = mergeBars(olderBars, mergedBars);
|
||||
resolvedNextCursor = secondPage.hasMore ? secondPage.nextCursor : null;
|
||||
} catch {
|
||||
// 2페이지 실패는 치명적이지 않으므로 1페이지 데이터는 유지합니다.
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
setBars(mergedBars);
|
||||
setNextCursor(resolvedNextCursor);
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (!disposed) initialLoadCompleteRef.current = true;
|
||||
}, 350);
|
||||
} catch (error) {
|
||||
if (disposed) return;
|
||||
|
||||
const message =
|
||||
error instanceof Error ? error.message : "차트 조회에 실패했습니다.";
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "차트 조회 중 오류가 발생했습니다.";
|
||||
toast.error(message);
|
||||
// 에러 발생 시 fallback으로 props로 전달된 candles 사용
|
||||
|
||||
setBars(normalizeCandles(latestCandlesRef.current, timeframe));
|
||||
setNextCursor(null);
|
||||
} finally {
|
||||
@@ -349,6 +389,7 @@ export function StockLineChart({
|
||||
};
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
@@ -356,37 +397,84 @@ export function StockLineChart({
|
||||
|
||||
useEffect(() => {
|
||||
if (!isChartReady) return;
|
||||
setSeriesData(renderableBars);
|
||||
|
||||
// 초기 로딩 시에만 fitContent 수행
|
||||
setSeriesData(renderableBars);
|
||||
if (!initialLoadCompleteRef.current && renderableBars.length > 0) {
|
||||
chartRef.current?.timeScale().fitContent();
|
||||
}
|
||||
}, [isChartReady, renderableBars, setSeriesData]);
|
||||
|
||||
const latestRealtime = useMemo(() => candles.at(-1), [candles]);
|
||||
/**
|
||||
* @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다.
|
||||
* @see features/dashboard/hooks/useKisTradeWebSocket.ts latestTick
|
||||
* @see features/dashboard/components/chart/chart-utils.ts toRealtimeTickBar
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!latestRealtime || bars.length === 0) return;
|
||||
if (timeframe === "1w" && !latestRealtime.timestamp && !latestRealtime.time)
|
||||
return;
|
||||
if (!latestTick) return;
|
||||
if (bars.length === 0) return;
|
||||
|
||||
const key = `${latestRealtime.time}-${latestRealtime.price}-${latestRealtime.volume ?? 0}`;
|
||||
if (lastRealtimeKeyRef.current === key) return;
|
||||
lastRealtimeKeyRef.current = key;
|
||||
const dedupeKey = `${timeframe}:${latestTick.tickTime}:${latestTick.price}:${latestTick.tradeVolume}`;
|
||||
if (lastRealtimeKeyRef.current === dedupeKey) return;
|
||||
|
||||
const nextBar = convertCandleToBar(latestRealtime, timeframe);
|
||||
if (!nextBar) return;
|
||||
const realtimeBar = toRealtimeTickBar(latestTick, timeframe);
|
||||
if (!realtimeBar) return;
|
||||
|
||||
setBars((prev) => upsertRealtimeBar(prev, nextBar));
|
||||
}, [bars.length, candles, latestRealtime, timeframe]);
|
||||
lastRealtimeKeyRef.current = dedupeKey;
|
||||
lastRealtimeAppliedAtRef.current = Date.now();
|
||||
setBars((prev) => upsertRealtimeBar(prev, realtimeBar));
|
||||
}, [bars.length, latestTick, timeframe]);
|
||||
|
||||
/**
|
||||
* @description 분봉(1m/30m/1h)에서는 WS 공백 상황을 대비해 최신 페이지를 주기적으로 동기화합니다.
|
||||
* @see features/dashboard/apis/kis-stock.api.ts fetchStockChart
|
||||
* @see lib/kis/domestic.ts getDomesticChart
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!symbol || !credentials) return;
|
||||
if (!isMinuteTimeframe(timeframe)) return;
|
||||
|
||||
let disposed = false;
|
||||
|
||||
const syncLatestMinuteBars = async () => {
|
||||
const now = Date.now();
|
||||
const isRealtimeFresh =
|
||||
now - lastRealtimeAppliedAtRef.current < REALTIME_STALE_THRESHOLD_MS;
|
||||
if (isRealtimeFresh) return;
|
||||
|
||||
try {
|
||||
const response = await fetchStockChart(symbol, timeframe, credentials);
|
||||
if (disposed) return;
|
||||
|
||||
const latestPageBars = normalizeCandles(response.candles, timeframe);
|
||||
const recentBars = latestPageBars.slice(-10);
|
||||
if (recentBars.length === 0) return;
|
||||
|
||||
setBars((prev) => mergeBars(prev, recentBars));
|
||||
} catch {
|
||||
// 폴링 실패는 치명적이지 않으므로 조용히 다음 주기에서 재시도합니다.
|
||||
}
|
||||
};
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
void syncLatestMinuteBars();
|
||||
}, MINUTE_SYNC_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [credentials, symbol, timeframe]);
|
||||
|
||||
// 상태 메시지 결정 (오버레이로 표시)
|
||||
const statusMessage = (() => {
|
||||
if (isLoading && bars.length === 0)
|
||||
if (isLoading && bars.length === 0) {
|
||||
return "차트 데이터를 불러오는 중입니다.";
|
||||
if (bars.length === 0) return "차트 데이터가 없습니다.";
|
||||
if (renderableBars.length === 0)
|
||||
return "차트 데이터 형식이 올바르지 않아 렌더링할 수 없습니다.";
|
||||
}
|
||||
if (bars.length === 0) {
|
||||
return "차트 데이터가 없습니다.";
|
||||
}
|
||||
if (renderableBars.length === 0) {
|
||||
return "차트 데이터 형식이 올바르지 않습니다.";
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
@@ -395,24 +483,24 @@ export function StockLineChart({
|
||||
{/* ========== CHART TOOLBAR ========== */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-2 py-2 sm:px-3">
|
||||
<div className="flex flex-wrap items-center gap-1 text-xs sm:text-sm">
|
||||
{/* 분봉 드롭다운 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMinuteDropdownOpen((v) => !v)}
|
||||
onClick={() => setIsMinuteDropdownOpen((prev) => !prev)}
|
||||
onBlur={() =>
|
||||
setTimeout(() => setIsMinuteDropdownOpen(false), 200)
|
||||
window.setTimeout(() => setIsMinuteDropdownOpen(false), 200)
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded px-2 py-1 text-slate-600 transition-colors hover:bg-slate-100",
|
||||
MINUTE_TIMEFRAMES.some((t) => t.value === timeframe) &&
|
||||
MINUTE_TIMEFRAMES.some((item) => item.value === timeframe) &&
|
||||
"bg-brand-100 font-semibold text-brand-700",
|
||||
)}
|
||||
>
|
||||
{MINUTE_TIMEFRAMES.find((t) => t.value === timeframe)?.label ??
|
||||
"분봉"}
|
||||
{MINUTE_TIMEFRAMES.find((item) => item.value === timeframe)
|
||||
?.label ?? "분봉"}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
{isMinuteDropdownOpen && (
|
||||
<div className="absolute left-0 top-full z-10 mt-1 rounded border border-slate-200 bg-white shadow-lg">
|
||||
{MINUTE_TIMEFRAMES.map((item) => (
|
||||
@@ -436,7 +524,6 @@ export function StockLineChart({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 일/주 버튼 */}
|
||||
{PERIOD_TIMEFRAMES.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
@@ -451,28 +538,27 @@ export function StockLineChart({
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{isLoadingMore && (
|
||||
<span className="ml-2 text-[11px] text-muted-foreground">
|
||||
과거 데이터 로딩중...
|
||||
과거 데이터 로딩 중...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-slate-600 sm:text-xs">
|
||||
O {formatPrice(latest?.open ?? 0)} H {formatPrice(latest?.high ?? 0)}{" "}
|
||||
L {formatPrice(latest?.low ?? 0)} C{" "}
|
||||
O {formatPrice(latest?.open ?? 0)} H {formatPrice(latest?.high ?? 0)} L{" "}
|
||||
{formatPrice(latest?.low ?? 0)} C{" "}
|
||||
<span className={cn(change >= 0 ? "text-red-600" : "text-blue-600")}>
|
||||
{formatPrice(latest?.close ?? 0)} ({formatSignedPercent(changeRate)}
|
||||
)
|
||||
{formatPrice(latest?.close ?? 0)} ({formatSignedPercent(changeRate)})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== CHART BODY ========== */}
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||
{/* 차트 캔버스 컨테이너 - 항상 렌더링 */}
|
||||
<div ref={containerRef} className="h-full w-full" />
|
||||
|
||||
{/* 상태 메시지 오버레이 */}
|
||||
{statusMessage && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/80 text-sm text-muted-foreground">
|
||||
{statusMessage}
|
||||
|
||||
@@ -3,13 +3,53 @@
|
||||
* @description StockLineChart에서 사용하는 유틸리티 함수 모음
|
||||
*/
|
||||
|
||||
import type { UTCTimestamp } from "lightweight-charts";
|
||||
import type {
|
||||
TickMarkType,
|
||||
Time,
|
||||
UTCTimestamp,
|
||||
} from "lightweight-charts";
|
||||
import type {
|
||||
DashboardChartTimeframe,
|
||||
DashboardRealtimeTradeTick,
|
||||
StockCandlePoint,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
const KRW_FORMATTER = new Intl.NumberFormat("ko-KR");
|
||||
const KST_TIME_ZONE = "Asia/Seoul";
|
||||
const KST_TIME_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
const KST_TIME_SECONDS_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
const KST_DATE_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
const KST_MONTH_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
month: "short",
|
||||
});
|
||||
const KST_YEAR_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
year: "numeric",
|
||||
});
|
||||
const KST_CROSSHAIR_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
// ─── 타입 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -186,6 +226,63 @@ export function upsertRealtimeBar(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 실시간 체결 틱을 차트용 ChartBar로 변환합니다. (KST 날짜 + tickTime 기준)
|
||||
* @see features/dashboard/hooks/useKisTradeWebSocket.ts latestTick
|
||||
* @see features/dashboard/components/chart/StockLineChart.tsx 실시간 캔들 반영
|
||||
*/
|
||||
export function toRealtimeTickBar(
|
||||
tick: DashboardRealtimeTradeTick,
|
||||
timeframe: DashboardChartTimeframe,
|
||||
now = new Date(),
|
||||
): ChartBar | null {
|
||||
if (!Number.isFinite(tick.price) || tick.price <= 0) return null;
|
||||
|
||||
const hhmmss = normalizeTickTime(tick.tickTime);
|
||||
if (!hhmmss) return null;
|
||||
|
||||
const ymd = getKstYmd(now);
|
||||
const baseTimestamp = toKstTimestamp(ymd, hhmmss);
|
||||
const alignedTimestamp = alignTimestamp(baseTimestamp, timeframe);
|
||||
const minuteFrame = isMinuteTimeframe(timeframe);
|
||||
|
||||
return {
|
||||
time: alignedTimestamp,
|
||||
open: minuteFrame ? tick.price : Math.max(tick.open, tick.price),
|
||||
high: minuteFrame ? tick.price : Math.max(tick.high, tick.price),
|
||||
low: minuteFrame ? tick.price : Math.min(tick.low || tick.price, tick.price),
|
||||
close: tick.price,
|
||||
volume: minuteFrame
|
||||
? Math.max(tick.tradeVolume, 0)
|
||||
: Math.max(tick.accumulatedVolume, 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description lightweight-charts X축 라벨을 KST 기준으로 강제 포맷합니다.
|
||||
* @see features/dashboard/components/chart/StockLineChart.tsx createChart options.timeScale.tickMarkFormatter
|
||||
*/
|
||||
export function formatKstTickMark(time: Time, tickMarkType: TickMarkType) {
|
||||
const date = toDateFromChartTime(time);
|
||||
if (!date) return null;
|
||||
|
||||
if (tickMarkType === 0) return KST_YEAR_FORMATTER.format(date);
|
||||
if (tickMarkType === 1) return KST_MONTH_FORMATTER.format(date);
|
||||
if (tickMarkType === 2) return KST_DATE_FORMATTER.format(date);
|
||||
if (tickMarkType === 4) return KST_TIME_SECONDS_FORMATTER.format(date);
|
||||
return KST_TIME_FORMATTER.format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description crosshair 시간 라벨을 KST로 포맷합니다.
|
||||
* @see features/dashboard/components/chart/StockLineChart.tsx createChart options.localization.timeFormatter
|
||||
*/
|
||||
export function formatKstCrosshairTime(time: Time) {
|
||||
const date = toDateFromChartTime(time);
|
||||
if (!date) return "";
|
||||
return KST_CROSSHAIR_FORMATTER.format(date);
|
||||
}
|
||||
|
||||
// ─── 포맷터 ───────────────────────────────────────────────
|
||||
|
||||
export function formatPrice(value: number) {
|
||||
@@ -203,3 +300,49 @@ export function formatSignedPercent(value: number) {
|
||||
export function isMinuteTimeframe(tf: DashboardChartTimeframe) {
|
||||
return tf === "1m" || tf === "30m" || tf === "1h";
|
||||
}
|
||||
|
||||
function normalizeTickTime(value?: string) {
|
||||
if (!value) return null;
|
||||
const normalized = value.trim();
|
||||
return /^\d{6}$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function getKstYmd(now = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(now);
|
||||
|
||||
const map = new Map(parts.map((part) => [part.type, part.value]));
|
||||
return `${map.get("year")}${map.get("month")}${map.get("day")}`;
|
||||
}
|
||||
|
||||
function toKstTimestamp(yyyymmdd: string, hhmmss: string) {
|
||||
const y = Number(yyyymmdd.slice(0, 4));
|
||||
const m = Number(yyyymmdd.slice(4, 6));
|
||||
const d = Number(yyyymmdd.slice(6, 8));
|
||||
const hh = Number(hhmmss.slice(0, 2));
|
||||
const mm = Number(hhmmss.slice(2, 4));
|
||||
const ss = Number(hhmmss.slice(4, 6));
|
||||
return Math.floor(Date.UTC(y, m - 1, d, hh - 9, mm, ss) / 1000);
|
||||
}
|
||||
|
||||
function toDateFromChartTime(time: Time) {
|
||||
if (typeof time === "number" && Number.isFinite(time)) {
|
||||
return new Date(time * 1000);
|
||||
}
|
||||
|
||||
if (typeof time === "string") {
|
||||
const parsed = Date.parse(time);
|
||||
return Number.isFinite(parsed) ? new Date(parsed) : null;
|
||||
}
|
||||
|
||||
if (time && typeof time === "object" && "year" in time) {
|
||||
const { year, month, day } = time;
|
||||
return new Date(Date.UTC(year, month - 1, day));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Activity, ShieldCheck } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
||||
@@ -6,53 +6,50 @@ import {
|
||||
import type {
|
||||
DashboardRealtimeTradeTick,
|
||||
DashboardStockOrderBookResponse,
|
||||
StockCandlePoint,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import {
|
||||
appendRealtimeTick,
|
||||
buildKisRealtimeMessage,
|
||||
formatRealtimeTickTime,
|
||||
parseKisRealtimeOrderbook,
|
||||
parseKisRealtimeTickBatch,
|
||||
toTickOrderValue,
|
||||
} from "@/features/dashboard/utils/kis-realtime.utils";
|
||||
import {
|
||||
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
|
||||
resolveDomesticKisSession,
|
||||
shouldUseAfterHoursSinglePriceTr,
|
||||
shouldUseExpectedExecutionTr,
|
||||
type DomesticKisSession,
|
||||
} from "@/lib/kis/domestic-market-session";
|
||||
|
||||
// ─── TR ID 상수 ─────────────────────────────────────────
|
||||
const TRADE_TR_ID = "H0STCNT0"; // 체결 (실전/모의 공통)
|
||||
const TRADE_TR_ID_OVERTIME = "H0STOUP0"; // 시간외 단일가
|
||||
const ORDERBOOK_TR_ID = "H0STASP0"; // 호가 (정규장)
|
||||
const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0"; // 호가 (시간외)
|
||||
|
||||
const TRADE_TR_ID = "H0STCNT0";
|
||||
const TRADE_TR_ID_EXPECTED = "H0STANC0";
|
||||
const TRADE_TR_ID_OVERTIME = "H0STOUP0";
|
||||
const ORDERBOOK_TR_ID = "H0STASP0";
|
||||
const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0";
|
||||
const MAX_TRADE_TICKS = 10;
|
||||
|
||||
// ─── 시간대별 TR ID 선택 ────────────────────────────────
|
||||
|
||||
function isOvertimeHours() {
|
||||
const now = new Date();
|
||||
const t = now.getHours() * 100 + now.getMinutes();
|
||||
return t >= 1600 && t < 1800;
|
||||
}
|
||||
|
||||
function resolveTradeTrId(env: KisRuntimeCredentials["tradingEnv"]) {
|
||||
function resolveTradeTrId(
|
||||
env: KisRuntimeCredentials["tradingEnv"],
|
||||
session: DomesticKisSession,
|
||||
) {
|
||||
if (env === "mock") return TRADE_TR_ID;
|
||||
return isOvertimeHours() ? TRADE_TR_ID_OVERTIME : TRADE_TR_ID;
|
||||
if (shouldUseAfterHoursSinglePriceTr(session)) return TRADE_TR_ID_OVERTIME;
|
||||
if (shouldUseExpectedExecutionTr(session)) return TRADE_TR_ID_EXPECTED;
|
||||
return TRADE_TR_ID;
|
||||
}
|
||||
|
||||
function resolveOrderBookTrId() {
|
||||
return isOvertimeHours() ? ORDERBOOK_TR_ID_OVERTIME : ORDERBOOK_TR_ID;
|
||||
function resolveOrderBookTrId(
|
||||
env: KisRuntimeCredentials["tradingEnv"],
|
||||
session: DomesticKisSession,
|
||||
) {
|
||||
if (env === "mock") return ORDERBOOK_TR_ID;
|
||||
if (shouldUseAfterHoursSinglePriceTr(session)) return ORDERBOOK_TR_ID_OVERTIME;
|
||||
return ORDERBOOK_TR_ID;
|
||||
}
|
||||
|
||||
// ─── 메인 훅 ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 통합 실시간 웹소켓 훅 — 체결(H0STCNT0) + 호가(H0STASP0)를 단일 WS로 수신합니다.
|
||||
*
|
||||
* @param symbol 종목코드
|
||||
* @param credentials KIS 인증 정보
|
||||
* @param isVerified 인증 완료 여부
|
||||
* @param onTick 체결 콜백 (StockHeader 갱신용)
|
||||
* @param options.orderBookSymbol 호가 구독 종목코드
|
||||
* @param options.onOrderBookMessage 호가 수신 콜백
|
||||
* @description KIS 실시간 체결/호가를 단일 WebSocket으로 구독합니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx useKisTradeWebSocket 호출
|
||||
* @see lib/kis/domestic-market-session.ts 장 세션 계산 및 테스트용 override
|
||||
*/
|
||||
export function useKisTradeWebSocket(
|
||||
symbol: string | undefined,
|
||||
@@ -66,45 +63,54 @@ export function useKisTradeWebSocket(
|
||||
) {
|
||||
const [latestTick, setLatestTick] =
|
||||
useState<DashboardRealtimeTradeTick | null>(null);
|
||||
const [realtimeCandles, setRealtimeCandles] = useState<StockCandlePoint[]>(
|
||||
[],
|
||||
);
|
||||
const [recentTradeTicks, setRecentTradeTicks] = useState<
|
||||
DashboardRealtimeTradeTick[]
|
||||
>([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastTickAt, setLastTickAt] = useState<number | null>(null);
|
||||
const [marketSession, setMarketSession] = useState<DomesticKisSession>(() =>
|
||||
resolveSessionInClient(),
|
||||
);
|
||||
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const approvalKeyRef = useRef<string | null>(null);
|
||||
const lastTickOrderRef = useRef<number>(-1);
|
||||
const seenTickRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const trId = credentials ? resolveTradeTrId(credentials.tradingEnv) : null;
|
||||
const obSymbol = options?.orderBookSymbol;
|
||||
const onOrderBookMsg = options?.onOrderBookMessage;
|
||||
const obTrId = obSymbol ? resolveOrderBookTrId() : null;
|
||||
const realtimeTrId = credentials
|
||||
? resolveTradeTrId(credentials.tradingEnv, marketSession)
|
||||
: TRADE_TR_ID;
|
||||
|
||||
// 8초간 데이터 없을 시 안내 메시지
|
||||
// KST 장 세션을 주기적으로 재평가합니다.
|
||||
useEffect(() => {
|
||||
const timerId = window.setInterval(() => {
|
||||
const nextSession = resolveSessionInClient();
|
||||
setMarketSession((prev) => (prev === nextSession ? prev : nextSession));
|
||||
}, 30_000);
|
||||
|
||||
return () => window.clearInterval(timerId);
|
||||
}, []);
|
||||
|
||||
// 연결은 되었는데 체결이 오래 안 들어오는 경우 안내합니다.
|
||||
useEffect(() => {
|
||||
if (!isConnected || lastTickAt) return;
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setError(
|
||||
"실시간 연결은 되었지만 체결 데이터가 없습니다. 장중(09:00~15:30)인지 확인해 주세요.",
|
||||
"실시간 연결은 되었지만 체결 데이터가 없습니다. 장 시간(한국시간) 여부를 확인해 주세요.",
|
||||
);
|
||||
}, 8000);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isConnected, lastTickAt]);
|
||||
|
||||
// ─── 웹소켓 연결 ─────────────────────────────────────
|
||||
useEffect(() => {
|
||||
setLatestTick(null);
|
||||
setRealtimeCandles([]);
|
||||
setRecentTradeTicks([]);
|
||||
setError(null);
|
||||
setLastTickAt(null);
|
||||
seenTickRef.current.clear();
|
||||
|
||||
if (!symbol || !isVerified || !credentials) {
|
||||
@@ -117,7 +123,11 @@ export function useKisTradeWebSocket(
|
||||
|
||||
let disposed = false;
|
||||
let socket: WebSocket | null = null;
|
||||
const currentTrId = resolveTradeTrId(credentials.tradingEnv);
|
||||
|
||||
const tradeTrId = resolveTradeTrId(credentials.tradingEnv, marketSession);
|
||||
const orderBookTrId = obSymbol
|
||||
? resolveOrderBookTrId(credentials.tradingEnv, marketSession)
|
||||
: null;
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
@@ -128,18 +138,20 @@ export function useKisTradeWebSocket(
|
||||
.getState()
|
||||
.getOrFetchApprovalKey();
|
||||
|
||||
if (!approvalKey) throw new Error("웹소켓 승인키 발급에 실패했습니다.");
|
||||
if (disposed) return;
|
||||
if (!approvalKey) {
|
||||
throw new Error("웹소켓 승인키 발급에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (disposed) return;
|
||||
approvalKeyRef.current = approvalKey;
|
||||
|
||||
const wsBase =
|
||||
process.env.NEXT_PUBLIC_KIS_WS_URL ||
|
||||
"ws://ops.koreainvestment.com:21000";
|
||||
socket = new WebSocket(`${wsBase}/tryitout/${currentTrId}`);
|
||||
|
||||
socket = new WebSocket(`${wsBase}/tryitout/${tradeTrId}`);
|
||||
socketRef.current = socket;
|
||||
|
||||
// ── onopen: 체결 + 호가 구독 ──
|
||||
socket.onopen = () => {
|
||||
if (disposed || !approvalKeyRef.current) return;
|
||||
|
||||
@@ -148,19 +160,19 @@ export function useKisTradeWebSocket(
|
||||
buildKisRealtimeMessage(
|
||||
approvalKeyRef.current,
|
||||
symbol,
|
||||
currentTrId,
|
||||
tradeTrId,
|
||||
"1",
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (obSymbol && obTrId) {
|
||||
if (obSymbol && orderBookTrId) {
|
||||
socket?.send(
|
||||
JSON.stringify(
|
||||
buildKisRealtimeMessage(
|
||||
approvalKeyRef.current,
|
||||
obSymbol,
|
||||
obTrId,
|
||||
orderBookTrId,
|
||||
"1",
|
||||
),
|
||||
),
|
||||
@@ -170,53 +182,35 @@ export function useKisTradeWebSocket(
|
||||
setIsConnected(true);
|
||||
};
|
||||
|
||||
// ── onmessage: TR ID 기반 분기 ──
|
||||
socket.onmessage = (event) => {
|
||||
if (disposed || typeof event.data !== "string") return;
|
||||
|
||||
// 호가 메시지 확인
|
||||
if (obSymbol && onOrderBookMsg) {
|
||||
const ob = parseKisRealtimeOrderbook(event.data, obSymbol);
|
||||
if (ob) {
|
||||
if (credentials) ob.tradingEnv = credentials.tradingEnv;
|
||||
onOrderBookMsg(ob);
|
||||
const orderBook = parseKisRealtimeOrderbook(event.data, obSymbol);
|
||||
if (orderBook) {
|
||||
orderBook.tradingEnv = credentials.tradingEnv;
|
||||
onOrderBookMsg(orderBook);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 체결 메시지 파싱
|
||||
const ticks = parseKisRealtimeTickBatch(event.data, symbol);
|
||||
if (ticks.length === 0) return;
|
||||
|
||||
// 중복 제거 (TradeTape용)
|
||||
const meaningful = ticks.filter((t) => t.tradeVolume > 0);
|
||||
const deduped = meaningful.filter((t) => {
|
||||
const key = `${t.tickTime}-${t.price}-${t.tradeVolume}`;
|
||||
const meaningfulTicks = ticks.filter((tick) => tick.tradeVolume > 0);
|
||||
const dedupedTicks = meaningfulTicks.filter((tick) => {
|
||||
const key = `${tick.tickTime}-${tick.price}-${tick.tradeVolume}`;
|
||||
if (seenTickRef.current.has(key)) return false;
|
||||
seenTickRef.current.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
// 최신 틱 → Header
|
||||
const latest = ticks[ticks.length - 1];
|
||||
setLatestTick(latest);
|
||||
|
||||
// 캔들 → Chart
|
||||
const order = toTickOrderValue(latest.tickTime);
|
||||
if (order > 0 && lastTickOrderRef.current <= order) {
|
||||
lastTickOrderRef.current = order;
|
||||
setRealtimeCandles((prev) =>
|
||||
appendRealtimeTick(prev, {
|
||||
time: formatRealtimeTickTime(latest.tickTime),
|
||||
price: latest.price,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 체결 테이프
|
||||
if (deduped.length > 0) {
|
||||
if (dedupedTicks.length > 0) {
|
||||
setRecentTradeTicks((prev) =>
|
||||
[...deduped.reverse(), ...prev].slice(0, MAX_TRADE_TICKS),
|
||||
[...dedupedTicks.reverse(), ...prev].slice(0, MAX_TRADE_TICKS),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -228,11 +222,13 @@ export function useKisTradeWebSocket(
|
||||
socket.onerror = () => {
|
||||
if (!disposed) setIsConnected(false);
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (!disposed) setIsConnected(false);
|
||||
};
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
@@ -245,7 +241,6 @@ export function useKisTradeWebSocket(
|
||||
void connect();
|
||||
const seenRef = seenTickRef.current;
|
||||
|
||||
// ── cleanup: 구독 해제 ──
|
||||
return () => {
|
||||
disposed = true;
|
||||
setIsConnected(false);
|
||||
@@ -253,13 +248,14 @@ export function useKisTradeWebSocket(
|
||||
const key = approvalKeyRef.current;
|
||||
if (socket?.readyState === WebSocket.OPEN && key) {
|
||||
socket.send(
|
||||
JSON.stringify(
|
||||
buildKisRealtimeMessage(key, symbol, currentTrId, "2"),
|
||||
),
|
||||
JSON.stringify(buildKisRealtimeMessage(key, symbol, tradeTrId, "2")),
|
||||
);
|
||||
if (obSymbol && obTrId) {
|
||||
|
||||
if (obSymbol && orderBookTrId) {
|
||||
socket.send(
|
||||
JSON.stringify(buildKisRealtimeMessage(key, obSymbol, obTrId, "2")),
|
||||
JSON.stringify(
|
||||
buildKisRealtimeMessage(key, obSymbol, orderBookTrId, "2"),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -270,22 +266,36 @@ export function useKisTradeWebSocket(
|
||||
seenRef.clear();
|
||||
};
|
||||
}, [
|
||||
isVerified,
|
||||
symbol,
|
||||
isVerified,
|
||||
credentials,
|
||||
marketSession,
|
||||
onTick,
|
||||
obSymbol,
|
||||
obTrId,
|
||||
onOrderBookMsg,
|
||||
]);
|
||||
|
||||
return {
|
||||
latestTick,
|
||||
realtimeCandles,
|
||||
recentTradeTicks,
|
||||
isConnected,
|
||||
error,
|
||||
lastTickAt,
|
||||
realtimeTrId: trId ?? TRADE_TR_ID,
|
||||
realtimeTrId,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSessionInClient() {
|
||||
if (typeof window === "undefined") {
|
||||
return resolveDomesticKisSession();
|
||||
}
|
||||
|
||||
try {
|
||||
const override = window.localStorage.getItem(
|
||||
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
|
||||
);
|
||||
return resolveDomesticKisSession(override);
|
||||
} catch {
|
||||
return resolveDomesticKisSession();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,47 +57,33 @@ export function useStockOverview() {
|
||||
[],
|
||||
);
|
||||
|
||||
// 실시간 체결 데이터 수신 시 헤더/차트 기준 가격을 갱신합니다.
|
||||
/**
|
||||
* 실시간 체결 수신 시 헤더/주요 시세 상태만 갱신합니다.
|
||||
* 차트 캔들은 StockLineChart 내부 API 응답을 기준으로 유지합니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx useKisTradeWebSocket onTick 전달
|
||||
* @see features/dashboard/components/chart/StockLineChart.tsx 차트 데이터 fetchStockChart 기준 렌더링
|
||||
*/
|
||||
const updateRealtimeTradeTick = useCallback(
|
||||
(tick: DashboardRealtimeTradeTick) => {
|
||||
setSelectedStock((prev) => {
|
||||
if (!prev) return prev;
|
||||
const { price, accumulatedVolume, change, changeRate, tickTime } = tick;
|
||||
const pointTime =
|
||||
tickTime && tickTime.length === 6
|
||||
? `${tickTime.slice(0, 2)}:${tickTime.slice(2, 4)}`
|
||||
: "실시간";
|
||||
|
||||
const { price, accumulatedVolume, change, changeRate } = tick;
|
||||
const nextChange = change;
|
||||
const nextChangeRate = Number.isFinite(changeRate)
|
||||
? changeRate
|
||||
: prev.prevClose > 0
|
||||
? (nextChange / prev.prevClose) * 100
|
||||
: prev.changeRate;
|
||||
const nextHigh = prev.high > 0 ? Math.max(prev.high, price) : price;
|
||||
const nextLow = prev.low > 0 ? Math.min(prev.low, price) : price;
|
||||
const nextCandles =
|
||||
prev.candles.length > 0 &&
|
||||
prev.candles[prev.candles.length - 1]?.time === pointTime
|
||||
? [
|
||||
...prev.candles.slice(0, -1),
|
||||
{
|
||||
...prev.candles[prev.candles.length - 1],
|
||||
time: pointTime,
|
||||
price,
|
||||
},
|
||||
]
|
||||
: [...prev.candles, { time: pointTime, price }].slice(-80);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
currentPrice: price,
|
||||
change: nextChange,
|
||||
changeRate: nextChangeRate,
|
||||
high: nextHigh,
|
||||
low: nextLow,
|
||||
high: prev.high > 0 ? Math.max(prev.high, price) : price,
|
||||
low: prev.low > 0 ? Math.min(prev.low, price) : price,
|
||||
volume: accumulatedVolume > 0 ? accumulatedVolume : prev.volume,
|
||||
candles: nextCandles,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import type {
|
||||
DashboardRealtimeTradeTick,
|
||||
DashboardStockOrderBookResponse,
|
||||
StockCandlePoint,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
const REALTIME_SIGN_NEGATIVE = new Set(["4", "5"]);
|
||||
const ALLOWED_REALTIME_TRADE_TR_IDS = new Set([
|
||||
"H0STCNT0",
|
||||
"H0STANC0",
|
||||
"H0STOUP0",
|
||||
"H0STOAC0",
|
||||
]);
|
||||
|
||||
const TICK_FIELD_INDEX = {
|
||||
symbol: 0,
|
||||
@@ -27,7 +32,7 @@ const TICK_FIELD_INDEX = {
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* KIS 실시간 구독/해제 웹소켓 메시지를 생성합니다.
|
||||
* KIS ?ㅼ떆媛?援щ룆/?댁젣 ?뱀냼耳?硫붿떆吏瑜??앹꽦?⑸땲??
|
||||
*/
|
||||
export function buildKisRealtimeMessage(
|
||||
approvalKey: string,
|
||||
@@ -52,9 +57,9 @@ export function buildKisRealtimeMessage(
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 체결 스트림(raw)을 배열 단위로 파싱합니다.
|
||||
* - 배치 전송(복수 틱)일 때도 모든 틱을 추출
|
||||
* - 심볼 불일치/가격 0 이하 데이터는 제외
|
||||
* ?ㅼ떆媛?泥닿껐 ?ㅽ듃由?raw)??諛곗뿴 ?⑥쐞濡??뚯떛?⑸땲??
|
||||
* - 諛곗튂 ?꾩넚(蹂듭닔 ?????뚮룄 紐⑤뱺 ?깆쓣 異붿텧
|
||||
* - ?щ낵 遺덉씪移?媛寃?0 ?댄븯 ?곗씠?곕뒗 ?쒖쇅
|
||||
*/
|
||||
export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
|
||||
if (!/^([01])\|/.test(raw)) return [] as DashboardRealtimeTradeTick[];
|
||||
@@ -62,10 +67,9 @@ export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
|
||||
const parts = raw.split("|");
|
||||
if (parts.length < 4) return [] as DashboardRealtimeTradeTick[];
|
||||
|
||||
// TR ID Check: Allow H0STCNT0 (Real/Mock) or H0STOUP0 (Overtime)
|
||||
// TR ID check: regular tick / expected tick / after-hours tick.
|
||||
const receivedTrId = parts[1];
|
||||
if (receivedTrId !== "H0STCNT0" && receivedTrId !== "H0STOUP0") {
|
||||
// console.warn("[KisRealtime] Unknown TR ID for Trade Tick:", receivedTrId);
|
||||
if (!ALLOWED_REALTIME_TRADE_TR_IDS.has(receivedTrId)) {
|
||||
return [] as DashboardRealtimeTradeTick[];
|
||||
}
|
||||
|
||||
@@ -147,32 +151,8 @@ export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
|
||||
return ticks;
|
||||
}
|
||||
|
||||
export function formatRealtimeTickTime(hhmmss?: string) {
|
||||
if (!hhmmss || hhmmss.length !== 6) return "실시간";
|
||||
return `${hhmmss.slice(0, 2)}:${hhmmss.slice(2, 4)}:${hhmmss.slice(4, 6)}`;
|
||||
}
|
||||
|
||||
export function appendRealtimeTick(
|
||||
prev: StockCandlePoint[],
|
||||
next: StockCandlePoint,
|
||||
) {
|
||||
if (prev.length === 0) return [next];
|
||||
|
||||
const last = prev[prev.length - 1];
|
||||
if (last.time === next.time) {
|
||||
return [...prev.slice(0, -1), next];
|
||||
}
|
||||
|
||||
return [...prev, next].slice(-80);
|
||||
}
|
||||
|
||||
export function toTickOrderValue(hhmmss?: string) {
|
||||
if (!hhmmss || !/^\d{6}$/.test(hhmmss)) return -1;
|
||||
return Number(hhmmss);
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 실시간 호가(H0STASP0/H0UNASP0/H0STOAA0)를 OrderBook 구조로 파싱합니다.
|
||||
* KIS ?ㅼ떆媛??멸?(H0STASP0/H0UNASP0/H0STOAA0)瑜?OrderBook 援ъ“濡??뚯떛?⑸땲??
|
||||
*/
|
||||
export function parseKisRealtimeOrderbook(
|
||||
raw: string,
|
||||
@@ -244,8 +224,8 @@ export function parseKisRealtimeOrderbook(
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 국내 종목코드 비교를 위해 접두 문자를 제거하고 6자리 코드로 정규화합니다.
|
||||
* @see features/dashboard/utils/kis-realtime.utils.ts parseKisRealtimeOrderbook 종목 매칭 비교
|
||||
* @description 援?궡 醫낅ぉ肄붾뱶 鍮꾧탳瑜??꾪빐 ?묐몢 臾몄옄瑜??쒓굅?섍퀬 6?먮━ 肄붾뱶濡??뺢퇋?뷀빀?덈떎.
|
||||
* @see features/dashboard/utils/kis-realtime.utils.ts parseKisRealtimeOrderbook 醫낅ぉ 留ㅼ묶 鍮꾧탳
|
||||
*/
|
||||
function normalizeDomesticSymbol(value: string) {
|
||||
const trimmed = value.trim();
|
||||
|
||||
176
lib/kis/domestic-market-session.ts
Normal file
176
lib/kis/domestic-market-session.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* @file lib/kis/domestic-market-session.ts
|
||||
* @description KRX market-session helpers based on KST (Asia/Seoul)
|
||||
*/
|
||||
|
||||
export type DomesticKisSession =
|
||||
| "openAuction"
|
||||
| "regular"
|
||||
| "closeAuction"
|
||||
| "afterCloseFixedPrice"
|
||||
| "afterHoursSinglePrice"
|
||||
| "closed";
|
||||
|
||||
export const DOMESTIC_KIS_SESSION_OVERRIDE_HEADER = "x-kis-session-override";
|
||||
export const DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY =
|
||||
"KIS_SESSION_OVERRIDE";
|
||||
|
||||
const OPEN_AUCTION_START_MINUTES = 8 * 60 + 30; // 08:30
|
||||
const OPEN_AUCTION_END_MINUTES = 9 * 60; // 09:00
|
||||
const REGULAR_START_MINUTES = 9 * 60; // 09:00
|
||||
const REGULAR_END_MINUTES = 15 * 60 + 20; // 15:20
|
||||
const CLOSE_AUCTION_START_MINUTES = 15 * 60 + 20; // 15:20
|
||||
const CLOSE_AUCTION_END_MINUTES = 15 * 60 + 30; // 15:30
|
||||
const AFTER_CLOSE_FIXED_START_MINUTES = 15 * 60 + 40; // 15:40
|
||||
const AFTER_CLOSE_FIXED_END_MINUTES = 16 * 60; // 16:00
|
||||
const AFTER_HOURS_SINGLE_START_MINUTES = 16 * 60; // 16:00
|
||||
const AFTER_HOURS_SINGLE_END_MINUTES = 18 * 60; // 18:00
|
||||
|
||||
/**
|
||||
* @description Converts external string to strict session enum.
|
||||
* @see lib/kis/domestic.ts getDomesticOrderBook
|
||||
* @see features/dashboard/hooks/useKisTradeWebSocket.ts resolveSessionInClient
|
||||
*/
|
||||
export function parseDomesticKisSession(value?: string | null) {
|
||||
if (!value) return null;
|
||||
|
||||
const normalized = value.trim();
|
||||
if (!normalized) return null;
|
||||
|
||||
const allowed: DomesticKisSession[] = [
|
||||
"openAuction",
|
||||
"regular",
|
||||
"closeAuction",
|
||||
"afterCloseFixedPrice",
|
||||
"afterHoursSinglePrice",
|
||||
"closed",
|
||||
];
|
||||
|
||||
return allowed.includes(normalized as DomesticKisSession)
|
||||
? (normalized as DomesticKisSession)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Returns current session in KST.
|
||||
* @see features/dashboard/hooks/useKisTradeWebSocket.ts WebSocket TR switching
|
||||
* @see lib/kis/domestic.ts REST orderbook source switching
|
||||
*/
|
||||
export function getDomesticKisSessionInKst(now = new Date()): DomesticKisSession {
|
||||
const { weekday, totalMinutes } = toKstWeekdayAndMinutes(now);
|
||||
|
||||
if (weekday === "Sat" || weekday === "Sun") {
|
||||
return "closed";
|
||||
}
|
||||
|
||||
if (
|
||||
totalMinutes >= OPEN_AUCTION_START_MINUTES &&
|
||||
totalMinutes < OPEN_AUCTION_END_MINUTES
|
||||
) {
|
||||
return "openAuction";
|
||||
}
|
||||
|
||||
if (
|
||||
totalMinutes >= REGULAR_START_MINUTES &&
|
||||
totalMinutes < REGULAR_END_MINUTES
|
||||
) {
|
||||
return "regular";
|
||||
}
|
||||
|
||||
if (
|
||||
totalMinutes >= CLOSE_AUCTION_START_MINUTES &&
|
||||
totalMinutes < CLOSE_AUCTION_END_MINUTES
|
||||
) {
|
||||
return "closeAuction";
|
||||
}
|
||||
|
||||
if (
|
||||
totalMinutes >= AFTER_CLOSE_FIXED_START_MINUTES &&
|
||||
totalMinutes < AFTER_CLOSE_FIXED_END_MINUTES
|
||||
) {
|
||||
return "afterCloseFixedPrice";
|
||||
}
|
||||
|
||||
if (
|
||||
totalMinutes >= AFTER_HOURS_SINGLE_START_MINUTES &&
|
||||
totalMinutes < AFTER_HOURS_SINGLE_END_MINUTES
|
||||
) {
|
||||
return "afterHoursSinglePrice";
|
||||
}
|
||||
|
||||
return "closed";
|
||||
}
|
||||
|
||||
/**
|
||||
* @description If override is valid, use it. Otherwise use real KST time.
|
||||
* @see app/api/kis/domestic/orderbook/route.ts session override header
|
||||
* @see features/dashboard/hooks/useKisTradeWebSocket.ts localStorage override
|
||||
*/
|
||||
export function resolveDomesticKisSession(
|
||||
override?: string | null,
|
||||
now = new Date(),
|
||||
) {
|
||||
return parseDomesticKisSession(override) ?? getDomesticKisSessionInKst(now);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Maps detailed KIS session to dashboard phase.
|
||||
* @see lib/kis/domestic.ts getDomesticOverview
|
||||
*/
|
||||
export function mapDomesticKisSessionToMarketPhase(
|
||||
session: DomesticKisSession,
|
||||
): "regular" | "afterHours" {
|
||||
if (
|
||||
session === "regular" ||
|
||||
session === "openAuction" ||
|
||||
session === "closeAuction"
|
||||
) {
|
||||
return "regular";
|
||||
}
|
||||
|
||||
return "afterHours";
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Whether orderbook should use overtime REST API.
|
||||
* @see lib/kis/domestic.ts getDomesticOrderBook
|
||||
*/
|
||||
export function shouldUseOvertimeOrderBookApi(session: DomesticKisSession) {
|
||||
return (
|
||||
session === "afterCloseFixedPrice" || session === "afterHoursSinglePrice"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Whether trade tick should use expected-execution TR.
|
||||
* @see features/dashboard/hooks/useKisTradeWebSocket.ts resolveTradeTrId
|
||||
*/
|
||||
export function shouldUseExpectedExecutionTr(session: DomesticKisSession) {
|
||||
return session === "openAuction" || session === "closeAuction";
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Whether trade tick/orderbook should use after-hours single-price TR.
|
||||
* @see features/dashboard/hooks/useKisTradeWebSocket.ts resolveTradeTrId
|
||||
*/
|
||||
export function shouldUseAfterHoursSinglePriceTr(session: DomesticKisSession) {
|
||||
return session === "afterHoursSinglePrice";
|
||||
}
|
||||
|
||||
function toKstWeekdayAndMinutes(now: Date) {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Asia/Seoul",
|
||||
weekday: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(now);
|
||||
|
||||
const partMap = new Map(parts.map((part) => [part.type, part.value]));
|
||||
const weekday = partMap.get("weekday") ?? "Sun";
|
||||
const hour = Number(partMap.get("hour") ?? "0");
|
||||
const minute = Number(partMap.get("minute") ?? "0");
|
||||
const totalMinutes = hour * 60 + minute;
|
||||
|
||||
return { weekday, totalMinutes };
|
||||
}
|
||||
@@ -5,6 +5,11 @@ import type {
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||
import { kisGet } from "@/lib/kis/client";
|
||||
import {
|
||||
mapDomesticKisSessionToMarketPhase,
|
||||
resolveDomesticKisSession,
|
||||
shouldUseOvertimeOrderBookApi,
|
||||
} from "@/lib/kis/domestic-market-session";
|
||||
|
||||
/**
|
||||
* @file lib/kis/domestic.ts
|
||||
@@ -129,6 +134,10 @@ interface DomesticOverviewResult {
|
||||
marketPhase: DomesticMarketPhase;
|
||||
}
|
||||
|
||||
interface DomesticSessionAwareOptions {
|
||||
sessionOverride?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 국내주식 현재가 조회
|
||||
* @param symbol 6자리 종목코드
|
||||
@@ -238,10 +247,18 @@ export async function getDomesticOvertimePrice(
|
||||
export async function getDomesticOrderBook(
|
||||
symbol: string,
|
||||
credentials?: KisCredentialInput,
|
||||
options?: DomesticSessionAwareOptions,
|
||||
) {
|
||||
const session = resolveDomesticKisSession(options?.sessionOverride);
|
||||
const useOvertimeApi = shouldUseOvertimeOrderBookApi(session);
|
||||
const apiPath = useOvertimeApi
|
||||
? "/uapi/domestic-stock/v1/quotations/inquire-overtime-asking-price"
|
||||
: "/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn";
|
||||
const trId = useOvertimeApi ? "FHPST02300400" : "FHKST01010200";
|
||||
|
||||
const response = await kisGet<KisDomesticOrderBookOutput>(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn",
|
||||
"FHKST01010200",
|
||||
apiPath,
|
||||
trId,
|
||||
{
|
||||
FID_COND_MRKT_DIV_CODE: resolvePriceMarketDivCode(),
|
||||
FID_INPUT_ISCD: symbol,
|
||||
@@ -271,8 +288,12 @@ export async function getDomesticOverview(
|
||||
symbol: string,
|
||||
fallbackMeta?: DashboardStockFallbackMeta,
|
||||
credentials?: KisCredentialInput,
|
||||
options?: DomesticSessionAwareOptions,
|
||||
): Promise<DomesticOverviewResult> {
|
||||
const marketPhase = getDomesticMarketPhaseInKst();
|
||||
const marketPhase = getDomesticMarketPhaseInKst(
|
||||
new Date(),
|
||||
options?.sessionOverride,
|
||||
);
|
||||
const emptyQuote: KisDomesticQuoteOutput = {};
|
||||
const emptyDaily: KisDomesticDailyPriceOutput[] = [];
|
||||
const emptyCcnl: KisDomesticCcnlOutput = {};
|
||||
@@ -375,7 +396,7 @@ export async function getDomesticOverview(
|
||||
|
||||
function toNumber(value?: string) {
|
||||
if (!value) return 0;
|
||||
const normalized = value.replaceAll(",", "").trim();
|
||||
const normalized = value.replace(/,/g, "").trim();
|
||||
if (!normalized) return 0;
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
@@ -383,7 +404,7 @@ function toNumber(value?: string) {
|
||||
|
||||
function toOptionalNumber(value?: string) {
|
||||
if (!value) return undefined;
|
||||
const normalized = value.replaceAll(",", "").trim();
|
||||
const normalized = value.replace(/,/g, "").trim();
|
||||
if (!normalized) return undefined;
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
@@ -454,24 +475,13 @@ function formatDate(date: string) {
|
||||
return `${date.slice(4, 6)}/${date.slice(6, 8)}`;
|
||||
}
|
||||
|
||||
function getDomesticMarketPhaseInKst(now = new Date()): DomesticMarketPhase {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Asia/Seoul",
|
||||
weekday: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(now);
|
||||
|
||||
const partMap = new Map(parts.map((part) => [part.type, part.value]));
|
||||
const weekday = partMap.get("weekday");
|
||||
const hour = Number(partMap.get("hour") ?? "0");
|
||||
const minute = Number(partMap.get("minute") ?? "0");
|
||||
const totalMinutes = hour * 60 + minute;
|
||||
|
||||
if (weekday === "Sat" || weekday === "Sun") return "afterHours";
|
||||
if (totalMinutes >= 9 * 60 && totalMinutes < 15 * 60 + 30) return "regular";
|
||||
return "afterHours";
|
||||
function getDomesticMarketPhaseInKst(
|
||||
now = new Date(),
|
||||
sessionOverride?: string | null,
|
||||
): DomesticMarketPhase {
|
||||
return mapDomesticKisSessionToMarketPhase(
|
||||
resolveDomesticKisSession(sessionOverride, now),
|
||||
);
|
||||
}
|
||||
|
||||
function firstDefinedNumber(...values: Array<number | undefined>) {
|
||||
@@ -629,7 +639,7 @@ function mergeCandlesByTimestamp(rows: StockCandlePoint[]) {
|
||||
volume: (prev.volume ?? 0) + (row.volume ?? 0),
|
||||
});
|
||||
}
|
||||
return [...map.values()].sort(
|
||||
return Array.from(map.values()).sort(
|
||||
(a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0),
|
||||
);
|
||||
}
|
||||
@@ -699,12 +709,43 @@ function minutesForTimeframe(tf: DashboardChartTimeframe) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 국내주식 주식일별분봉조회 (과거 분봉)
|
||||
* @param symbol 종목코드
|
||||
* @param date 조회할 날짜 (YYYYMMDD)
|
||||
* @param time 조회할 기준 시간 (HHMMSS) - 이 시간부터 과거로 조회
|
||||
* @param credentials
|
||||
*/
|
||||
export async function getDomesticDailyTimeChart(
|
||||
symbol: string,
|
||||
date: string,
|
||||
time: string,
|
||||
credentials?: KisCredentialInput,
|
||||
) {
|
||||
const response = await kisGet<unknown>(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-time-dailychartprice",
|
||||
"FHKST03010230",
|
||||
{
|
||||
FID_COND_MRKT_DIV_CODE: "J",
|
||||
FID_INPUT_ISCD: symbol,
|
||||
FID_INPUT_DATE_1: date,
|
||||
FID_INPUT_HOUR_1: time,
|
||||
FID_PW_DATA_INCU_YN: "N",
|
||||
FID_FAKE_TICK_INCU_YN: "",
|
||||
},
|
||||
credentials,
|
||||
);
|
||||
|
||||
return parseOutput2Rows(response);
|
||||
}
|
||||
|
||||
// ─── 차트 데이터 조회 메인 ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* 종목 차트 데이터 조회 (일봉/주봉/분봉)
|
||||
* - 일봉/주봉: inquire-daily-itemchartprice (FHKST03010100), 과거 페이징 지원
|
||||
* - 분봉: inquire-time-itemchartprice (FHKST03010200), **당일 데이터만** 제공
|
||||
* - 분봉 (오늘): inquire-time-itemchartprice (FHKST03010200)
|
||||
* - 분봉 (과거): inquire-time-dailychartprice (FHKST03010230)
|
||||
*/
|
||||
export async function getDomesticChart(
|
||||
symbol: string,
|
||||
@@ -743,7 +784,7 @@ export async function getDomesticChart(
|
||||
new Date(oldest.timestamp * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10)
|
||||
.replaceAll("-", ""),
|
||||
.replace(/-/g, ""),
|
||||
-1,
|
||||
)
|
||||
: null;
|
||||
@@ -751,7 +792,65 @@ export async function getDomesticChart(
|
||||
return { candles: parsed, hasMore: Boolean(nextCursor), nextCursor };
|
||||
}
|
||||
|
||||
// ── 분봉 (1m / 30m / 1h) — 당일 데이터만 제공 ──
|
||||
// ── 분봉 (1m / 30m / 1h) ──
|
||||
const minuteBucket = minutesForTimeframe(timeframe);
|
||||
let rawRows: KisDomesticItemChartRow[] = [];
|
||||
let nextCursor: string | null = null;
|
||||
|
||||
// Case A: 과거 데이터 조회 (커서 존재)
|
||||
if (cursor && cursor.length >= 8) {
|
||||
const targetDate = cursor.slice(0, 8);
|
||||
const targetTime = cursor.slice(8) || "153000";
|
||||
rawRows = await getDomesticDailyTimeChart(
|
||||
symbol,
|
||||
targetDate,
|
||||
targetTime,
|
||||
credentials,
|
||||
);
|
||||
|
||||
// 다음 커서 계산
|
||||
// 데이터가 있으면 가장 오래된 시간 - 1분? 혹은 해당 날짜의 09:00 도달 시 전일로 이동
|
||||
// API가 시간 역순으로 데이터를 준다고 가정 (output[0]이 가장 최신, output[last]가 가장 과거)
|
||||
// 실제 KIS API는 보통 최신순 정렬
|
||||
if (rawRows.length > 0) {
|
||||
// 가장 과거 데이터의 시간 확인
|
||||
const oldestRow = rawRows[rawRows.length - 1]; // 마지막이 가장 과거라 가정
|
||||
const oldestTime = readRowString(oldestRow, "stck_cntg_hour");
|
||||
|
||||
// 09:00:00보다 크면 계속 같은 날짜 페이징 (단, KIS가 120건씩 주므로)
|
||||
// 만약 09시 근처라면 전일로 이동
|
||||
// 간단히: 가져온 데이터 중 090000이 포함되어 있거나, 더 가져올 게 없어 보이면 전일로
|
||||
// 여기서는 단순히 전일 153000으로 넘어가는 로직을 사용하거나,
|
||||
// 현재 날짜에서 시간을 줄여서 재요청해야 함.
|
||||
// KIS API가 '다음 커서'를 주지 않으므로, 마지막 데이터 시간을 기준으로 다음 요청
|
||||
|
||||
if (oldestTime && Number(oldestTime) > 90000) {
|
||||
// 같은 날짜, 시간만 조정 (1분 전)
|
||||
// HHMMSS -> number -> subtract -> string
|
||||
// 편의상 120개 꽉 찼으면 마지막 시간 사용, 아니면 전일로
|
||||
if (rawRows.length >= 120) {
|
||||
nextCursor = targetDate + oldestTime; // 다음 요청 시 이 시간 '이전'을 달라고 해야 함 (Inclusive 여부 확인 필요)
|
||||
// 만약 Inclusive라면 -1분 해야 함. 안전하게 -1분 처리
|
||||
// 시간 연산이 복잡하므로, 단순히 전일로 넘어가는 게 나을 수도 있으나,
|
||||
// 하루치 분봉이 380개라 120개로는 부족함.
|
||||
// 따라서 시간 연산 필요.
|
||||
nextCursor = targetDate + subOneMinute(oldestTime);
|
||||
} else {
|
||||
// 120개 미만이면 장 시작까지 다 가져왔다고 가정 -> 전일로
|
||||
nextCursor = shiftYmd(targetDate, -1) + "153000";
|
||||
}
|
||||
} else {
|
||||
// 09:00 도달 -> 전일로
|
||||
nextCursor = shiftYmd(targetDate, -1) + "153000";
|
||||
}
|
||||
} else {
|
||||
// 데이터 없음 (휴장일 등) -> 전일로 계속 시도 (최대 5일? 무한 루프 방지 필요하나 UI에서 제어)
|
||||
nextCursor = shiftYmd(targetDate, -1) + "153000";
|
||||
// 너무 과거(1년)면 중단? 일단 생략
|
||||
}
|
||||
|
||||
} else {
|
||||
// Case B: 초기 진입 (오늘 실시간/장중 데이터)
|
||||
const response = await kisGet<unknown>(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice",
|
||||
"FHKST03010200",
|
||||
@@ -764,14 +863,29 @@ export async function getDomesticChart(
|
||||
},
|
||||
credentials,
|
||||
);
|
||||
rawRows = parseOutput2Rows(response);
|
||||
|
||||
// 오늘 데이터 다음은 '어제 마감'
|
||||
const todayYmd = nowYmdInKst();
|
||||
nextCursor = shiftYmd(todayYmd, -1) + "153000";
|
||||
}
|
||||
|
||||
const minuteBucket = minutesForTimeframe(timeframe);
|
||||
const candles = mergeCandlesByTimestamp(
|
||||
parseOutput2Rows(response)
|
||||
rawRows
|
||||
.map((row) => parseMinuteCandleRow(row, minuteBucket))
|
||||
.filter((c): c is StockCandlePoint => Boolean(c)),
|
||||
);
|
||||
|
||||
// 당일 분봉만 제공되므로 과거 페이징 불필요
|
||||
return { candles, hasMore: false, nextCursor: null };
|
||||
return { candles, hasMore: Boolean(nextCursor), nextCursor };
|
||||
}
|
||||
|
||||
function subOneMinute(hhmmss: string) {
|
||||
const hh = Number(hhmmss.slice(0, 2));
|
||||
const mm = Number(hhmmss.slice(2, 4));
|
||||
let totalMin = hh * 60 + mm - 1;
|
||||
if (totalMin < 0) totalMin = 0;
|
||||
|
||||
const h = Math.floor(totalMin / 60);
|
||||
const m = totalMin % 60;
|
||||
return `${String(h).padStart(2, '0')}${String(m).padStart(2, '0')}00`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user