/** * 매매 시그널 알림 행 — 캔들·지표 미니 차트 카드 */ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import TradingChart from '../TradingChart'; import type { IndicatorConfig } from '../../types'; import type { ChartMode, ChartType, Drawing, LegendData, OHLCVBar, Theme, Timeframe, } from '../../types'; import { isUpbitMarket } from '../../utils/upbitApi'; import { useChartRealtimeData } from '../../hooks/useChartRealtimeData'; import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData'; import { useHistoryLoader } from '../../hooks/useHistoryLoader'; import { useAppSettings } from '../../hooks/useAppSettings'; import type { ChartManager } from '../../utils/ChartManager'; import { pinCandleWatch } from '../../utils/backendApi'; import { timeframeToCandleType } from '../../utils/chartCandleType'; import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; import { useSessionCandleOverlayControls } from '../../hooks/useSessionCandleOverlayControls'; import { mergeGlobalDefaultsOntoChartIndicators } from '../../utils/indicatorMainConfig'; import { buildNotificationChartIndicators } from '../../utils/notificationChartIndicators'; import { ensurePaperChartOverlays } from '../../utils/strategyToChartIndicators'; import { ensureWorkspaceChartIndicators } from '../../utils/workspaceChartIndicatorsCache'; import { indicatorDefaultsFingerprint } from '../../utils/indicatorDefaultsFingerprint'; import { applyNotificationSignalMarkers, type TradeSignalChartMarker, } from '../../utils/tradeSignalChartMarkers'; interface Props { market: string; timeframe: Timeframe; theme?: Theme; indicators?: IndicatorConfig[]; enabled?: boolean; /** 전체보기 모드 — 부모 높이에 맞춤 */ fillHeight?: boolean; /** STOMP/WS 실시간 봉 수신 시 (목록 행 형광 플래시 등) */ onRealtimeActivity?: () => void; /** 알림 발생 시점 매매 시그널 (캔들 마커) */ signalMarker?: TradeSignalChartMarker; } const noop = () => {}; const TradeSignalMiniChart: React.FC = ({ market, timeframe, theme = 'dark', indicators = [], enabled = true, fillHeight = false, onRealtimeActivity, signalMarker, }) => { const { getParams, getVisualConfig, settingsLoaded, settingsRevision } = useIndicatorSettings(); const { defaults: appDefaults } = useAppSettings(); const appChartSource = (appDefaults.chartRealtimeSource ?? 'BACKEND_STOMP') as ChartRealtimeSource; const managerRef = useRef(null); const canvasWrapRef = useRef(null); const [chartReloadTick, setChartReloadTick] = useState(0); const chartReloadTriggeredRef = useRef(false); const pendingBarRef = useRef(null); const barsMarketRef = useRef(null); const chartLiveReadyRef = useRef(false); const marketRef = useRef(market); marketRef.current = market; const signalMarkerRef = useRef(signalMarker); signalMarkerRef.current = signalMarker; const [wsIndicators, setWsIndicators] = useState([]); const [wsRevision, setWsRevision] = useState(0); useEffect(() => { let cancelled = false; void ensureWorkspaceChartIndicators(settingsRevision).then(inds => { if (cancelled) return; setWsIndicators(inds); setWsRevision(r => r + 1); }); return () => { cancelled = true; }; }, [settingsRevision]); const baseIndicators = useMemo(() => { const strategyMerged = mergeGlobalDefaultsOntoChartIndicators( ensurePaperChartOverlays(indicators, getParams, getVisualConfig), getParams, getVisualConfig, wsIndicators, ); return buildNotificationChartIndicators(strategyMerged, wsIndicators); }, [indicators, getParams, getVisualConfig, settingsRevision, wsRevision, wsIndicators]); const indicatorStyleKey = useMemo( () => baseIndicators.map(i => indicatorDefaultsFingerprint(i)).join('|'), [baseIndicators], ); /** OBV 등 워크스페이스 지표가 있으면 ws config 로드 완료 후 렌더 (maLength=1 플래시 방지) */ const needsWorkspaceConfig = useMemo( () => baseIndicators.some(i => i.type === 'OBV'), [baseIndicators], ); /** 실시간(메인) 차트와 동일한 데이터 소스 사용 */ const chartRealtimeSource: ChartRealtimeSource = appChartSource; const { indicators: chartIndicators, candleOverlayToggles, handleToggleCandleOverlay, syncToManager, } = useSessionCandleOverlayControls(managerRef, baseIndicators); const applySignalMarkers = useCallback((attempt = 0) => { const mgr = managerRef.current; if (!mgr?.hasMainSeries()) { if (attempt < 80) requestAnimationFrame(() => applySignalMarkers(attempt + 1)); return; } applyNotificationSignalMarkers(mgr, signalMarkerRef.current, false); }, []); const syncChartLayout = useCallback(() => { const wrap = canvasWrapRef.current; const mgr = managerRef.current; if (!wrap || !mgr?.hasMainSeries()) return; const chartWrap = wrap.querySelector('.tv-chart-wrap'); const w = chartWrap?.clientWidth ?? wrap.clientWidth; const h = chartWrap?.clientHeight ?? wrap.clientHeight; if (w <= 0 || h <= 0) return; mgr.syncLayout(w, h); }, []); const handleCandlesReady = useCallback(() => { chartLiveReadyRef.current = true; const pending = pendingBarRef.current; const mgr = managerRef.current; if (pending && mgr && barsMarketRef.current === marketRef.current) { pendingBarRef.current = null; mgr.updateBar(pending); } syncChartLayout(); applySignalMarkers(); }, [syncChartLayout, applySignalMarkers]); const handleManagerReady = useCallback((mgr: ChartManager) => { managerRef.current = mgr; syncToManager(mgr); applySignalMarkers(); }, [syncToManager, applySignalMarkers]); const applyRealtimeBar = useCallback((bar: OHLCVBar, append: boolean) => { if (barsMarketRef.current !== marketRef.current) return; if (!chartLiveReadyRef.current || !managerRef.current) { pendingBarRef.current = bar; return; } if (append) managerRef.current.appendBar(bar); else managerRef.current.updateBar(bar); }, []); const handleTickUpdate = useCallback((bar: OHLCVBar) => { applyRealtimeBar(bar, false); onRealtimeActivity?.(); }, [applyRealtimeBar, onRealtimeActivity]); const handleNewCandle = useCallback((bar: OHLCVBar) => { applyRealtimeBar(bar, true); onRealtimeActivity?.(); }, [applyRealtimeBar, onRealtimeActivity]); const useUpbit = isUpbitMarket(market); const { bars, barsMarket, isLoading } = useChartRealtimeData( market, timeframe, useMemo(() => ({ onTickUpdate: handleTickUpdate, onNewCandle: handleNewCandle, enabled: enabled && useUpbit, source: chartRealtimeSource, }), [handleTickUpdate, handleNewCandle, enabled, useUpbit, chartRealtimeSource]), ); const { isLoadingMore } = useHistoryLoader({ symbol: market, timeframe, isUpbit: useUpbit && enabled, managerRef, }); barsMarketRef.current = barsMarket; const barsReady = bars.length >= 2 && barsMarket === market; // 워크스페이스 지표(OBV maLength 등) 로드 완료 전에는 렌더 보류 → 잘못된 기본값 플래시 방지 const wsConfigReady = !needsWorkspaceConfig || wsRevision > 0; const chartDataReady = barsReady && wsConfigReady && settingsLoaded; useLayoutEffect(() => { chartLiveReadyRef.current = false; pendingBarRef.current = null; chartReloadTriggeredRef.current = false; managerRef.current?.clearBacktestMarkers(); }, [market, timeframe, enabled]); useEffect(() => { if (!enabled) { managerRef.current?.clearBacktestMarkers(); return; } if (!chartDataReady) return; applySignalMarkers(); }, [enabled, signalMarker, applySignalMarkers, chartDataReady]); useEffect(() => { if (!enabled) return; const wrap = canvasWrapRef.current; if (!wrap) return; const timers = [200, 600, 1200].map(delay => setTimeout(() => { if (!canvasWrapRef.current) return; if (managerRef.current?.hasMainSeries()) { syncChartLayout(); applySignalMarkers(); } else if (!chartReloadTriggeredRef.current && delay >= 600 && chartDataReady) { chartReloadTriggeredRef.current = true; setChartReloadTick(t => t + 1); } }, delay), ); const ro = new ResizeObserver(() => syncChartLayout()); ro.observe(wrap); return () => { timers.forEach(clearTimeout); ro.disconnect(); }; }, [market, timeframe, enabled, chartDataReady, syncChartLayout, applySignalMarkers, fillHeight]); useEffect(() => { if (!enabled || !chartDataReady) return; if (!managerRef.current?.hasMainSeries() && !chartReloadTriggeredRef.current) { chartReloadTriggeredRef.current = true; setChartReloadTick(t => t + 1); } }, [enabled, chartDataReady]); useEffect(() => { if (!enabled) return; const t = setTimeout(() => syncChartLayout(), 80); return () => clearTimeout(t); }, [fillHeight, enabled, syncChartLayout]); useEffect(() => { if (!enabled || !useUpbit) return; void pinCandleWatch(market, timeframeToCandleType(timeframe)); if (timeframeToCandleType(timeframe) !== '1m') { void pinCandleWatch(market, '1m'); } }, [market, timeframe, enabled, useUpbit]); if (!enabled) { return (
차트 대기
); } return (
{(!settingsLoaded || isLoading) &&
로딩…
} {isLoadingMore && (
)} {chartDataReady && ( i.id).join(',')}`} chartVisible bars={bars} barsMarket={barsMarket} market={market} timeframe={timeframe} chartType={'candlestick' as ChartType} theme={theme} mode={'chart' as ChartMode} indicators={chartIndicators} drawingTool="cursor" drawings={[] as Drawing[]} logScale={false} drawingsLocked drawingsVisible={false} onCrosshair={noop as (d: LegendData | null) => void} onManagerReady={handleManagerReady} onAddDrawing={noop as (d: Drawing) => void} onCandlesReady={handleCandlesReady} magnifierEnabled={false} volumeVisible={false} showHoverToolbar={false} seriesPriceLabelsEnabled={false} paneLayoutClamp showPaneLegend showChartRightToolbar={false} showCandleOverlayControls candleOverlayToggles={candleOverlayToggles} onToggleCandleOverlay={handleToggleCandleOverlay} crosshairInfoVisible={false} /> )}
); }; export default TradeSignalMiniChart;