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