실시간 차트 오류 수정
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user