전략평가 화면 수익률 요약표시

This commit is contained in:
Macbook
2026-06-19 07:44:14 +09:00
parent 2514e0adae
commit 994a2024c7
5 changed files with 415 additions and 51 deletions
@@ -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<BacktestRunTimeframeChoice>(BACKTEST_STRATEGY_TIMEFRAME);
const [bars, setBars] = useState<OHLCVBar[]>([]);
const [evalWindowMeta, setEvalWindowMeta] = useState<StrategyEvaluationWindowMeta>({
evaluationBarCount: BACKTEST_DISPLAY_BAR_COUNT,
warmupBarCount: 0,
isAtLatestWindow: true,
windowEndTimeSec: null,
});
const [selectedBarIndex, setSelectedBarIndex] = useState(0);
const [snapshot, setSnapshot] = useState<VirtualIndicatorSnapshot | undefined>();
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<BacktestSignal[]>([]);
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<BacktestAnalysisReportModel | null>(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) {
<h1 className="btd-header-title"> </h1>
<span className="btd-header-sub">Strategy Match Analysis</span>
</div>
<div className="btd-header-actions seval-header-analysis">
{selectedStrategy && signalScanRunning && (
<span className="seval-header-analysis-busy"> </span>
)}
{selectedStrategy && analysisSummary && !signalScanRunning && (
<span
className="seval-analysis-summary"
title="일괄매수(최초 전액) · 분할매수(40/30/잔여) · 레포트와 동일 필터 기준"
>
<span className="seval-analysis-item">
:<strong>{padEvaluationSignalCount(analysisSummary.buyCount)}</strong>
</span>
<span className="seval-analysis-sep" aria-hidden>·</span>
<span className="seval-analysis-item">
:<strong>{padEvaluationSignalCount(analysisSummary.sellCount)}</strong>
</span>
<span className="seval-analysis-sep" aria-hidden>·</span>
<span className={`seval-analysis-item seval-analysis-return${
analysisSummary.fullBuyReturnPct > 0
? ' seval-analysis-return--pos'
: analysisSummary.fullBuyReturnPct < 0
? ' seval-analysis-return--neg'
: ''
}`}>
:<strong>{formatEvaluationReturnPct(analysisSummary.fullBuyReturnPct)}</strong>
</span>
<span className="seval-analysis-sep" aria-hidden>·</span>
<span className={`seval-analysis-item seval-analysis-return${
analysisSummary.splitBuyReturnPct > 0
? ' seval-analysis-return--pos'
: analysisSummary.splitBuyReturnPct < 0
? ' seval-analysis-return--neg'
: ''
}`}>
:<strong>{formatEvaluationReturnPct(analysisSummary.splitBuyReturnPct)}</strong>
</span>
</span>
)}
</div>
</header>
<div className="btd-body">
@@ -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}
@@ -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<Props> = ({
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<Props> = ({
const backtestGenRef = useRef(0);
const evaluationBarCountRef = useRef(BACKTEST_DISPLAY_BAR_COUNT);
const warmupBarCountRef = useRef(0);
const latestBarTimeSecRef = useRef<number | null>(null);
type EvaluationWindowAnchor = 'latest' | number;
const [windowAnchor, setWindowAnchor] = useState<EvaluationWindowAnchor>('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<Props> = ({
}, [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<Props> = ({
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<Props> = ({
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<Props> = ({
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<Props> = ({
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<Props> = ({
onMarketSearchOpenChange?.(showMarketSearch);
}, [showMarketSearch, onMarketSearchOpenChange]);
useEffect(() => {
onChartLoadingChange?.(loading);
}, [loading, onChartLoadingChange]);
const selectedBar = bars[selectedBarIndex] ?? null;
return (
@@ -655,11 +738,6 @@ const StrategyEvaluationChart: React.FC<Props> = ({
{new Date(selectedBar.time * 1000).toLocaleString('ko-KR')}
</span>
)}
{strategy && signals.length > 0 && !backtestRunning && (
<span className="seval-chart-signal-count" title="조건 충족 매매 시그널">
{signals.length}
</span>
)}
{strategy && backtestRunning && (
<span className="seval-chart-backtest-busy"> </span>
)}
@@ -818,37 +896,67 @@ const StrategyEvaluationChart: React.FC<Props> = ({
</div>
<div className="seval-chart-bottom-toolbar">
<button
type="button"
className="btd-btn btd-btn--ghost seval-chart-nav--step"
title="이전 봉 (←)"
onClick={() => stepBar(-1)}
disabled={selectedBarIndex <= 0 || bars.length === 0}
>
</button>
<span className="seval-chart-count">
{bars.length > 0 ? `${selectedBarIndex + 1} / ${bars.length}` : '0봉'}
</span>
<button
type="button"
className="btd-btn btd-btn--ghost seval-chart-nav--step"
title="다음 봉 ()"
onClick={() => stepBar(1)}
disabled={selectedBarIndex >= bars.length - 1 || bars.length === 0}
>
</button>
<span className="seval-chart-bottom-divider" aria-hidden />
<button
type="button"
className="btd-btn btd-btn--ghost"
title="최신 봉"
onClick={goLatest}
disabled={bars.length === 0 || selectedBarIndex >= bars.length - 1}
>
</button>
<div className="seval-chart-bottom-side seval-chart-bottom-side--left">
<button
type="button"
className="btd-btn btd-btn--ghost seval-chart-nav--window"
title={`이전 ${BACKTEST_DISPLAY_BAR_COUNT}봉 구간 평가 (워밍업 포함 재조회)`}
onClick={() => shiftEvaluationWindow('prev')}
disabled={loading || bars.length === 0}
>
</button>
</div>
<div className="seval-chart-bottom-center">
<button
type="button"
className="btd-btn btd-btn--ghost seval-chart-nav--step"
title="이전 봉 ()"
onClick={() => stepBar(-1)}
disabled={selectedBarIndex <= 0 || bars.length === 0}
>
</button>
<span className="seval-chart-count">
{bars.length > 0 ? `${selectedBarIndex + 1} / ${bars.length}` : '0봉'}
{bars.length > 0 && (
<span className="seval-chart-eval-range">
{isAtLatestWindow ? ' · 최신 평가' : ' · 과거 평가'}
{` · ${Math.min(displayEvalBarCount, bars.length)}`}
</span>
)}
</span>
<button
type="button"
className="btd-btn btd-btn--ghost seval-chart-nav--step"
title="다음 봉 (→)"
onClick={() => stepBar(1)}
disabled={selectedBarIndex >= bars.length - 1 || bars.length === 0}
>
</button>
<span className="seval-chart-bottom-divider" aria-hidden />
<button
type="button"
className="btd-btn btd-btn--ghost"
title="최신 봉"
onClick={goLatest}
disabled={bars.length === 0 || selectedBarIndex >= bars.length - 1}
>
</button>
</div>
<div className="seval-chart-bottom-side seval-chart-bottom-side--right">
<button
type="button"
className="btd-btn btd-btn--ghost seval-chart-nav--window"
title={`다음 ${BACKTEST_DISPLAY_BAR_COUNT}봉 구간 평가`}
onClick={() => shiftEvaluationWindow('next')}
disabled={loading || bars.length === 0 || isAtLatestWindow}
>
</button>
</div>
</div>
{showMarketSearch && marketBtnRef.current && ReactDOM.createPortal(
+80 -2
View File
@@ -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;
}
+50
View File
@@ -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,
@@ -285,6 +285,60 @@ const ALLOCATION_LABELS: Record<BuyAllocationMode, string> = {
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[];