전체적인 리팩토링

This commit is contained in:
2026-03-12 09:26:27 +09:00
parent 406af7408a
commit e51d767878
97 changed files with 13651 additions and 363 deletions

View File

@@ -39,10 +39,14 @@ import {
CHART_MIN_HEIGHT,
DEFAULT_CHART_PALETTE,
getChartPaletteFromCssVars,
HISTORY_LOAD_TRIGGER_BARS_BEFORE,
INITIAL_MINUTE_PREFETCH_BUDGET_MS,
MINUTE_SYNC_INTERVAL_MS,
MINUTE_TIMEFRAMES,
PERIOD_TIMEFRAMES,
REALTIME_STALE_THRESHOLD_MS,
resolveInitialMinutePrefetchPages,
resolveInitialMinuteTargetBars,
UP_COLOR,
} from "./stock-line-chart-meta";
@@ -101,6 +105,9 @@ export function StockLineChart({
const loadingMoreRef = useRef(false);
const loadMoreHandlerRef = useRef<() => Promise<void>>(async () => {});
const initialLoadCompleteRef = useRef(false);
const pendingFitContentRef = useRef(false);
const nextCursorRef = useRef<string | null>(null);
const autoFillLeftGapRef = useRef(false);
// API 오류 시 fallback 용도로 유지
const latestCandlesRef = useRef(candles);
@@ -108,6 +115,10 @@ export function StockLineChart({
latestCandlesRef.current = candles;
}, [candles]);
useEffect(() => {
nextCursorRef.current = nextCursor;
}, [nextCursor]);
const latest = bars.at(-1);
const prevClose = bars.length > 1 ? (bars.at(-2)?.close ?? 0) : 0;
const change = latest ? latest.close - prevClose : 0;
@@ -196,7 +207,13 @@ export function StockLineChart({
const olderBars = normalizeCandles(response.candles, timeframe);
setBars((prev) => mergeBars(olderBars, prev));
setNextCursor(response.hasMore ? response.nextCursor : null);
setNextCursor(
response.hasMore &&
response.nextCursor &&
response.nextCursor !== nextCursor
? response.nextCursor
: null,
);
} catch (error) {
const message =
error instanceof Error
@@ -213,6 +230,58 @@ export function StockLineChart({
loadMoreHandlerRef.current = handleLoadMore;
}, [handleLoadMore]);
const fillLeftWhitespaceIfNeeded = useCallback(async () => {
if (!isMinuteTimeframe(timeframe)) return;
if (autoFillLeftGapRef.current) return;
if (loadingMoreRef.current) return;
if (!nextCursorRef.current) return;
const chart = chartRef.current;
const candleSeries = candleSeriesRef.current;
if (!chart || !candleSeries) return;
autoFillLeftGapRef.current = true;
const startedAt = Date.now();
let rounds = 0;
try {
while (
rounds < 16 &&
Date.now() - startedAt < INITIAL_MINUTE_PREFETCH_BUDGET_MS
) {
const range = chart.timeScale().getVisibleLogicalRange();
if (!range) break;
const barsInfo = candleSeries.barsInLogicalRange(range);
const hasLeftWhitespace =
Boolean(
barsInfo &&
Number.isFinite(barsInfo.barsBefore) &&
barsInfo.barsBefore < 0,
) || false;
if (!hasLeftWhitespace) break;
const cursorBefore = nextCursorRef.current;
if (!cursorBefore) break;
await loadMoreHandlerRef.current();
rounds += 1;
await new Promise<void>((resolve) => {
window.setTimeout(() => resolve(), 120);
});
chart.timeScale().fitContent();
const cursorAfter = nextCursorRef.current;
if (!cursorAfter || cursorAfter === cursorBefore) break;
}
} finally {
autoFillLeftGapRef.current = false;
}
}, [timeframe]);
useEffect(() => {
lastRealtimeKeyRef.current = "";
lastRealtimeAppliedAtRef.current = 0;
@@ -257,7 +326,10 @@ export function StockLineChart({
borderColor: palette.borderColor,
timeVisible: true,
secondsVisible: false,
rightOffset: 2,
rightOffset: 4,
barSpacing: 6,
minBarSpacing: 1,
rightBarStaysOnScroll: true,
tickMarkFormatter: formatKstTickMark,
},
handleScroll: {
@@ -298,15 +370,29 @@ export function StockLineChart({
});
let scrollTimeout: number | undefined;
chart.timeScale().subscribeVisibleLogicalRangeChange((range) => {
const handleVisibleLogicalRangeChange = (range: {
from: number;
to: number;
} | null) => {
if (!range || !initialLoadCompleteRef.current) return;
if (range.from >= 10) return;
const barsInfo = candleSeries.barsInLogicalRange(range);
if (!barsInfo) return;
if (
Number.isFinite(barsInfo.barsBefore) &&
barsInfo.barsBefore > HISTORY_LOAD_TRIGGER_BARS_BEFORE
) {
return;
}
if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout);
scrollTimeout = window.setTimeout(() => {
void loadMoreHandlerRef.current();
}, 250);
});
};
chart
.timeScale()
.subscribeVisibleLogicalRangeChange(handleVisibleLogicalRangeChange);
chartRef.current = chart;
candleSeriesRef.current = candleSeries;
@@ -330,6 +416,9 @@ export function StockLineChart({
return () => {
if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout);
chart
.timeScale()
.unsubscribeVisibleLogicalRangeChange(handleVisibleLogicalRangeChange);
window.cancelAnimationFrame(rafId);
resizeObserver.disconnect();
chart.remove();
@@ -386,6 +475,8 @@ export function StockLineChart({
if (!symbol || !credentials) return;
initialLoadCompleteRef.current = false;
pendingFitContentRef.current = true;
autoFillLeftGapRef.current = false;
let disposed = false;
let initialLoadTimer: number | null = null;
@@ -401,16 +492,24 @@ export function StockLineChart({
? firstPage.nextCursor
: null;
// 분봉은 기본 3페이지까지 순차 조회해 오전 구간 누락을 줄입니다.
// 분봉은 시간프레임별 목표 봉 수까지 순차 조회해 초기 과거 가시성을 보강합니다.
if (
isMinuteTimeframe(timeframe) &&
firstPage.hasMore &&
firstPage.nextCursor
) {
const targetBars = resolveInitialMinuteTargetBars(timeframe);
const maxPrefetchPages = resolveInitialMinutePrefetchPages(timeframe);
const prefetchStartedAt = Date.now();
let minuteCursor: string | null = firstPage.nextCursor;
let extraPageCount = 0;
while (minuteCursor && extraPageCount < 2) {
while (
minuteCursor &&
extraPageCount < maxPrefetchPages &&
Date.now() - prefetchStartedAt < INITIAL_MINUTE_PREFETCH_BUDGET_MS &&
mergedBars.length < targetBars
) {
try {
const olderPage = await fetchStockChart(
symbol,
@@ -421,10 +520,14 @@ export function StockLineChart({
const olderBars = normalizeCandles(olderPage.candles, timeframe);
mergedBars = mergeBars(olderBars, mergedBars);
resolvedNextCursor = olderPage.hasMore
const nextMinuteCursor = olderPage.hasMore
? olderPage.nextCursor
: null;
minuteCursor = olderPage.hasMore ? olderPage.nextCursor : null;
resolvedNextCursor = nextMinuteCursor;
minuteCursor =
nextMinuteCursor && nextMinuteCursor !== minuteCursor
? nextMinuteCursor
: null;
extraPageCount += 1;
} catch {
// 추가 페이지 실패는 치명적이지 않으므로 현재 데이터는 유지합니다.
@@ -469,10 +572,25 @@ export function StockLineChart({
if (!isChartReady) return;
setSeriesData(renderableBars);
if (!initialLoadCompleteRef.current && renderableBars.length > 0) {
if (renderableBars.length === 0) return;
if (pendingFitContentRef.current) {
chartRef.current?.timeScale().fitContent();
pendingFitContentRef.current = false;
} else if (!initialLoadCompleteRef.current) {
chartRef.current?.timeScale().fitContent();
}
}, [isChartReady, renderableBars, setSeriesData]);
if (nextCursorRef.current) {
void fillLeftWhitespaceIfNeeded();
}
}, [
fillLeftWhitespaceIfNeeded,
isChartReady,
renderableBars,
setSeriesData,
timeframe,
]);
/**
* @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다.
@@ -495,7 +613,7 @@ export function StockLineChart({
}, [latestTick, timeframe]);
/**
* @description 분봉(1m/30m/1h)에서는 WS 공백 상황을 대비해 최신 페이지를 주기적으로 동기화합니다.
* @description 분봉(1m/5m/10m/15m/30m/1h)에서는 WS 공백 상황을 대비해 최신 페이지를 주기적으로 동기화합니다.
* @see features/trade/apis/kis-stock.api.ts fetchStockChart
* @see lib/kis/domestic.ts getDomesticChart
*/

View File

@@ -150,8 +150,7 @@ function resolveBarTimestamp(
/**
* 타임스탬프를 타임프레임 버킷 경계에 정렬
* - 1m: 초/밀리초를 제거해 분 경계에 정렬
* - 30m/1h: 분 단위를 버킷에 정렬
* - 분봉(1/5/10/15/30/60분): 분 단위를 버킷 경계에 정렬
* - 1d: 00:00:00
* - 1w: 월요일 00:00:00
*/
@@ -160,12 +159,14 @@ function alignTimestamp(
timeframe: DashboardChartTimeframe,
): UTCTimestamp {
const d = new Date(timestamp * 1000);
const minuteBucket = resolveMinuteBucket(timeframe);
if (timeframe === "1m") {
d.setUTCSeconds(0, 0);
} else if (timeframe === "30m" || timeframe === "1h") {
const bucket = timeframe === "30m" ? 30 : 60;
d.setUTCMinutes(Math.floor(d.getUTCMinutes() / bucket) * bucket, 0, 0);
if (minuteBucket !== null) {
d.setUTCMinutes(
Math.floor(d.getUTCMinutes() / minuteBucket) * minuteBucket,
0,
0,
);
} else if (timeframe === "1d") {
d.setUTCHours(0, 0, 0, 0);
} else if (timeframe === "1w") {
@@ -300,7 +301,17 @@ export function formatSignedPercent(value: number) {
* 분봉 타임프레임인지 판별
*/
export function isMinuteTimeframe(tf: DashboardChartTimeframe) {
return tf === "1m" || tf === "30m" || tf === "1h";
return resolveMinuteBucket(tf) !== null;
}
function resolveMinuteBucket(tf: DashboardChartTimeframe): number | null {
if (tf === "1m") return 1;
if (tf === "5m") return 5;
if (tf === "10m") return 10;
if (tf === "15m") return 15;
if (tf === "30m") return 30;
if (tf === "1h") return 60;
return null;
}
function normalizeTickTime(value?: string) {

View File

@@ -5,6 +5,8 @@ export const UP_COLOR = "#ef4444";
export const MINUTE_SYNC_INTERVAL_MS = 30000;
export const REALTIME_STALE_THRESHOLD_MS = 12000;
export const CHART_MIN_HEIGHT = 220;
export const HISTORY_LOAD_TRIGGER_BARS_BEFORE = 40;
export const INITIAL_MINUTE_PREFETCH_BUDGET_MS = 12000;
export interface ChartPalette {
backgroundColor: string;
@@ -31,6 +33,9 @@ export const MINUTE_TIMEFRAMES: Array<{
label: string;
}> = [
{ value: "1m", label: "1분" },
{ value: "5m", label: "5분" },
{ value: "10m", label: "10분" },
{ value: "15m", label: "15분" },
{ value: "30m", label: "30분" },
{ value: "1h", label: "1시간" },
];
@@ -43,6 +48,30 @@ export const PERIOD_TIMEFRAMES: Array<{
{ value: "1w", label: "주" },
];
export function resolveInitialMinuteTargetBars(
timeframe: DashboardChartTimeframe,
) {
if (timeframe === "1m") return 260;
if (timeframe === "5m") return 240;
if (timeframe === "10m") return 220;
if (timeframe === "15m") return 200;
if (timeframe === "30m") return 180;
if (timeframe === "1h") return 260;
return 140;
}
export function resolveInitialMinutePrefetchPages(
timeframe: DashboardChartTimeframe,
) {
if (timeframe === "1m") return 24;
if (timeframe === "5m") return 28;
if (timeframe === "10m") return 32;
if (timeframe === "15m") return 36;
if (timeframe === "30m") return 44;
if (timeframe === "1h") return 80;
return 20;
}
/**
* @description 브랜드 CSS 변수에서 차트 팔레트를 읽어옵니다.
* @see features/trade/components/chart/StockLineChart.tsx 차트 생성/테마 반영