실시간 차트 오류 수정

This commit is contained in:
Macbook
2026-05-25 04:18:30 +09:00
parent ac2e6a1dae
commit 0cfe7fc84c
3 changed files with 96 additions and 27 deletions
+18 -13
View File
@@ -8,17 +8,18 @@ import { DISPLAY_COUNT } from '../hooks/useUpbitData';
/** Upbit 실시간 — 초기 히스토리가 이 개수 미만이면 setData 하지 않음 (종목 전환 직후 sparse 봉 → 캔들 폭 비정상) */ /** Upbit 실시간 — 초기 히스토리가 이 개수 미만이면 setData 하지 않음 (종목 전환 직후 sparse 봉 → 캔들 폭 비정상) */
const MIN_BARS_FOR_FULL_RELOAD = Math.min(50, Math.floor(DISPLAY_COUNT / 4)); const MIN_BARS_FOR_FULL_RELOAD = Math.min(50, Math.floor(DISPLAY_COUNT / 4));
/** 종목 전환 직후 stale/partial 데이터 차단 — fetch 완료(barsMarket 확정) + 충분한 히스토리만 렌더 */ /** 종목 전환 직후 stale/partial 데이터 차단 — barsMarket 확정 후 렌더 허용 */
function canApplyChartBars( function canApplyChartBars(
barData: OHLCVBar[], barData: OHLCVBar[],
barsMarket: string | null | undefined, barsMarket: string | null | undefined,
market: string, market: string,
): boolean { ): boolean {
if (barData.length === 0) return false; if (barData.length < 2) return false;
if (barsMarket === undefined) return true; if (barsMarket === undefined) return true;
if (barsMarket == null || barsMarket !== market) return false; if (barsMarket == null || barsMarket !== market) return false;
if (!market.startsWith('KRW-')) return barData.length >= 2; if (!market.startsWith('KRW-')) return true;
return barData.length >= MIN_BARS_FOR_FULL_RELOAD; if (barData.length >= MIN_BARS_FOR_FULL_RELOAD) return true;
return true;
} }
import DrawingCanvas, { hitTestDrawing } from './DrawingCanvas'; import DrawingCanvas, { hitTestDrawing } from './DrawingCanvas';
import PaneLegend, { type PaneLegendProps } from './PaneLegend'; import PaneLegend, { type PaneLegendProps } from './PaneLegend';
@@ -172,6 +173,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
const prevSortedPKRef = useRef<string>(''); // 순서 무관 paramKey (reorder 감지용) const prevSortedPKRef = useRef<string>(''); // 순서 무관 paramKey (reorder 감지용)
const prevMarket = useRef<string>(market); const prevMarket = useRef<string>(market);
const prevMarketTf = useRef<Timeframe>(timeframe); const prevMarketTf = useRef<Timeframe>(timeframe);
const lastAppliedMarketRef = useRef<string>('');
const prevChartType = useRef<ChartType>(chartType); const prevChartType = useRef<ChartType>(chartType);
const prevTheme = useRef<Theme>(theme); const prevTheme = useRef<Theme>(theme);
const prevLogScale = useRef<boolean>(logScale); const prevLogScale = useRef<boolean>(logScale);
@@ -381,19 +383,19 @@ const TradingChart: React.FC<TradingChartProps> = ({
return; return;
} }
barsRef.current = newBars;
reloadInFlightRef.current = true; reloadInFlightRef.current = true;
try { try {
do { do {
reloadCoalesceRef.current = false; reloadCoalesceRef.current = false;
const gen = reloadGenRef.current; const gen = reloadGenRef.current;
if (newBars.length === 0) break; const barData = barsRef.current.length >= 2 ? barsRef.current : newBars;
if (barData.length < 2) break;
reloadSafetyTimers.current.forEach(clearTimeout); reloadSafetyTimers.current.forEach(clearTimeout);
reloadSafetyTimers.current = []; reloadSafetyTimers.current = [];
barsRef.current = barData;
barsRef.current = newBars;
const barData = newBars;
mgr.setData(barData, ct, th); mgr.setData(barData, ct, th);
if (gen !== reloadGenRef.current) { if (gen !== reloadGenRef.current) {
@@ -405,6 +407,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
mgr.setDisplayTimezone(displayTimezone, timeframe); mgr.setDisplayTimezone(displayTimezone, timeframe);
commitCandleLayout(mgr); commitCandleLayout(mgr);
onCandlesReady?.(); onCandlesReady?.();
lastAppliedMarketRef.current = market;
let stale = false; let stale = false;
for (const ind of inds) { for (const ind of inds) {
@@ -449,6 +452,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
prevBarsKey.current = barsKey(barData, market); prevBarsKey.current = barsKey(barData, market);
prevIndKey.current = indKey(inds); prevIndKey.current = indKey(inds);
prevSortedPKRef.current = sortedParamKey(inds); prevSortedPKRef.current = sortedParamKey(inds);
lastAppliedMarketRef.current = market;
await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => { await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => {
applyPaneLayout(mgr); applyPaneLayout(mgr);
@@ -818,6 +822,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
prevMarketTf.current = timeframe; prevMarketTf.current = timeframe;
prevBarsKey.current = ''; prevBarsKey.current = '';
prevIndKey.current = ''; prevIndKey.current = '';
lastAppliedMarketRef.current = '';
barsRef.current = []; barsRef.current = [];
reloadRetryRef.current = 0; reloadRetryRef.current = 0;
reloadGenRef.current += 1; reloadGenRef.current += 1;
@@ -832,11 +837,11 @@ const TradingChart: React.FC<TradingChartProps> = ({
// ── 데이터/인디케이터 동기화 ───────────────────────────────────────────── // ── 데이터/인디케이터 동기화 ─────────────────────────────────────────────
useEffect(() => { useEffect(() => {
const mgr = managerRef.current; const mgr = managerRef.current;
if (!mgr || !chartMgr || bars.length === 0) return; if (!mgr || !chartMgr || bars.length < 2) return;
if (!canApplyChartBars(bars, barsMarket, market)) return; if (!canApplyChartBars(bars, barsMarket, market)) return;
// manager 준비 전 bars 가 먼저 도착한 경우(로그인 직후 등) 메인 시리즈 누락 방지 // manager 준비 전·종목 미적용 시 reloadAll 강제
if (!mgr.hasMainSeries()) { if (!mgr.hasMainSeries() || lastAppliedMarketRef.current !== market) {
void reloadAll(mgr, bars, chartType, theme, logScale, indicators); void reloadAll(mgr, bars, chartType, theme, logScale, indicators);
return; return;
} }
@@ -942,7 +947,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
// 데이터는 준비됐으나 메인 시리즈가 없는 빈 차트 자동 복구 (종목 전환·레이아웃 전환 직후) // 데이터는 준비됐으나 메인 시리즈가 없는 빈 차트 자동 복구 (종목 전환·레이아웃 전환 직후)
useEffect(() => { useEffect(() => {
const mgr = managerRef.current; const mgr = managerRef.current;
if (!mgr || !chartMgr || bars.length === 0) return; if (!mgr || !chartMgr || bars.length < 2) return;
if (!canApplyChartBars(bars, barsMarket, market)) return; if (!canApplyChartBars(bars, barsMarket, market)) return;
if (mgr.hasMainSeries()) return; if (mgr.hasMainSeries()) return;
@@ -969,7 +974,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
// 종목 전환 직후 reloadAll 이 중단되어 캔들만 남은 경우 자동 복구 // 종목 전환 직후 reloadAll 이 중단되어 캔들만 남은 경우 자동 복구
useEffect(() => { useEffect(() => {
const mgr = managerRef.current; const mgr = managerRef.current;
if (!mgr || !chartMgr || bars.length === 0) return; if (!mgr || !chartMgr || bars.length < 2) return;
if (!canApplyChartBars(bars, barsMarket, market)) return; if (!canApplyChartBars(bars, barsMarket, market)) return;
const expected = countExpectedVisibleIndicators(indicators); const expected = countExpectedVisibleIndicators(indicators);
+60 -3
View File
@@ -10,6 +10,10 @@ import { getCandleStart, TF_SECONDS } from '../utils/upbitApi';
import { DISPLAY_COUNT, WARMUP_COUNT } from './useUpbitData'; import { DISPLAY_COUNT, WARMUP_COUNT } from './useUpbitData';
import type { WsStatus } from './useUpbitData'; import type { WsStatus } from './useUpbitData';
import { API_BASE } from '../utils/backendApi'; import { API_BASE } from '../utils/backendApi';
import { fetchUpbitCandlesCached } from '../utils/requestCache';
/** 초기 렌더 최소 봉 수 — 미만이면 업비트 REST 폴백 */
const MIN_INITIAL_BARS = Math.min(50, Math.floor(DISPLAY_COUNT / 4));
/** 백엔드 BarBuilder 는 틱마다 1m 만 STOMP 발행 — 차트 분봉은 클라이언트에서 집계 */ /** 백엔드 BarBuilder 는 틱마다 1m 만 STOMP 발행 — 차트 분봉은 클라이언트에서 집계 */
const STOMP_TICK_CANDLE_TYPE = '1m'; const STOMP_TICK_CANDLE_TYPE = '1m';
@@ -100,6 +104,36 @@ async function fetchInitialBarsWithRetry(
throw lastErr ?? new Error('history fetch failed'); throw lastErr ?? new Error('history fetch failed');
} }
/** 백엔드 history → 부족·실패 시 업비트 REST 폴백 (종목별 Ta4j 미적재 대응) */
async function loadInitialBars(
market: string,
timeframe: Timeframe,
count: number,
marketChanged: boolean,
): Promise<OHLCVBar[]> {
const candleType = timeframeToCandleType(timeframe);
let bars: OHLCVBar[] = [];
try {
bars = await fetchInitialBarsWithRetry(market, candleType, count);
} catch {
bars = [];
}
if (bars.length < MIN_INITIAL_BARS) {
try {
const fallback = await fetchUpbitCandlesCached(
market,
timeframe,
Math.min(count, 200),
marketChanged,
);
if (fallback.length > bars.length) bars = fallback;
} catch {
/* 폴백 실패 시 backend 결과 유지 */
}
}
return bars;
}
export function useStompChartData( export function useStompChartData(
market: string, market: string,
timeframe: Timeframe, timeframe: Timeframe,
@@ -122,11 +156,15 @@ export function useStompChartData(
const last1mTimeRef = useRef<number>(0); const last1mTimeRef = useRef<number>(0);
/** 초기 히스토리 로드 완료 전에는 차트 직접 갱신 콜백을 호출하지 않음 */ /** 초기 히스토리 로드 완료 전에는 차트 직접 갱신 콜백을 호출하지 않음 */
const historyReadyRef = useRef(false); const historyReadyRef = useRef(false);
/** 백엔드 history 비어 있을 때 STOMP 누적 → React bars 동기화 */
const bootstrapFromStompRef = useRef(false);
const prevMarketRef = useRef('');
useEffect(() => { useEffect(() => {
if (opts.enabled === false) return; if (opts.enabled === false) return;
let cancelled = false; let cancelled = false;
historyReadyRef.current = false; historyReadyRef.current = false;
bootstrapFromStompRef.current = false;
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
setBars([]); setBars([]);
@@ -136,16 +174,26 @@ export function useStompChartData(
last1mTimeRef.current = 0; last1mTimeRef.current = 0;
lastTimeRef.current = 0; lastTimeRef.current = 0;
const marketChanged = prevMarketRef.current !== '' && prevMarketRef.current !== market;
prevMarketRef.current = market;
const count = DISPLAY_COUNT + WARMUP_COUNT; const count = DISPLAY_COUNT + WARMUP_COUNT;
fetchInitialBarsWithRetry(market, candleType, count) loadInitialBars(market, timeframe, count, marketChanged)
.then(newBars => { .then(newBars => {
if (cancelled) return; if (cancelled) return;
barsRef.current = newBars; barsRef.current = newBars;
historyReadyRef.current = true; historyReadyRef.current = true;
setBarsMarket(market); setBarsMarket(market);
if (newBars.length >= 2) {
bootstrapFromStompRef.current = false;
setBars(newBars); setBars(newBars);
setLatestBar(newBars[newBars.length - 1] ?? null); setLatestBar(newBars[newBars.length - 1] ?? null);
lastTimeRef.current = newBars[newBars.length - 1]?.time ?? 0; lastTimeRef.current = newBars[newBars.length - 1]?.time ?? 0;
} else {
bootstrapFromStompRef.current = true;
setBars([]);
setLatestBar(null);
}
setIsLoading(false); setIsLoading(false);
}) })
.catch(err => { .catch(err => {
@@ -154,7 +202,15 @@ export function useStompChartData(
setIsLoading(false); setIsLoading(false);
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [market, candleType, opts.enabled]); }, [market, candleType, timeframe, opts.enabled]);
const syncBarsToReactIfNeeded = useCallback((marketId: string) => {
if (!bootstrapFromStompRef.current) return;
if (barsRef.current.length < 2) return;
bootstrapFromStompRef.current = false;
setBarsMarket(marketId);
setBars([...barsRef.current]);
}, []);
const applyCandle = useCallback((data: StompCandlePayload, displayTf: Timeframe) => { const applyCandle = useCallback((data: StompCandlePayload, displayTf: Timeframe) => {
if (!historyReadyRef.current) return; if (!historyReadyRef.current) return;
@@ -193,6 +249,7 @@ export function useStompChartData(
if (notifyChart) optsRef.current.onTickUpdate(patched); if (notifyChart) optsRef.current.onTickUpdate(patched);
} }
lastTimeRef.current = bar.time; lastTimeRef.current = bar.time;
syncBarsToReactIfNeeded(market);
}; };
const lastChart = barsRef.current[barsRef.current.length - 1]; const lastChart = barsRef.current[barsRef.current.length - 1];
@@ -256,7 +313,7 @@ export function useStompChartData(
last1mTimeRef.current = oneMin.time; last1mTimeRef.current = oneMin.time;
last1mVolRef.current = oneMin.volume; last1mVolRef.current = oneMin.volume;
} }
}, []); }, [market, syncBarsToReactIfNeeded]);
useEffect(() => { useEffect(() => {
if (opts.enabled === false) { if (opts.enabled === false) {
+10 -3
View File
@@ -356,17 +356,20 @@ export class ChartManager {
this._reapplyAllPatternMarkers(); this._reapplyAllPatternMarkers();
// 이전 timeScale visible range를 완전히 리셋 후 fitContent // 이전 timeScale visible range를 완전히 리셋
// setVisibleRange → 이전 zoom 상태를 무시하고 새 데이터 범위로 명시 초기화
const ts = this.chart.timeScale(); const ts = this.chart.timeScale();
if (bars.length >= 2) { if (bars.length >= 2) {
if (bars.length < 20) {
ts.setVisibleLogicalRange({ from: bars.length - 200, to: bars.length + 5 });
} else {
ts.setVisibleRange({ ts.setVisibleRange({
from: bars[0].time as Time, from: bars[0].time as Time,
to: bars[bars.length - 1].time as Time, to: bars[bars.length - 1].time as Time,
}); });
}
ts.fitContent(); ts.fitContent();
} }
}
}
private _createMainSeries(chartType: ChartType, t: ThemeTokens): ISeriesApi<SeriesType> { private _createMainSeries(chartType: ChartType, t: ThemeTokens): ISeriesApi<SeriesType> {
switch (chartType) { switch (chartType) {
@@ -2389,7 +2392,11 @@ export class ChartManager {
const ts = this.chart.timeScale(); const ts = this.chart.timeScale();
if (bars.length <= displayCount && extendFutureBars === 0) { if (bars.length <= displayCount && extendFutureBars === 0) {
if (bars.length < 20) {
ts.setVisibleLogicalRange({ from: bars.length - displayCount, to: bars.length + 5 });
} else {
ts.fitContent(); ts.fitContent();
}
return; return;
} }