328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
/**
|
|
* 매매 시그널 알림 행 — 캔들·지표 미니 차트 카드
|
|
*/
|
|
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, invalidateWorkspaceChartIndicatorsCache } 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<Props> = ({
|
|
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<ChartManager | null>(null);
|
|
const canvasWrapRef = useRef<HTMLDivElement | null>(null);
|
|
const [chartReloadTick, setChartReloadTick] = useState(0);
|
|
const chartReloadTriggeredRef = useRef(false);
|
|
const pendingBarRef = useRef<OHLCVBar | null>(null);
|
|
const barsMarketRef = useRef<string | null>(null);
|
|
const chartLiveReadyRef = useRef(false);
|
|
const marketRef = useRef(market);
|
|
marketRef.current = market;
|
|
const signalMarkerRef = useRef(signalMarker);
|
|
signalMarkerRef.current = signalMarker;
|
|
|
|
const [wsIndicators, setWsIndicators] = useState<IndicatorConfig[]>([]);
|
|
const [wsRevision, setWsRevision] = useState(0);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
invalidateWorkspaceChartIndicatorsCache();
|
|
void ensureWorkspaceChartIndicators().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<HTMLElement>('.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;
|
|
}
|
|
applySignalMarkers();
|
|
}, [enabled, signalMarker, applySignalMarkers, barsReady]);
|
|
|
|
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();
|
|
} 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, 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 (
|
|
<div className={`tnl-mini-chart-placeholder${fillHeight ? ' tnl-mini-chart-placeholder--fill' : ''}`}>
|
|
차트 대기
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={canvasWrapRef}
|
|
className={[
|
|
'tnl-mini-chart-canvas',
|
|
'slot-chart-wrap',
|
|
fillHeight ? 'tnl-mini-chart-canvas--fill' : '',
|
|
].filter(Boolean).join(' ')}
|
|
>
|
|
{(!settingsLoaded || isLoading) && <div className="tnl-mini-chart-loading">로딩…</div>}
|
|
{isLoadingMore && (
|
|
<div className="chart-history-loading">
|
|
<div className="loading-spinner" style={{ width: 12, height: 12 }} />
|
|
</div>
|
|
)}
|
|
{chartDataReady && (
|
|
<TradingChart
|
|
key={`${market}-${timeframe}-${chartReloadTick}-${chartRealtimeSource}-${settingsRevision}-${wsRevision}-${indicatorStyleKey}-${chartIndicators.map(i => 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}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TradeSignalMiniChart;
|