diff --git a/frontend/src/components/StrategyEvaluationPage.tsx b/frontend/src/components/StrategyEvaluationPage.tsx index f70ab50..3d541f9 100644 --- a/frontend/src/components/StrategyEvaluationPage.tsx +++ b/frontend/src/components/StrategyEvaluationPage.tsx @@ -48,8 +48,14 @@ import { resolveBarSignalHighlight } from '../utils/strategyEvaluationBarSignals import type { OHLCVBar } from '../types'; import BacktestAnalysisReportModal from './backtest/BacktestAnalysisReportModal'; import type { BacktestAnalysisReportModel } from './backtest/BacktestAnalysisReportModal'; -import { buildStrategyEvaluationReportModel } from '../utils/strategyEvaluationReport'; -import { BACKTEST_DISPLAY_BAR_COUNT } from '../utils/backtestWarmup'; +import { + buildStrategyEvaluationReportModel, + buildStrategyEvaluationToolbarSummary, + formatEvaluationReturnPct, + padEvaluationSignalCount, +} from '../utils/strategyEvaluationReport'; +import { BACKTEST_DISPLAY_BAR_COUNT, resolveEvaluationBarSlice } from '../utils/backtestWarmup'; +import type { StrategyEvaluationWindowMeta } from './strategyEvaluation/StrategyEvaluationChart'; import StrategyEvaluationAiVerifyPanel from './strategyEvaluation/StrategyEvaluationAiVerifyPanel'; import StrategyEvaluationChartSettingsTab from './strategyEvaluation/StrategyEvaluationChartSettingsTab'; import PaletteDragOverlay from './strategyEditor/PaletteDragOverlay'; @@ -104,6 +110,12 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { const [timeframeChoice, setTimeframeChoice] = useState(BACKTEST_STRATEGY_TIMEFRAME); const [bars, setBars] = useState([]); + const [evalWindowMeta, setEvalWindowMeta] = useState({ + evaluationBarCount: BACKTEST_DISPLAY_BAR_COUNT, + warmupBarCount: 0, + isAtLatestWindow: true, + windowEndTimeSec: null, + }); const [selectedBarIndex, setSelectedBarIndex] = useState(0); const [snapshot, setSnapshot] = useState(); const [evalLoading, setEvalLoading] = useState(false); @@ -112,6 +124,8 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { const [paramsRevision, setParamsRevision] = useState(0); const [chartLoading, setChartLoading] = useState(false); const [backtestSignals, setBacktestSignals] = useState([]); + const [signalScanRunning, setSignalScanRunning] = useState(false); + const [initialCapital, setInitialCapital] = useState(10_000_000); const [editorModalOpen, setEditorModalOpen] = useState(false); const [reportOpen, setReportOpen] = useState(false); const [reportModel, setReportModel] = useState(null); @@ -385,6 +399,20 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { setBacktestSignals(signals); }, []); + useEffect(() => { + void loadBacktestSettings().then(s => { + if (s?.initialCapital) setInitialCapital(s.initialCapital); + }); + }, []); + + const analysisSummary = useMemo(() => { + if (!selectedStrategy) return null; + return buildStrategyEvaluationToolbarSummary(backtestSignals, { + initialCapital, + symbol: market.replace(/^KRW-/, ''), + }); + }, [selectedStrategy, backtestSignals, initialCapital, market]); + const refreshEvaluation = useCallback(async () => { if (!selectedStrategyId || !selectedStrategy) { setSnapshot(undefined); @@ -488,8 +516,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { const reportCacheKey = useMemo(() => { if (!selectedStrategyId || bars.length < 10) return null; const lastBarTime = bars[bars.length - 1]?.time ?? 0; - return `${selectedStrategyId}|${market}|${chartTimeframe}|${paramsRevision}|${bars.length}|${lastBarTime}`; - }, [selectedStrategyId, market, chartTimeframe, paramsRevision, bars]); + const windowKey = evalWindowMeta.windowEndTimeSec ?? 'latest'; + return `${selectedStrategyId}|${market}|${chartTimeframe}|${paramsRevision}|${bars.length}|${lastBarTime}|${windowKey}`; + }, [selectedStrategyId, market, chartTimeframe, paramsRevision, bars, evalWindowMeta.windowEndTimeSec]); const reportDisabled = !selectedStrategyId || !selectedStrategy || bars.length < 10 || chartLoading; @@ -517,6 +546,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { try { const indicatorParams = buildEvalParamsFromStrategy(selectedStrategy, getEvalParams); + const evaluationBarCount = evalWindowMeta.evaluationBarCount; const context = buildStrategyEvaluationAiVerifyContext({ strategy: selectedStrategy, strategyId: selectedStrategyId, @@ -530,7 +560,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { chartSignals: backtestSignals, barSignalHighlight, indicatorParams, - evaluationBarCount: Math.min(BACKTEST_DISPLAY_BAR_COUNT, bars.length), + evaluationBarCount, }); setAiVerifyContext(context); @@ -568,6 +598,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { backtestSignals, barSignalHighlight, getEvalParams, + evalWindowMeta.evaluationBarCount, ]); const handleOpenAiVerify = useCallback(() => { @@ -590,7 +621,8 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { try { const [btSettings] = await Promise.all([loadBacktestSettings()]); const indicatorParams = buildEvalParamsFromStrategy(selectedStrategy, getEvalParams); - const evaluationBarCount = Math.min(BACKTEST_DISPLAY_BAR_COUNT, bars.length); + const evaluationBarCount = evalWindowMeta.evaluationBarCount; + const { evalBars } = resolveEvaluationBarSlice(bars, evaluationBarCount); const res = await runBacktest({ strategyId: selectedStrategyId, bars: bars.map(b => ({ @@ -621,9 +653,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { strategyName: selectedStrategy.name ?? strategyLabel, symbol: market.replace(/^KRW-/, ''), timeframe: chartTimeframe, - barCount: bars.length, - firstBarClose: bars[0]?.close, - lastBarClose: bars[bars.length - 1]?.close, + barCount: evaluationBarCount, + firstBarClose: evalBars[0]?.close, + lastBarClose: evalBars[evalBars.length - 1]?.close, }); if (!model) { window.alert('분석 데이터를 생성할 수 없습니다.'); @@ -650,6 +682,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { chartTimeframe, market, strategyLabel, + evalWindowMeta.evaluationBarCount, ]); return ( @@ -659,6 +692,45 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {

전략 평가

Strategy Match Analysis +
+ {selectedStrategy && signalScanRunning && ( + 시그널 계산 중… + )} + {selectedStrategy && analysisSummary && !signalScanRunning && ( + + + 매수:{padEvaluationSignalCount(analysisSummary.buyCount)}건 + + · + + 매도:{padEvaluationSignalCount(analysisSummary.sellCount)}건 + + · + 0 + ? ' seval-analysis-return--pos' + : analysisSummary.fullBuyReturnPct < 0 + ? ' seval-analysis-return--neg' + : '' + }`}> + 일괄:{formatEvaluationReturnPct(analysisSummary.fullBuyReturnPct)} + + · + 0 + ? ' seval-analysis-return--pos' + : analysisSummary.splitBuyReturnPct < 0 + ? ' seval-analysis-return--neg' + : '' + }`}> + 분할:{formatEvaluationReturnPct(analysisSummary.splitBuyReturnPct)} + + + )} +
@@ -819,12 +891,14 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { selectedBarIndex={selectedBarIndex} onSelectedBarIndexChange={setSelectedBarIndex} onBarsLoaded={setBars} + onEvalWindowMetaChange={setEvalWindowMeta} paramsRevision={paramsRevision} appliedIndicatorParams={appliedIndicatorParams} getParamsOverride={getEvalParams} getVisualConfigOverride={getVisualConfig} evalLoading={evalLoading} onSignalsChange={handleSignalsChange} + onBacktestRunningChange={setSignalScanRunning} onOpenReport={() => { void handleOpenReport(); }} reportDisabled={reportDisabled} reportLoading={reportLoading} diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx index 1d869de..930487d 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx @@ -19,6 +19,8 @@ import { fetchLiveConditionScanSignals } from '../../utils/backendApi'; import type { MarketInfo, TickerData } from '../../hooks/useMarketTicker'; import { BACKTEST_DISPLAY_BAR_COUNT, + computeNextWindowEndTime, + computePreviousWindowEndTime, fetchBacktestCandleBundle, } from '../../utils/backtestWarmup'; import type { EvalIndicatorParams } from '../../utils/strategyEvaluationParams'; @@ -52,6 +54,13 @@ import { } from '../../utils/backtestRunTimeframe'; import { timeframeBarSeconds } from '../../utils/backtestUiUtils'; +export interface StrategyEvaluationWindowMeta { + evaluationBarCount: number; + warmupBarCount: number; + isAtLatestWindow: boolean; + windowEndTimeSec: number | null; +} + interface Props { market: string; timeframe: Timeframe; @@ -68,6 +77,7 @@ interface Props { selectedBarIndex: number; onSelectedBarIndexChange: (index: number) => void; onBarsLoaded?: (bars: OHLCVBar[]) => void; + onEvalWindowMetaChange?: (meta: StrategyEvaluationWindowMeta) => void; paramsRevision?: number; /** 사용자가 저장한 지표 파라미터 — null 이면 백테스트도 서버 DB 설정 사용 (live-conditions 와 동일) */ appliedIndicatorParams?: EvalIndicatorParams | null; @@ -88,6 +98,7 @@ interface Props { evalLoading?: boolean; /** 백테스트 시그널 목록 변경 시 (우측 패널 하이라이트 등) */ onSignalsChange?: (signals: BacktestSignal[]) => void; + onBacktestRunningChange?: (running: boolean) => void; /** 투자분석 상세 레포트 (인쇄·PDF) */ onOpenReport?: () => void; reportDisabled?: boolean; @@ -132,12 +143,14 @@ const StrategyEvaluationChart: React.FC = ({ selectedBarIndex, onSelectedBarIndexChange, onBarsLoaded, + onEvalWindowMetaChange, paramsRevision = 0, appliedIndicatorParams = null, getParamsOverride, getVisualConfigOverride, evalLoading = false, onSignalsChange, + onBacktestRunningChange, onOpenReport, reportDisabled = false, reportLoading = false, @@ -181,6 +194,11 @@ const StrategyEvaluationChart: React.FC = ({ const backtestGenRef = useRef(0); const evaluationBarCountRef = useRef(BACKTEST_DISPLAY_BAR_COUNT); + const warmupBarCountRef = useRef(0); + const latestBarTimeSecRef = useRef(null); + type EvaluationWindowAnchor = 'latest' | number; + const [windowAnchor, setWindowAnchor] = useState('latest'); + const [displayEvalBarCount, setDisplayEvalBarCount] = useState(BACKTEST_DISPLAY_BAR_COUNT); const signalsRef = useRef(signals); signalsRef.current = signals; const chartType: ChartType = 'candlestick'; @@ -428,8 +446,25 @@ const StrategyEvaluationChart: React.FC = ({ }, [signals, onSignalsChange]); useEffect(() => { - onChartLoadingChange?.(loading); - }, [loading, onChartLoadingChange]); + onBacktestRunningChange?.(backtestRunning); + }, [backtestRunning, onBacktestRunningChange]); + + const emitEvalWindowMeta = useCallback(( + evalCount: number, + warmupCount: number, + anchor: EvaluationWindowAnchor, + ) => { + onEvalWindowMetaChange?.({ + evaluationBarCount: evalCount, + warmupBarCount: warmupCount, + isAtLatestWindow: anchor === 'latest', + windowEndTimeSec: anchor === 'latest' ? null : anchor, + }); + }, [onEvalWindowMetaChange]); + + useEffect(() => { + setWindowAnchor('latest'); + }, [market, timeframe, strategy?.id, paramsRevision]); useEffect(() => { let cancelled = false; @@ -440,6 +475,8 @@ const StrategyEvaluationChart: React.FC = ({ managerRef.current?.clearBacktestMarkers(); setLoading(true); setError(null); + + const toTimeSec = windowAnchor === 'latest' ? undefined : windowAnchor; void fetchBacktestCandleBundle({ market, timeframe, @@ -447,12 +484,19 @@ const StrategyEvaluationChart: React.FC = ({ buyDsl: strategy?.buyCondition, sellDsl: strategy?.sellCondition, indicatorParams, + toTimeSec, }) - .then(({ bars: data, evaluationBarCount: evalCount }) => { + .then(({ bars: data, evaluationBarCount: evalCount, warmupBarCount }) => { if (cancelled) return; evaluationBarCountRef.current = evalCount; + warmupBarCountRef.current = warmupBarCount; + setDisplayEvalBarCount(evalCount); + if (windowAnchor === 'latest') { + latestBarTimeSecRef.current = data[data.length - 1]?.time ?? null; + } setBars(data); onBarsLoaded?.(data); + emitEvalWindowMeta(evalCount, warmupBarCount, windowAnchor); if (data.length > 0) { onSelectedBarIndexChange(data.length - 1); } @@ -466,7 +510,17 @@ const StrategyEvaluationChart: React.FC = ({ if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; - }, [market, timeframe, strategy?.id, paramsRevision, onBarsLoaded, onSelectedBarIndexChange]); + }, [ + market, + timeframe, + strategy?.id, + paramsRevision, + indicatorParamsKey, + windowAnchor, + onBarsLoaded, + onSelectedBarIndexChange, + emitEvalWindowMeta, + ]); const scrollToBar = useCallback((barIdx: number) => { const mgr = managerRef.current; @@ -523,6 +577,31 @@ const StrategyEvaluationChart: React.FC = ({ onSelectedBarIndexChange(bars.length - 1); }, [bars.length, onSelectedBarIndexChange]); + const isAtLatestWindow = windowAnchor === 'latest'; + + const shiftEvaluationWindow = useCallback((direction: 'prev' | 'next') => { + if (loading || bars.length === 0) return; + const evalCount = evaluationBarCountRef.current; + const barSec = timeframeBarSeconds(timeframe); + + if (direction === 'next') { + if (isAtLatestWindow) return; + const next = computeNextWindowEndTime( + bars, + evalCount, + latestBarTimeSecRef.current, + barSec, + ); + if (next == null) return; + setWindowAnchor(next === 'latest' ? 'latest' : next); + return; + } + + const prevEnd = computePreviousWindowEndTime(bars, evalCount, barSec); + if (prevEnd == null) return; + setWindowAnchor(prevEnd); + }, [loading, bars, timeframe, isAtLatestWindow]); + useEffect(() => { const mgr = chartMgr; if (!mgr || bars.length === 0) return; @@ -601,6 +680,10 @@ const StrategyEvaluationChart: React.FC = ({ onMarketSearchOpenChange?.(showMarketSearch); }, [showMarketSearch, onMarketSearchOpenChange]); + useEffect(() => { + onChartLoadingChange?.(loading); + }, [loading, onChartLoadingChange]); + const selectedBar = bars[selectedBarIndex] ?? null; return ( @@ -655,11 +738,6 @@ const StrategyEvaluationChart: React.FC = ({ {new Date(selectedBar.time * 1000).toLocaleString('ko-KR')} )} - {strategy && signals.length > 0 && !backtestRunning && ( - - 시그널 {signals.length}건 - - )} {strategy && backtestRunning && ( 시그널 계산 중… )} @@ -818,37 +896,67 @@ const StrategyEvaluationChart: React.FC = ({
- - - {bars.length > 0 ? `${selectedBarIndex + 1} / ${bars.length}` : '0봉'} - - - - +
+ +
+
+ + + {bars.length > 0 ? `${selectedBarIndex + 1} / ${bars.length}` : '0봉'} + {bars.length > 0 && ( + + {isAtLatestWindow ? ' · 최신 평가' : ' · 과거 평가'} + {` · ${Math.min(displayEvalBarCount, bars.length)}봉`} + + )} + + + + +
+
+ +
{showMarketSearch && marketBtnRef.current && ReactDOM.createPortal( diff --git a/frontend/src/styles/strategyEvaluation.css b/frontend/src/styles/strategyEvaluation.css index bfa7c70..c772cc4 100644 --- a/frontend/src/styles/strategyEvaluation.css +++ b/frontend/src/styles/strategyEvaluation.css @@ -945,6 +945,53 @@ white-space: nowrap; } +.seval-header-analysis { + flex: 1; + justify-content: flex-end; + min-width: 0; +} + +.seval-header-analysis-busy { + font-size: 0.72rem; + color: var(--se-text-muted); + white-space: nowrap; +} + +.seval-analysis-summary { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + font-size: 0.75rem; + font-weight: 500; + color: var(--se-text-muted); + white-space: nowrap; + padding: 4px 12px; + border-radius: 6px; + background: color-mix(in srgb, var(--se-text-muted) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--se-border, #30363d) 80%, transparent); +} + +.seval-analysis-item strong { + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--se-text, var(--text)); + margin: 0 2px; +} + +.seval-analysis-sep { + opacity: 0.45; + user-select: none; +} + +.seval-analysis-return--pos strong { + color: #3fb950; +} + +.seval-analysis-return--neg strong { + color: #f85149; +} + .seval-chart-backtest-busy { font-size: 0.66rem; color: var(--se-text-muted); @@ -967,15 +1014,46 @@ .seval-chart-bottom-toolbar { flex-shrink: 0; - display: flex; + display: grid; + grid-template-columns: 1fr auto 1fr; align-items: center; - justify-content: center; gap: 8px; padding: 8px 10px; border-top: 1px solid var(--btd-divider, var(--se-border)); background: var(--se-header-bg, var(--se-bg-elevated)); } +.seval-chart-bottom-side { + display: flex; + align-items: center; +} + +.seval-chart-bottom-side--left { + justify-content: flex-start; +} + +.seval-chart-bottom-side--right { + justify-content: flex-end; +} + +.seval-chart-bottom-center { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-wrap: wrap; +} + +.seval-chart-nav--window { + min-width: 108px; + font-size: 0.72rem; +} + +.seval-chart-eval-range { + color: var(--se-text-muted); + font-size: 0.62rem; +} + .seval-chart-nav--step { min-width: 88px; } diff --git a/frontend/src/utils/backtestWarmup.ts b/frontend/src/utils/backtestWarmup.ts index 244ec17..3895d32 100644 --- a/frontend/src/utils/backtestWarmup.ts +++ b/frontend/src/utils/backtestWarmup.ts @@ -195,6 +195,56 @@ export async function fetchBacktestCandleBundle(opts: { return { bars, evaluationBarCount, warmupBarCount }; } +/** 로드된 캔들에서 실제 Rule 평가 구간(마지막 evaluationBarCount 봉) */ +export function resolveEvaluationBarSlice( + bars: OHLCVBar[], + evaluationBarCount: number, +): { evalBars: OHLCVBar[]; firstEvalIndex: number } { + const count = Math.min(Math.max(evaluationBarCount, 1), bars.length); + const firstEvalIndex = Math.max(0, bars.length - count); + return { evalBars: bars.slice(firstEvalIndex), firstEvalIndex }; +} + +/** + * 이전 200봉 평가 구간 — 현재 평가 창 직전 봉을 새 창의 종료 시점(to)으로 사용. + * 워밍업은 fetchBacktestCandleBundle 이 별도로 앞쪽에 추가 요청한다. + */ +export function computePreviousWindowEndTime( + bars: OHLCVBar[], + evaluationBarCount: number, + barSec: number, +): number | null { + if (bars.length === 0) return null; + const { firstEvalIndex } = resolveEvaluationBarSlice(bars, evaluationBarCount); + if (firstEvalIndex > 0) { + return bars[firstEvalIndex - 1].time; + } + return bars[0].time - Math.max(barSec, 1); +} + +/** + * 이후 200봉 평가 구간 — 현재 창에서 evaluationBarCount 만큼 앞으로 이동. + * 'latest' = 최신 데이터 창으로 복귀, null = 이미 최신(이동 불가). + */ +export function computeNextWindowEndTime( + bars: OHLCVBar[], + evaluationBarCount: number, + latestBarTimeSec: number | null, + barSec: number, +): 'latest' | number | null { + if (bars.length === 0) return null; + const lastBarTime = bars[bars.length - 1].time; + const tolerance = Math.max(barSec / 2, 1); + if (latestBarTimeSec != null && lastBarTime >= latestBarTimeSec - tolerance) { + return null; + } + const nextEndTime = lastBarTime + evaluationBarCount * Math.max(barSec, 1); + if (latestBarTimeSec != null && nextEndTime >= latestBarTimeSec - tolerance) { + return 'latest'; + } + return nextEndTime; +} + /** 이미 로드된 캔들(차트 rawBars)에 대한 평가·워밍업 구간 계산 */ export function resolveEvaluationFromLoadedBars( barCount: number, diff --git a/frontend/src/utils/strategyEvaluationReport.ts b/frontend/src/utils/strategyEvaluationReport.ts index 1f9e7a5..aa902ca 100644 --- a/frontend/src/utils/strategyEvaluationReport.ts +++ b/frontend/src/utils/strategyEvaluationReport.ts @@ -285,6 +285,60 @@ const ALLOCATION_LABELS: Record = { split: '분할매수 (1차 40% · 2차 30% · 3차 잔여 전액)', }; +export interface StrategyEvaluationToolbarSummary { + buyCount: number; + sellCount: number; + /** 일괄매수 수익률 (소수) */ + fullBuyReturnPct: number; + /** 분할매수 수익률 (소수) */ + splitBuyReturnPct: number; +} + +export function padEvaluationSignalCount(n: number): string { + return String(Math.max(0, Math.floor(n))).padStart(2, '0'); +} + +export function formatEvaluationReturnPct(v: number): string { + if (!Number.isFinite(v) || v === 0) return '0.00%'; + return `${v >= 0 ? '+' : ''}${(v * 100).toFixed(2)}%`; +} + +/** 차트 상단 툴바 — 필터·일괄/분할매수 시뮬레이션 기준 요약 */ +export function buildStrategyEvaluationToolbarSummary( + signals: BacktestSignal[], + opts?: { + initialCapital?: number; + symbol?: string; + }, +): StrategyEvaluationToolbarSummary { + const filtered = filterSignalsForEvaluationReport(signals); + let buyCount = 0; + let sellCount = 0; + for (const s of filtered) { + if (BUY_TYPES.has(s.type)) buyCount += 1; + else if (SELL_TYPES.has(s.type)) sellCount += 1; + } + + const empty = { buyCount: 0, sellCount: 0, fullBuyReturnPct: 0, splitBuyReturnPct: 0 }; + if (filtered.length === 0) return empty; + + const capital = opts?.initialCapital ?? 10_000_000; + const symbol = opts?.symbol ?? ''; + const stillHolding = isStillHolding(filtered); + + const fullSim = simulateEvaluationPortfolio(filtered, capital, symbol, 'full'); + const splitSim = simulateEvaluationPortfolio(filtered, capital, symbol, 'split'); + const fullAnalysis = buildAnalysisFromSimulation(fullSim, capital, 0, stillHolding); + const splitAnalysis = buildAnalysisFromSimulation(splitSim, capital, 0, stillHolding); + + return { + buyCount, + sellCount, + fullBuyReturnPct: fullAnalysis.totalReturnPct, + splitBuyReturnPct: splitAnalysis.totalReturnPct, + }; +} + /** 전략평가 화면 — 필터·시뮬레이션 기반 분석 레포트 모델 */ export function buildStrategyEvaluationReportModel(opts: { signals: BacktestSignal[];