투자관리 차트탭 기능 수정

This commit is contained in:
Macbook
2026-05-31 16:31:25 +09:00
parent 1b7c39e11f
commit 78f00dbaa5
14 changed files with 935 additions and 111 deletions
@@ -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>
);
@@ -0,0 +1,96 @@
import React, { useMemo } from 'react';
import type { OHLCVBar } from '../../types';
import type { StrategyDto } from '../../utils/backendApi';
import {
buildOscillatorPaneData,
collectStrategyOscillatorPanes,
defaultOscillatorPaneSpecs,
} from '../../utils/strategyOscillatorSeries';
function spark(values: number[], w: number, h: number, min?: number, max?: number): string {
if (!values.length) return '';
const lo = min ?? Math.min(...values);
const hi = max ?? Math.max(...values);
const range = hi - lo || 1;
return values.map((v, i) => {
const x = (i / Math.max(values.length - 1, 1)) * w;
const y = h - ((v - lo) / range) * (h - 6) - 3;
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
}
interface Props {
bars: OHLCVBar[];
strategy?: StrategyDto;
/** 부모 flex 영역 높이에 맞춰 pane 균등 분배 */
fillHeight?: boolean;
}
/** 전략 조건에 사용된 보조지표 하단 pane (없으면 RSI·MACD 기본) */
const StrategyOscillatorPanes: React.FC<Props> = ({ bars, strategy, fillHeight = false }) => {
const panes = useMemo(() => {
const specs = collectStrategyOscillatorPanes(strategy);
const useSpecs = specs.length > 0 ? specs : defaultOscillatorPaneSpecs();
return buildOscillatorPaneData(bars, useSpecs);
}, [bars, strategy]);
if (panes.length === 0) return null;
return (
<div className={`btd-osc-stack${fillHeight ? ' btd-osc-stack--fill' : ''}`}>
{panes.map(pane => {
const last = pane.values[pane.values.length - 1];
const lo = pane.refLines?.[0];
const hi = pane.refLines?.[pane.refLines.length - 1];
const path = spark(
pane.values,
280,
52,
lo != null && hi != null && pane.spec.registryType === 'RSI' ? 0 : undefined,
lo != null && hi != null && pane.spec.registryType === 'RSI' ? 100 : undefined,
);
return (
<div key={pane.spec.key} className={`btd-osc-pane${fillHeight ? ' btd-osc-pane--fill' : ''}`}>
<div className="btd-osc-head">
<span className="btd-osc-label">{pane.spec.label}</span>
<span className="btd-osc-val">
{last != null
? (pane.spec.registryType === 'RSI' ? last.toFixed(1) : last.toFixed(2))
: ''}
</span>
</div>
<svg className="btd-osc-svg" viewBox="0 0 280 52" preserveAspectRatio="none">
{pane.refLines?.map((r, i) => {
const min = pane.refLines![0];
const max = pane.refLines![pane.refLines!.length - 1];
const y = 52 - ((r - min) / (max - min || 1)) * 44 - 4;
return (
<line
key={i}
x1="0"
y1={y}
x2="280"
y2={y}
className={`btd-osc-ref${i === 0 || i === pane.refLines!.length - 1 ? ' btd-osc-ref--hi' : ''}`}
/>
);
})}
{!pane.refLines?.length && (
<line x1="0" y1="26" x2="280" y2="26" className="btd-osc-ref" />
)}
<path
d={path}
className="btd-osc-line"
fill="none"
stroke={pane.color}
strokeWidth="1.5"
/>
</svg>
</div>
);
})}
</div>
);
};
export default StrategyOscillatorPanes;