실시간 차트 갱신문제 수정
This commit is contained in:
+23
-2
@@ -614,6 +614,8 @@ function App() {
|
|||||||
const managerRef = useRef<ChartManager | null>(null);
|
const managerRef = useRef<ChartManager | null>(null);
|
||||||
/** 차트 매니저 준비 전 도착한 STOMP 틱 (준비 후 1회 적용) */
|
/** 차트 매니저 준비 전 도착한 STOMP 틱 (준비 후 1회 적용) */
|
||||||
const pendingRealtimeBarRef = useRef<OHLCVBar | null>(null);
|
const pendingRealtimeBarRef = useRef<OHLCVBar | null>(null);
|
||||||
|
/** reloadAll setData 완료 전 — 이전 종목 rawBars에 실시간 틱 혼입 방지 */
|
||||||
|
const chartLiveReadyRef = useRef(false);
|
||||||
const useUpbit = isUpbitMarket(symbol);
|
const useUpbit = isUpbitMarket(symbol);
|
||||||
|
|
||||||
// ── 백테스팅 ───────────────────────────────────────────────────────────────
|
// ── 백테스팅 ───────────────────────────────────────────────────────────────
|
||||||
@@ -940,6 +942,10 @@ function App() {
|
|||||||
// 틱 수신: 동일 캔들 업데이트 → React state 변경 없이 차트만 직접 갱신
|
// 틱 수신: 동일 캔들 업데이트 → React state 변경 없이 차트만 직접 갱신
|
||||||
const handleTickUpdate = useCallback((bar: OHLCVBar) => {
|
const handleTickUpdate = useCallback((bar: OHLCVBar) => {
|
||||||
if (barsMarketRef.current !== chartContextRef.current.symbol) return;
|
if (barsMarketRef.current !== chartContextRef.current.symbol) return;
|
||||||
|
if (!chartLiveReadyRef.current) {
|
||||||
|
pendingRealtimeBarRef.current = bar;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!managerRef.current) {
|
if (!managerRef.current) {
|
||||||
pendingRealtimeBarRef.current = bar;
|
pendingRealtimeBarRef.current = bar;
|
||||||
flashWsDot();
|
flashWsDot();
|
||||||
@@ -952,6 +958,10 @@ function App() {
|
|||||||
// 새 캔들: bars state를 변경하지 않고 차트에 바만 추가 + 인디케이터 last point 갱신
|
// 새 캔들: bars state를 변경하지 않고 차트에 바만 추가 + 인디케이터 last point 갱신
|
||||||
const handleNewCandle = useCallback((newBar: OHLCVBar) => {
|
const handleNewCandle = useCallback((newBar: OHLCVBar) => {
|
||||||
if (barsMarketRef.current !== chartContextRef.current.symbol) return;
|
if (barsMarketRef.current !== chartContextRef.current.symbol) return;
|
||||||
|
if (!chartLiveReadyRef.current) {
|
||||||
|
pendingRealtimeBarRef.current = newBar;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!managerRef.current) {
|
if (!managerRef.current) {
|
||||||
pendingRealtimeBarRef.current = newBar;
|
pendingRealtimeBarRef.current = newBar;
|
||||||
flashWsDot();
|
flashWsDot();
|
||||||
@@ -961,6 +971,15 @@ function App() {
|
|||||||
flashWsDot();
|
flashWsDot();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleCandlesReady = useCallback(() => {
|
||||||
|
chartLiveReadyRef.current = true;
|
||||||
|
const pending = pendingRealtimeBarRef.current;
|
||||||
|
const mgr = managerRef.current;
|
||||||
|
if (!pending || !mgr || barsMarketRef.current !== chartContextRef.current.symbol) return;
|
||||||
|
pendingRealtimeBarRef.current = null;
|
||||||
|
mgr.updateBar(pending);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const chartRealtimeSource = appDefaults.chartRealtimeSource ?? 'BACKEND_STOMP';
|
const chartRealtimeSource = appDefaults.chartRealtimeSource ?? 'BACKEND_STOMP';
|
||||||
const { bars: upbitBars, barsMarket: upbitBarsMarket, latestBar, wsStatus, isLoading, error } = useChartRealtimeData(
|
const { bars: upbitBars, barsMarket: upbitBarsMarket, latestBar, wsStatus, isLoading, error } = useChartRealtimeData(
|
||||||
symbol,
|
symbol,
|
||||||
@@ -978,8 +997,9 @@ function App() {
|
|||||||
const barsMarket = useUpbit ? upbitBarsMarket : symbol;
|
const barsMarket = useUpbit ? upbitBarsMarket : symbol;
|
||||||
barsMarketRef.current = barsMarket;
|
barsMarketRef.current = barsMarket;
|
||||||
|
|
||||||
/** 종목·타임프레임 전환 시 이전 틱 버퍼 폐기 (paint 전에 실행) */
|
/** 종목·타임프레임 전환 시 이전 틱 버퍼·실시간 허용 플래그 초기화 (paint 전에 실행) */
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
chartLiveReadyRef.current = false;
|
||||||
pendingRealtimeBarRef.current = null;
|
pendingRealtimeBarRef.current = null;
|
||||||
}, [symbol, timeframe]);
|
}, [symbol, timeframe]);
|
||||||
|
|
||||||
@@ -1970,10 +1990,11 @@ function App() {
|
|||||||
onCrosshair={setLegend}
|
onCrosshair={setLegend}
|
||||||
onTradeOrderRequest={applyTradeFill}
|
onTradeOrderRequest={applyTradeFill}
|
||||||
onDataLoaded={() => syncBacktestMarkersRef.current()}
|
onDataLoaded={() => syncBacktestMarkersRef.current()}
|
||||||
|
onCandlesReady={handleCandlesReady}
|
||||||
onManagerReady={mgr => {
|
onManagerReady={mgr => {
|
||||||
managerRef.current = mgr;
|
managerRef.current = mgr;
|
||||||
const pending = pendingRealtimeBarRef.current;
|
const pending = pendingRealtimeBarRef.current;
|
||||||
if (pending && barsMarketRef.current === symbol && bars.length > 0) {
|
if (pending && chartLiveReadyRef.current && barsMarketRef.current === symbol && bars.length > 0) {
|
||||||
pendingRealtimeBarRef.current = null;
|
pendingRealtimeBarRef.current = null;
|
||||||
const last = bars[bars.length - 1];
|
const last = bars[bars.length - 1];
|
||||||
if (last && pending.time === last.time) mgr.updateBar(pending);
|
if (last && pending.time === last.time) mgr.updateBar(pending);
|
||||||
|
|||||||
@@ -383,6 +383,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
const reloadTriggeredRef = useRef(false); // 재마운트는 최대 1회
|
const reloadTriggeredRef = useRef(false); // 재마운트는 최대 1회
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
reloadTriggeredRef.current = false;
|
reloadTriggeredRef.current = false;
|
||||||
|
chartLiveReadyRef.current = false;
|
||||||
pendingRealtimeBarRef.current = null;
|
pendingRealtimeBarRef.current = null;
|
||||||
}, [symbol, timeframe]);
|
}, [symbol, timeframe]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -429,9 +430,23 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
|
|
||||||
const pendingRealtimeBarRef = useRef<OHLCVBar | null>(null);
|
const pendingRealtimeBarRef = useRef<OHLCVBar | null>(null);
|
||||||
const barsMarketRef = useRef<string | null>(null);
|
const barsMarketRef = useRef<string | null>(null);
|
||||||
|
const chartLiveReadyRef = useRef(false);
|
||||||
|
|
||||||
|
const handleCandlesReady = useCallback(() => {
|
||||||
|
chartLiveReadyRef.current = true;
|
||||||
|
const pending = pendingRealtimeBarRef.current;
|
||||||
|
const mgr = managerRef.current;
|
||||||
|
if (!pending || !mgr || barsMarketRef.current !== symbolRef.current) return;
|
||||||
|
pendingRealtimeBarRef.current = null;
|
||||||
|
mgr.updateBar(pending);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleTickUpdate = useCallback((bar: OHLCVBar) => {
|
const handleTickUpdate = useCallback((bar: OHLCVBar) => {
|
||||||
if (barsMarketRef.current !== symbolRef.current) return;
|
if (barsMarketRef.current !== symbolRef.current) return;
|
||||||
|
if (!chartLiveReadyRef.current) {
|
||||||
|
pendingRealtimeBarRef.current = bar;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!managerRef.current) {
|
if (!managerRef.current) {
|
||||||
pendingRealtimeBarRef.current = bar;
|
pendingRealtimeBarRef.current = bar;
|
||||||
return;
|
return;
|
||||||
@@ -440,6 +455,10 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
}, []);
|
}, []);
|
||||||
const handleNewCandle = useCallback((bar: OHLCVBar) => {
|
const handleNewCandle = useCallback((bar: OHLCVBar) => {
|
||||||
if (barsMarketRef.current !== symbolRef.current) return;
|
if (barsMarketRef.current !== symbolRef.current) return;
|
||||||
|
if (!chartLiveReadyRef.current) {
|
||||||
|
pendingRealtimeBarRef.current = bar;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!managerRef.current) {
|
if (!managerRef.current) {
|
||||||
pendingRealtimeBarRef.current = bar;
|
pendingRealtimeBarRef.current = bar;
|
||||||
return;
|
return;
|
||||||
@@ -653,7 +672,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
onManagerReady={mgr => {
|
onManagerReady={mgr => {
|
||||||
managerRef.current = mgr;
|
managerRef.current = mgr;
|
||||||
const pending = pendingRealtimeBarRef.current;
|
const pending = pendingRealtimeBarRef.current;
|
||||||
if (pending && barsMarketRef.current === symbolRef.current && bars.length > 0) {
|
if (pending && chartLiveReadyRef.current && barsMarketRef.current === symbolRef.current && bars.length > 0) {
|
||||||
pendingRealtimeBarRef.current = null;
|
pendingRealtimeBarRef.current = null;
|
||||||
const last = bars[bars.length - 1];
|
const last = bars[bars.length - 1];
|
||||||
if (last && pending.time === last.time) mgr.updateBar(pending);
|
if (last && pending.time === last.time) mgr.updateBar(pending);
|
||||||
@@ -682,6 +701,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
onCandlesReady={handleCandlesReady}
|
||||||
onDataLoaded={() => {
|
onDataLoaded={() => {
|
||||||
// reloadAll(setData + setInitialVisibleRange) 완료 후
|
// reloadAll(setData + setInitialVisibleRange) 완료 후
|
||||||
// 첫 마운트 시 pending 상태였던 sync range 를 재적용한다
|
// 첫 마운트 시 pending 상태였던 sync range 를 재적용한다
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { ChartManager } from '../utils/ChartManager';
|
|||||||
import { setIndicatorChartContext } from '../utils/indicatorRegistry';
|
import { setIndicatorChartContext } from '../utils/indicatorRegistry';
|
||||||
import { DISPLAY_COUNT } from '../hooks/useUpbitData';
|
import { DISPLAY_COUNT } from '../hooks/useUpbitData';
|
||||||
|
|
||||||
/** Upbit 실시간 — 초기 히스토리가 이 개수 미만이면 setData 하지 않음 (종목 전환 직후 1~2봉만 로드되는 깨짐 방지) */
|
/** 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 확정) 후 sparse history도 렌더 허용 */
|
/** 종목 전환 직후 stale/partial 데이터 차단 — fetch 완료(barsMarket 확정) + 충분한 히스토리만 렌더 */
|
||||||
function canApplyChartBars(
|
function canApplyChartBars(
|
||||||
barData: OHLCVBar[],
|
barData: OHLCVBar[],
|
||||||
barsMarket: string | null | undefined,
|
barsMarket: string | null | undefined,
|
||||||
@@ -17,9 +17,8 @@ function canApplyChartBars(
|
|||||||
if (barData.length === 0) return false;
|
if (barData.length === 0) 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 true;
|
if (!market.startsWith('KRW-')) return barData.length >= 2;
|
||||||
if (barData.length >= MIN_BARS_FOR_FULL_RELOAD) return true;
|
return barData.length >= MIN_BARS_FOR_FULL_RELOAD;
|
||||||
return barData.length >= 2;
|
|
||||||
}
|
}
|
||||||
import DrawingCanvas, { hitTestDrawing } from './DrawingCanvas';
|
import DrawingCanvas, { hitTestDrawing } from './DrawingCanvas';
|
||||||
import PaneLegend, { type PaneLegendProps } from './PaneLegend';
|
import PaneLegend, { type PaneLegendProps } from './PaneLegend';
|
||||||
@@ -74,6 +73,8 @@ interface TradingChartProps {
|
|||||||
* ChartSlot 에서 멀티차트 첫 마운트 시 sync range 를 재적용하는 데 사용.
|
* ChartSlot 에서 멀티차트 첫 마운트 시 sync range 를 재적용하는 데 사용.
|
||||||
*/
|
*/
|
||||||
onDataLoaded?: () => void;
|
onDataLoaded?: () => void;
|
||||||
|
/** setData 직후 캔들·볼륨 반영 완료 — 실시간 틱 허용 시점 (지표 로드 전) */
|
||||||
|
onCandlesReady?: () => void;
|
||||||
/** 보조지표 pane 순서 변경 (드래그 핸들): fromId 를 insertBeforeId 앞으로 이동 (null = 맨 뒤) */
|
/** 보조지표 pane 순서 변경 (드래그 핸들): fromId 를 insertBeforeId 앞으로 이동 (null = 맨 뒤) */
|
||||||
onReorderIndicators?: (fromId: string, insertBeforeId: string | null) => void;
|
onReorderIndicators?: (fromId: string, insertBeforeId: string | null) => void;
|
||||||
/** 보조지표 pane 병합 */
|
/** 보조지표 pane 병합 */
|
||||||
@@ -109,6 +110,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
magnifierEnabled = false,
|
magnifierEnabled = false,
|
||||||
onMagnifierClose,
|
onMagnifierClose,
|
||||||
onDataLoaded,
|
onDataLoaded,
|
||||||
|
onCandlesReady,
|
||||||
onReorderIndicators,
|
onReorderIndicators,
|
||||||
onMergeIndicators,
|
onMergeIndicators,
|
||||||
onSplitIndicatorPane,
|
onSplitIndicatorPane,
|
||||||
@@ -385,13 +387,13 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
do {
|
do {
|
||||||
reloadCoalesceRef.current = false;
|
reloadCoalesceRef.current = false;
|
||||||
const gen = reloadGenRef.current;
|
const gen = reloadGenRef.current;
|
||||||
const barData = barsRef.current.length > 0 ? barsRef.current : newBars;
|
if (newBars.length === 0) break;
|
||||||
if (barData.length === 0) 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) {
|
||||||
@@ -402,6 +404,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
mgr.setLogScale(ls);
|
mgr.setLogScale(ls);
|
||||||
mgr.setDisplayTimezone(displayTimezone, timeframe);
|
mgr.setDisplayTimezone(displayTimezone, timeframe);
|
||||||
commitCandleLayout(mgr);
|
commitCandleLayout(mgr);
|
||||||
|
onCandlesReady?.();
|
||||||
|
|
||||||
let stale = false;
|
let stale = false;
|
||||||
for (const ind of inds) {
|
for (const ind of inds) {
|
||||||
@@ -489,7 +492,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
queueFullReload();
|
queueFullReload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [applyPaneLayout, commitCandleLayout, onDataLoaded, displayTimezone, timeframe, market, queueFullReload, queueIndicatorRecovery]);
|
}, [applyPaneLayout, commitCandleLayout, onCandlesReady, onDataLoaded, displayTimezone, timeframe, market, queueFullReload, queueIndicatorRecovery]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reloadAllRef.current = reloadAll;
|
reloadAllRef.current = reloadAll;
|
||||||
@@ -822,6 +825,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
reloadCoalesceRef.current = false;
|
reloadCoalesceRef.current = false;
|
||||||
reloadSafetyTimers.current.forEach(clearTimeout);
|
reloadSafetyTimers.current.forEach(clearTimeout);
|
||||||
reloadSafetyTimers.current = [];
|
reloadSafetyTimers.current = [];
|
||||||
|
managerRef.current?.clearForSymbolChange();
|
||||||
managerRef.current?.cancelPendingIndicatorUpdates();
|
managerRef.current?.cancelPendingIndicatorUpdates();
|
||||||
}, [market, timeframe]);
|
}, [market, timeframe]);
|
||||||
|
|
||||||
|
|||||||
@@ -271,6 +271,29 @@ export class ChartManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Data ───────────────────────────────────────────────────────────────
|
// ─── Data ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 종목·타임프레임 전환 직후 — 이전 시리즈·rawBars 제거 (실시간 틱 혼입·캔들 폭 깨짐 방지) */
|
||||||
|
clearForSymbolChange(): void {
|
||||||
|
this.cancelPendingIndicatorUpdates();
|
||||||
|
this._dataGeneration += 1;
|
||||||
|
|
||||||
|
for (const entry of this.indicators.values()) {
|
||||||
|
if (entry.cloudPlugin) {
|
||||||
|
for (const s of entry.seriesList) { try { s.detachPrimitive(entry.cloudPlugin); } catch { /* ok */ } }
|
||||||
|
}
|
||||||
|
detachBbBandFill(entry);
|
||||||
|
for (const s of entry.seriesList) { try { this.chart.removeSeries(s); } catch { /* ok */ } }
|
||||||
|
}
|
||||||
|
this.indicators.clear();
|
||||||
|
this.patternMarkers = [];
|
||||||
|
this._removeExtraSubPanes();
|
||||||
|
|
||||||
|
this._disposeMainMarkersPlugin();
|
||||||
|
if (this.mainSeries) { try { this.chart.removeSeries(this.mainSeries); } catch { /* ok */ } this.mainSeries = null; }
|
||||||
|
if (this.volumeSeries) { try { this.chart.removeSeries(this.volumeSeries); } catch { /* ok */ } this.volumeSeries = null; }
|
||||||
|
this.rawBars = [];
|
||||||
|
}
|
||||||
|
|
||||||
setData(bars: OHLCVBar[], chartType: ChartType, theme: Theme): void {
|
setData(bars: OHLCVBar[], chartType: ChartType, theme: Theme): void {
|
||||||
// 전체 재로드 전: 진행 중인 지표 갱신 타이머·플래그를 초기화해
|
// 전체 재로드 전: 진행 중인 지표 갱신 타이머·플래그를 초기화해
|
||||||
// 이전 rawBars 기반의 stale 계산이 새 지표에 적용되는 것을 방지한다.
|
// 이전 rawBars 기반의 stale 계산이 새 지표에 적용되는 것을 방지한다.
|
||||||
|
|||||||
Reference in New Issue
Block a user