투자관리 차트탭 기능 수정
This commit is contained in:
@@ -1,23 +1,26 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react';
|
||||
import TradingChart from '../TradingChart';
|
||||
import type { BacktestSignal, StrategyDto } from '../../utils/backendApi';
|
||||
import { loadStrategy } from '../../utils/backendApi';
|
||||
import type { ChartType, IndicatorConfig, OHLCVBar, Theme, Timeframe } from '../../types';
|
||||
import { loadAnalysisCandles } from '../../utils/analysisChartData';
|
||||
import type { ChartManager } from '../../utils/ChartManager';
|
||||
import { useHistoryLoader } from '../../hooks/useHistoryLoader';
|
||||
import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../../hooks/useHistoryLoader';
|
||||
import { isUpbitMarket } from '../../utils/upbitApi';
|
||||
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
|
||||
import { normalizeChartTimeframe, timeframeBarSeconds } from '../../utils/backtestUiUtils';
|
||||
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
||||
import { useAppSettings } from '../../hooks/useAppSettings';
|
||||
import { enrichIndicatorConfig, getIndicatorDef } from '../../utils/indicatorRegistry';
|
||||
import {
|
||||
chartPaneFlexRatio,
|
||||
countNonOverlayIndicatorPanes,
|
||||
} from '../../utils/strategyOscillatorSeries';
|
||||
import { normalizeSmaConfig } from '../../utils/smaConfig';
|
||||
import {
|
||||
buildVirtualTradingChartIndicators,
|
||||
resolveStrategyPrimaryTimeframe,
|
||||
} from '../../utils/strategyToChartIndicators';
|
||||
import BacktestOscillatorPanes from './BacktestOscillatorPanes';
|
||||
|
||||
interface Props {
|
||||
symbol: string;
|
||||
@@ -28,6 +31,10 @@ interface Props {
|
||||
signals: BacktestSignal[];
|
||||
strategyId?: number | null;
|
||||
theme?: Theme;
|
||||
/** 과거 캔들 추가 로드 후 (차트에 반영된 전체 봉) */
|
||||
onHistoryBarsLoaded?: (bars: OHLCVBar[]) => void;
|
||||
/** 과거 로드·시그널 재계산 중 UI */
|
||||
onHistoryLoadingChange?: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
@@ -59,6 +66,11 @@ function defaultOverlayIndicators(
|
||||
return [cfg];
|
||||
}
|
||||
|
||||
const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true;
|
||||
|
||||
/**
|
||||
* 백테스트·투자관리 차트 — TradingChart 단일 엔진(실시간 차트와 동일 크로스헤어·시간축·우측 가격)
|
||||
*/
|
||||
const BacktestAnalysisChart: React.FC<Props> = ({
|
||||
symbol,
|
||||
timeframe: timeframeRaw,
|
||||
@@ -68,8 +80,9 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
||||
signals,
|
||||
strategyId,
|
||||
theme = 'dark',
|
||||
onHistoryBarsLoaded,
|
||||
onHistoryLoadingChange,
|
||||
}) => {
|
||||
const timeframe = normalizeChartTimeframe(timeframeRaw);
|
||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||
const { defaults: appDefaults } = useAppSettings();
|
||||
const [bars, setBars] = useState<OHLCVBar[]>([]);
|
||||
@@ -78,10 +91,21 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
||||
const [chartType] = useState<ChartType>('candlestick');
|
||||
const [strategy, setStrategy] = useState<StrategyDto | undefined>();
|
||||
const managerRef = useRef<ChartManager | null>(null);
|
||||
const logicalRangeUnsubRef = useRef<(() => void) | null>(null);
|
||||
const viewportUnsubRef = useRef<(() => void) | null>(null);
|
||||
const loadMoreRefRef = useRef<MutableRefObject<() => void>>({ current: () => {} });
|
||||
const signalsRef = useRef(signals);
|
||||
signalsRef.current = signals;
|
||||
const onHistoryBarsLoadedRef = useRef(onHistoryBarsLoaded);
|
||||
onHistoryBarsLoadedRef.current = onHistoryBarsLoaded;
|
||||
const [loadedBarCount, setLoadedBarCount] = useState<number | null>(null);
|
||||
const market = symbol.startsWith('KRW-') ? symbol : `KRW-${symbol}`;
|
||||
|
||||
const chartTimeframe = useMemo((): Timeframe => {
|
||||
const tf = strategy ? resolveStrategyPrimaryTimeframe(strategy) : timeframeRaw;
|
||||
return normalizeChartTimeframe(tf) as Timeframe;
|
||||
}, [strategy, timeframeRaw]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!strategyId) {
|
||||
setStrategy(undefined);
|
||||
@@ -97,10 +121,21 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
||||
const indicators = useMemo(() => {
|
||||
if (strategy) {
|
||||
const inds = buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig);
|
||||
if (inds.length) return inds.filter(i => i.timeframeVisibility?.[timeframe] !== false);
|
||||
if (inds.length) return inds.filter(i => i.timeframeVisibility?.[chartTimeframe] !== false);
|
||||
}
|
||||
return defaultOverlayIndicators(getParams);
|
||||
}, [strategy, getParams, getVisualConfig, timeframe]);
|
||||
}, [strategy, getParams, getVisualConfig, chartTimeframe]);
|
||||
|
||||
const auxPaneCount = useMemo(
|
||||
() => countNonOverlayIndicatorPanes(indicators, isOverlayType),
|
||||
[indicators],
|
||||
);
|
||||
|
||||
const paneAreaRatio = useMemo(() => {
|
||||
if (auxPaneCount <= 0) return null;
|
||||
const flex = chartPaneFlexRatio(auxPaneCount);
|
||||
return { candle: flex.candle, aux: flex.aux };
|
||||
}, [auxPaneCount]);
|
||||
|
||||
const effectiveBarCount = useMemo(() => {
|
||||
const barSec = timeframeBarSeconds(timeframeRaw);
|
||||
@@ -114,7 +149,8 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void loadAnalysisCandles(market, timeframe, toTimeSec, effectiveBarCount)
|
||||
setLoadedBarCount(null);
|
||||
void loadAnalysisCandles(market, chartTimeframe, toTimeSec, effectiveBarCount)
|
||||
.then(data => {
|
||||
if (cancelled) return;
|
||||
setBars(data);
|
||||
@@ -128,7 +164,7 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [market, timeframe, toTimeSec, effectiveBarCount]);
|
||||
}, [market, chartTimeframe, toTimeSec, effectiveBarCount]);
|
||||
|
||||
const applyMarkers = useCallback((attempt = 0) => {
|
||||
const mgr = managerRef.current;
|
||||
@@ -139,51 +175,127 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
||||
mgr.setBacktestMarkers(signalsRef.current, true);
|
||||
}, []);
|
||||
|
||||
const applyPaneRatio = useCallback((mgr: ChartManager) => {
|
||||
if (paneAreaRatio && paneAreaRatio.aux > 0) {
|
||||
mgr.setPaneAreaRatio(paneAreaRatio.candle, paneAreaRatio.aux);
|
||||
} else {
|
||||
mgr.setPaneAreaRatio(0, 0);
|
||||
}
|
||||
}, [paneAreaRatio]);
|
||||
|
||||
const handleBarsPrepended = useCallback((_added: number, total: number) => {
|
||||
setLoadedBarCount(total);
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr) return;
|
||||
onHistoryBarsLoadedRef.current?.(mgr.getRawBars());
|
||||
// 왼쪽 끝에 붙어 있으면 연속 로드
|
||||
requestAnimationFrame(() => {
|
||||
const lr = mgr.getVisibleLogicalRange();
|
||||
if (lr && lr.from < LOAD_MORE_TRIGGER) {
|
||||
loadMoreRefRef.current.current();
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const { isLoadingMore, loadMoreRef } = useHistoryLoader({
|
||||
symbol: market,
|
||||
timeframe: chartTimeframe,
|
||||
isUpbit: isUpbitMarket(market),
|
||||
managerRef,
|
||||
onBarsPrepended: handleBarsPrepended,
|
||||
});
|
||||
|
||||
loadMoreRefRef.current = loadMoreRef;
|
||||
|
||||
const setupHistoryScroll = useCallback((mgr: ChartManager) => {
|
||||
logicalRangeUnsubRef.current?.();
|
||||
viewportUnsubRef.current?.();
|
||||
|
||||
const tryLoadMore = () => {
|
||||
if (!mgr.hasMainSeries()) return;
|
||||
const lr = mgr.getVisibleLogicalRange();
|
||||
if (lr && lr.from < LOAD_MORE_TRIGGER) {
|
||||
loadMoreRefRef.current.current();
|
||||
}
|
||||
};
|
||||
|
||||
logicalRangeUnsubRef.current = mgr.subscribeVisibleLogicalRange(r => {
|
||||
if (r && r.from < LOAD_MORE_TRIGGER) {
|
||||
loadMoreRefRef.current.current();
|
||||
}
|
||||
});
|
||||
// 커스텀 패닝은 LWC 논리범위 이벤트를 안 낼 수 있어 뷰포트 알림에도 연결
|
||||
viewportUnsubRef.current = mgr.subscribeViewport(tryLoadMore);
|
||||
requestAnimationFrame(tryLoadMore);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onHistoryLoadingChange?.(isLoadingMore);
|
||||
}, [isLoadingMore, onHistoryLoadingChange]);
|
||||
|
||||
useEffect(() => () => {
|
||||
logicalRangeUnsubRef.current?.();
|
||||
logicalRangeUnsubRef.current = null;
|
||||
viewportUnsubRef.current?.();
|
||||
viewportUnsubRef.current = null;
|
||||
}, []);
|
||||
|
||||
const onManagerReady = useCallback((mgr: ChartManager) => {
|
||||
managerRef.current = mgr;
|
||||
setupHistoryScroll(mgr);
|
||||
setLoadedBarCount(mgr.getRawBarsLength());
|
||||
applyPaneRatio(mgr);
|
||||
applyMarkers();
|
||||
}, [applyMarkers]);
|
||||
}, [applyMarkers, applyPaneRatio, setupHistoryScroll]);
|
||||
|
||||
const onCandlesReady = useCallback(() => {
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr) return;
|
||||
setupHistoryScroll(mgr);
|
||||
applyPaneRatio(mgr);
|
||||
applyMarkers();
|
||||
}, [applyMarkers]);
|
||||
setLoadedBarCount(mgr.getRawBarsLength());
|
||||
}, [applyMarkers, applyPaneRatio, setupHistoryScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
applyMarkers();
|
||||
}, [signals, bars, applyMarkers]);
|
||||
|
||||
useHistoryLoader({
|
||||
symbol: market,
|
||||
timeframe,
|
||||
isUpbit: isUpbitMarket(market),
|
||||
managerRef,
|
||||
});
|
||||
useEffect(() => {
|
||||
const mgr = managerRef.current;
|
||||
if (mgr) applyPaneRatio(mgr);
|
||||
}, [paneAreaRatio, applyPaneRatio]);
|
||||
|
||||
const symLabel = market.replace(/^KRW-/, '');
|
||||
const stratTf = strategy ? resolveStrategyPrimaryTimeframe(strategy) : timeframe;
|
||||
const stratTf = chartTimeframe;
|
||||
|
||||
const priceLabels = appDefaults.chartSeriesPriceLabels !== false;
|
||||
|
||||
return (
|
||||
<div className="btd-analysis-chart">
|
||||
<div className="btd-analysis-toolbar">
|
||||
<div className="btd-analysis-toolbar-left">
|
||||
<span className="btd-analysis-select btd-analysis-select--static">{symLabel}</span>
|
||||
<span className="btd-analysis-select btd-analysis-select--static">{timeframe}</span>
|
||||
<span className="btd-analysis-select btd-analysis-select--static">{chartTimeframe}</span>
|
||||
<span className="btd-analysis-select btd-analysis-select--static">Candle</span>
|
||||
</div>
|
||||
<div className="btd-analysis-toolbar-right">
|
||||
<span className="btd-analysis-tool" title="자석 모드">⊕</span>
|
||||
<span className="btd-analysis-tool" title="드로잉">✎</span>
|
||||
<span className="btd-analysis-tool" title="삭제">⌫</span>
|
||||
<span className="btd-analysis-count">{effectiveBarCount.toLocaleString()}봉</span>
|
||||
<span className="btd-analysis-count">
|
||||
{(loadedBarCount ?? effectiveBarCount).toLocaleString()}봉
|
||||
{isLoadingMore && ' · 과거 로드…'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="btd-analysis-main">
|
||||
<div className="btd-analysis-canvas-wrap">
|
||||
<div className="btd-analysis-canvas-wrap btd-analysis-canvas-wrap--live">
|
||||
{loading && <div className="btd-analysis-loading">차트 로딩…</div>}
|
||||
{error && !loading && <div className="btd-analysis-error">{error}</div>}
|
||||
{!loading && !error && bars.length > 0 && (
|
||||
<TradingChart
|
||||
key={`${market}-${timeframe}-${toTimeSec}-${effectiveBarCount}`}
|
||||
key={`${market}-${chartTimeframe}-${toTimeSec}-${effectiveBarCount}-${indicators.map(i => i.id).join(',')}`}
|
||||
bars={bars}
|
||||
barsMarket={market}
|
||||
market={market}
|
||||
@@ -201,13 +313,20 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
||||
onAddDrawing={noop}
|
||||
displayTimezone={DEFAULT_DISPLAY_TIMEZONE}
|
||||
paneSeparatorOptions={appDefaults.chartPaneSeparator}
|
||||
paneLayoutClamp
|
||||
paneAreaRatio={paneAreaRatio}
|
||||
showPaneLegend
|
||||
crosshairInfoVisible
|
||||
candleAreaPriceLabelsEnabled={priceLabels}
|
||||
indicatorAreaPriceLabelsEnabled={priceLabels}
|
||||
seriesPriceLabelsEnabled={priceLabels}
|
||||
showHoverToolbar
|
||||
/>
|
||||
)}
|
||||
{!loading && !error && bars.length === 0 && (
|
||||
<div className="btd-analysis-error">표시할 캔들 데이터가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
{!loading && bars.length > 0 && <BacktestOscillatorPanes bars={bars} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user