분석결과 레포트 수정

This commit is contained in:
Macbook
2026-06-19 07:19:32 +09:00
parent 8bceadc55b
commit 2514e0adae
7 changed files with 565 additions and 8 deletions
@@ -44,7 +44,92 @@ class ComplexStrategyDslAdapterTest {
seedStorage(MARKET, "5m", buildSyntheticSeries("5m", 80)); seedStorage(MARKET, "5m", buildSyntheticSeries("5m", 80));
} }
/** Logic Expression 예시와 동일 구조의 매수 조건 */ /** OR(AND, AND) — 4개 조건을 평탄 OR로 처리하면 true가 되는 케이스에서 false여야 함 */
@Test
void orAndNested_doesNotFlattenChildrenToOr() throws Exception {
JsonNode buy = MAPPER.readTree("""
{
"type": "OR",
"children": [
{
"type": "AND",
"children": [
{ "type": "CONDITION", "condition": {
"indicatorType": "MA", "conditionType": "GT",
"leftField": "CLOSE_PRICE", "rightField": "K_999999999", "candleRange": 1
}},
{ "type": "CONDITION", "condition": {
"indicatorType": "MA", "conditionType": "GT",
"leftField": "CLOSE_PRICE", "rightField": "K_1", "candleRange": 1
}}
]
},
{
"type": "AND",
"children": [
{ "type": "CONDITION", "condition": {
"indicatorType": "MA", "conditionType": "GT",
"leftField": "CLOSE_PRICE", "rightField": "K_999999999", "candleRange": 1
}},
{ "type": "CONDITION", "condition": {
"indicatorType": "MA", "conditionType": "GT",
"leftField": "CLOSE_PRICE", "rightField": "K_1", "candleRange": 1
}}
]
}
]
}
""");
Rule rule = adapter.toRule(buy, primary1m, Map.of());
int idx = primary1m.getEndIndex();
// 평탄 OR(4조건)이면 close>K_1 하나만으로 true — AND 중첩이면 각 분기 false → 전체 false
assertFalse(rule.isSatisfied(idx, null),
"OR(AND)는 분기 내 모든 조건이 충족될 때만 true — 단일 true 자식으로는 true가 되면 안 됨");
}
@Test
void orAndNested_oneFullAndBranchSatisfies() throws Exception {
JsonNode buy = MAPPER.readTree("""
{
"type": "OR",
"children": [
{
"type": "AND",
"children": [
{ "type": "CONDITION", "condition": {
"indicatorType": "MA", "conditionType": "GT",
"leftField": "CLOSE_PRICE", "rightField": "K_1", "candleRange": 1
}},
{ "type": "CONDITION", "condition": {
"indicatorType": "MA", "conditionType": "GT",
"leftField": "CLOSE_PRICE", "rightField": "K_0", "candleRange": 1
}}
]
},
{
"type": "AND",
"children": [
{ "type": "CONDITION", "condition": {
"indicatorType": "MA", "conditionType": "GT",
"leftField": "CLOSE_PRICE", "rightField": "K_999999999", "candleRange": 1
}},
{ "type": "CONDITION", "condition": {
"indicatorType": "MA", "conditionType": "GT",
"leftField": "CLOSE_PRICE", "rightField": "K_1", "candleRange": 1
}}
]
}
]
}
""");
Rule rule = adapter.toRule(buy, primary1m, Map.of());
assertTrue(rule.isSatisfied(primary1m.getEndIndex(), null),
"OR(AND) — 한 분기의 AND가 모두 true이면 전체 true");
}
/** Logic Expression 예시와 동일 구조의 매수 조건 */
@Test @Test
void buyCondition_compilesAndEvaluates() throws Exception { void buyCondition_compilesAndEvaluates() throws Exception {
JsonNode buy = MAPPER.readTree(""" JsonNode buy = MAPPER.readTree("""
+64
View File
@@ -9287,6 +9287,70 @@ html.desktop-client .tmb-logo-version {
.brd-pnl-net-label{ font-size: 11.5px; color: var(--text3); } .brd-pnl-net-label{ font-size: 11.5px; color: var(--text3); }
.brd-pnl-net-val { font-size: 15px; font-weight: 800; } .brd-pnl-net-val { font-size: 15px; font-weight: 800; }
/* ── 전략평가 — 매수 방식별 비교 ── */
.brd-eval-filter-note {
margin: 0 0 10px;
padding: 8px 10px;
font-size: 11px;
line-height: 1.45;
color: var(--text3);
background: var(--bg3);
border-radius: 6px;
}
.brd-alloc-tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.brd-alloc-tab {
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg2);
color: var(--text2);
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.brd-alloc-tab--active {
border-color: var(--brd-blue);
background: color-mix(in srgb, var(--brd-blue) 12%, var(--bg2));
color: var(--brd-blue);
}
.brd-alloc-compare {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.brd-alloc-card {
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg2);
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.brd-alloc-card--active {
border-color: var(--brd-blue);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--brd-blue) 35%, transparent);
}
.brd-alloc-card-label {
font-size: 11px;
color: var(--text3);
margin-bottom: 6px;
}
.brd-alloc-card-return {
font-size: 22px;
font-weight: 800;
margin-bottom: 4px;
}
.brd-alloc-card-sub {
font-size: 11px;
color: var(--text2);
}
.brd-card--full { grid-column: 1 / -1; }
/* ── 시그널 테이블 ── */ /* ── 시그널 테이블 ── */
.brd-sig-scroll { max-height: 200px; overflow-y: auto; } .brd-sig-scroll { max-height: 200px; overflow-y: auto; }
.brd-sig-table { width: 100%; border-collapse: collapse; font-size: 11.5px; } .brd-sig-table { width: 100%; border-collapse: collapse; font-size: 11.5px; }
@@ -4,10 +4,11 @@
* BacktestDashboard — 카드 기반 대시보드 (재사용 가능) * BacktestDashboard — 카드 기반 대시보드 (재사용 가능)
* BacktestResultModal — 드래그 팝업으로 Dashboard를 감싸는 래퍼 * BacktestResultModal — 드래그 팝업으로 Dashboard를 감싸는 래퍼
*/ */
import React from 'react'; import React, { useMemo, useState } from 'react';
import type { BacktestAnalysis, BacktestSignal, BacktestStats } from '../utils/backendApi'; import type { BacktestAnalysis, BacktestSignal, BacktestStats } from '../utils/backendApi';
import { useDraggablePanel } from '../hooks/useDraggablePanel'; import { useDraggablePanel } from '../hooks/useDraggablePanel';
import { repairUtf8Mojibake } from '../utils/textEncoding'; import { repairUtf8Mojibake } from '../utils/textEncoding';
import type { EvaluationAllocationReport } from './backtest/BacktestAnalysisReportModal';
import { import {
pct, pct,
pctAbs, pctAbs,
@@ -58,6 +59,9 @@ export interface BacktestDashboardProps {
reportMode?: boolean; reportMode?: boolean;
/** false — 거래 시그널 섹션 숨김 (별도 패널에 표시) */ /** false — 거래 시그널 섹션 숨김 (별도 패널에 표시) */
hideSignals?: boolean; hideSignals?: boolean;
/** 전략평가 — 일괄/분할 매수 방식별 분석 */
evaluationAllocations?: EvaluationAllocationReport[];
reportSignalFilterNote?: string;
} }
// ── 시그널 타입 ─────────────────────────────────────────────────────────── // ── 시그널 타입 ───────────────────────────────────────────────────────────
@@ -70,8 +74,17 @@ export function BacktestDashboard({
analysis, signals, strategyName, symbol, timeframe, barCount, createdAt, analysis, signals, strategyName, symbol, timeframe, barCount, createdAt,
reportMode = false, reportMode = false,
hideSignals = false, hideSignals = false,
evaluationAllocations,
reportSignalFilterNote,
}: BacktestDashboardProps) { }: BacktestDashboardProps) {
const a = analysis; const [allocMode, setAllocMode] = useState<'full' | 'split'>('split');
const displayAnalysis = useMemo(() => {
if (!evaluationAllocations?.length) return analysis;
return evaluationAllocations.find(e => e.mode === allocMode)?.analysis ?? analysis;
}, [analysis, evaluationAllocations, allocMode]);
const a = displayAnalysis;
if (!a) { if (!a) {
return ( return (
<div className="brd-empty-state"> <div className="brd-empty-state">
@@ -120,6 +133,49 @@ export function BacktestDashboard({
</div> </div>
</div> </div>
{evaluationAllocations && evaluationAllocations.length >= 2 && (
<div className="brd-row">
<div className="brd-card brd-card--full">
<SectionTitle icon="📋" title="매수 방식별 수익률 비교" />
{reportSignalFilterNote && (
<p className="brd-eval-filter-note">{reportSignalFilterNote}</p>
)}
<div className="brd-alloc-tabs">
{evaluationAllocations.map(item => (
<button
key={item.mode}
type="button"
className={`brd-alloc-tab${allocMode === item.mode ? ' brd-alloc-tab--active' : ''}`}
onClick={() => setAllocMode(item.mode)}
>
{item.label}
</button>
))}
</div>
<div className="brd-alloc-compare">
{evaluationAllocations.map(item => (
<div
key={item.mode}
className={`brd-alloc-card${allocMode === item.mode ? ' brd-alloc-card--active' : ''}`}
role="button"
tabIndex={0}
onClick={() => setAllocMode(item.mode)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') setAllocMode(item.mode); }}
>
<div className="brd-alloc-card-label">{item.label}</div>
<div className={`brd-alloc-card-return ${colorCls(item.analysis.totalReturnPct)}`}>
{pct(item.analysis.totalReturnPct)}
</div>
<div className="brd-alloc-card-sub">
{wonFmt(item.analysis.finalEquity)} · {pctAbs(item.analysis.winRate)}
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* ── Row 1: KPI 카드 6개 ── */} {/* ── Row 1: KPI 카드 6개 ── */}
<div className="brd-row"> <div className="brd-row">
<div className="brd-kpi-grid"> <div className="brd-kpi-grid">
@@ -48,7 +48,7 @@ import { resolveBarSignalHighlight } from '../utils/strategyEvaluationBarSignals
import type { OHLCVBar } from '../types'; import type { OHLCVBar } from '../types';
import BacktestAnalysisReportModal from './backtest/BacktestAnalysisReportModal'; import BacktestAnalysisReportModal from './backtest/BacktestAnalysisReportModal';
import type { BacktestAnalysisReportModel } from './backtest/BacktestAnalysisReportModal'; import type { BacktestAnalysisReportModel } from './backtest/BacktestAnalysisReportModal';
import { buildChartBacktestReportModel } from '../utils/backtestReportModel'; import { buildStrategyEvaluationReportModel } from '../utils/strategyEvaluationReport';
import { BACKTEST_DISPLAY_BAR_COUNT } from '../utils/backtestWarmup'; import { BACKTEST_DISPLAY_BAR_COUNT } from '../utils/backtestWarmup';
import StrategyEvaluationAiVerifyPanel from './strategyEvaluation/StrategyEvaluationAiVerifyPanel'; import StrategyEvaluationAiVerifyPanel from './strategyEvaluation/StrategyEvaluationAiVerifyPanel';
import StrategyEvaluationChartSettingsTab from './strategyEvaluation/StrategyEvaluationChartSettingsTab'; import StrategyEvaluationChartSettingsTab from './strategyEvaluation/StrategyEvaluationChartSettingsTab';
@@ -604,7 +604,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
timeframe: chartTimeframe, timeframe: chartTimeframe,
symbol: market, symbol: market,
strategyName: selectedStrategy.name, strategyName: selectedStrategy.name,
settings: btSettings, settings: { ...btSettings, positionMode: 'SIGNAL_ONLY' },
indicatorParams, indicatorParams,
evaluationBarCount, evaluationBarCount,
}); });
@@ -614,14 +614,16 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
return; return;
} }
const model = buildChartBacktestReportModel({ const initialCapital = btSettings.initialCapital ?? res.analysis?.initialCapital ?? 10_000_000;
analysis: res.analysis ?? null, const model = buildStrategyEvaluationReportModel({
stats: res.stats ?? null,
signals: res.signals, signals: res.signals,
initialCapital,
strategyName: selectedStrategy.name ?? strategyLabel, strategyName: selectedStrategy.name ?? strategyLabel,
symbol: market.replace(/^KRW-/, ''), symbol: market.replace(/^KRW-/, ''),
timeframe: chartTimeframe, timeframe: chartTimeframe,
barCount: bars.length, barCount: bars.length,
firstBarClose: bars[0]?.close,
lastBarClose: bars[bars.length - 1]?.close,
}); });
if (!model) { if (!model) {
window.alert('분석 데이터를 생성할 수 없습니다.'); window.alert('분석 데이터를 생성할 수 없습니다.');
@@ -8,6 +8,14 @@ import { BacktestDashboard } from '../BacktestResultModal';
import { printReportDocument } from '../../utils/printReportDocument'; import { printReportDocument } from '../../utils/printReportDocument';
import '../../styles/backtestReportPrint.css'; import '../../styles/backtestReportPrint.css';
export type EvaluationAllocationMode = 'full' | 'split';
export interface EvaluationAllocationReport {
mode: EvaluationAllocationMode;
label: string;
analysis: BacktestAnalysis;
}
export interface BacktestAnalysisReportModel { export interface BacktestAnalysisReportModel {
analysis: BacktestAnalysis; analysis: BacktestAnalysis;
signals: BacktestSignal[]; signals: BacktestSignal[];
@@ -18,6 +26,9 @@ export interface BacktestAnalysisReportModel {
barCount: number; barCount: number;
createdAt?: string; createdAt?: string;
reportKind: 'backtest' | 'live'; reportKind: 'backtest' | 'live';
/** 전략평가 — 일괄/분할 매수 방식별 분석 */
evaluationAllocations?: EvaluationAllocationReport[];
reportSignalFilterNote?: string;
} }
interface Props { interface Props {
@@ -135,6 +146,8 @@ export default function BacktestAnalysisReportModal({ open, onClose, model }: Pr
barCount={model.barCount} barCount={model.barCount}
createdAt={model.createdAt} createdAt={model.createdAt}
reportMode reportMode
evaluationAllocations={model.evaluationAllocations}
reportSignalFilterNote={model.reportSignalFilterNote}
/> />
<footer className="btd-report-print-footer"> <footer className="btd-report-print-footer">
<p> GoldenChart · .</p> <p> GoldenChart · .</p>
+2
View File
@@ -21,6 +21,8 @@ export interface TradeHistoryRow {
price: number; price: number;
quantity: number; quantity: number;
pnlPct?: number; pnlPct?: number;
/** 매도 체결 시 실현 손익 (원) */
pnlAmount?: number;
} }
const BUY_TYPES = new Set(['BUY']); const BUY_TYPES = new Set(['BUY']);
@@ -0,0 +1,335 @@
import type { BacktestAnalysis, BacktestSignal } from './backendApi';
import type {
BacktestAnalysisReportModel,
EvaluationAllocationReport,
} from '../components/backtest/BacktestAnalysisReportModal';
import type { EquityPoint, TradeHistoryRow } from './backtestEquity';
import { repairUtf8Mojibake } from './textEncoding';
import { getKoreanName } from './marketNameCache';
import { toUpbitMarket } from './backtestUiUtils';
export type BuyAllocationMode = 'full' | 'split';
const BUY_TYPES = new Set<BacktestSignal['type']>(['BUY']);
const SELL_TYPES = new Set<BacktestSignal['type']>(['SELL', 'SHORT_EXIT', 'PARTIAL_SELL']);
function sortSignals(signals: BacktestSignal[]): BacktestSignal[] {
return [...signals].sort((a, b) => {
const t = a.time - b.time;
if (t !== 0) return t;
return (a.barIndex ?? 0) - (b.barIndex ?? 0);
});
}
/** 최초 매수 이전 매도·최종 매도 이후 매수 시그널 제외 */
export function filterSignalsForEvaluationReport(signals: BacktestSignal[]): BacktestSignal[] {
const sorted = sortSignals(signals);
const firstBuyIdx = sorted.findIndex(s => BUY_TYPES.has(s.type));
if (firstBuyIdx < 0) return [];
let lastSellIdx = -1;
for (let i = sorted.length - 1; i >= 0; i--) {
if (SELL_TYPES.has(sorted[i].type)) {
lastSellIdx = i;
break;
}
}
return sorted.filter((s, i) => {
if (i < firstBuyIdx && SELL_TYPES.has(s.type)) return false;
if (lastSellIdx >= 0 && i > lastSellIdx && BUY_TYPES.has(s.type)) return false;
return true;
});
}
function buyCashFraction(buyIndexInCycle: number, mode: BuyAllocationMode): number | null {
if (mode === 'full') {
return buyIndexInCycle === 1 ? 1 : null;
}
if (buyIndexInCycle === 1) return 0.4;
if (buyIndexInCycle === 2) return 0.3;
if (buyIndexInCycle === 3) return 1;
return null;
}
export interface EvaluationSimulationResult {
curve: EquityPoint[];
trades: TradeHistoryRow[];
finalCash: number;
finalQty: number;
lastPrice: number;
}
/** 필터링된 시그널 기준 포트폴리오 시뮬레이션 (매도는 전량) */
export function simulateEvaluationPortfolio(
signals: BacktestSignal[],
initialCapital: number,
symbol: string,
mode: BuyAllocationMode,
): EvaluationSimulationResult {
const sorted = sortSignals(signals);
const curve: EquityPoint[] = [];
const trades: TradeHistoryRow[] = [];
if (sorted.length === 0) {
const now = Math.floor(Date.now() / 1000);
return {
curve: [{ time: now, equity: initialCapital }],
trades: [],
finalCash: initialCapital,
finalQty: 0,
lastPrice: 0,
};
}
let cash = initialCapital;
let qty = 0;
let totalCost = 0;
let buyIndexInCycle = 0;
let tradeId = 0;
let lastPrice = sorted[0].price;
const markEquity = (time: number, price: number) => {
lastPrice = price;
const equity = qty > 0 ? qty * price + cash : cash;
const prev = curve[curve.length - 1];
if (!prev || prev.time !== time || Math.abs(prev.equity - equity) > 0.01) {
curve.push({ time, equity });
}
};
markEquity(sorted[0].time, sorted[0].price);
for (const s of sorted) {
if (BUY_TYPES.has(s.type)) {
if (qty === 0) buyIndexInCycle = 0;
buyIndexInCycle += 1;
const frac = buyCashFraction(buyIndexInCycle, mode);
if (frac != null && cash > 0) {
const spend = cash * frac;
if (spend > 0 && s.price > 0) {
const buyQty = spend / s.price;
cash -= spend;
totalCost += spend;
qty += buyQty;
tradeId += 1;
trades.push({
id: tradeId,
time: s.time,
symbol,
side: 'buy',
price: s.price,
quantity: buyQty,
});
}
}
markEquity(s.time, s.price);
} else if (SELL_TYPES.has(s.type) && qty > 0) {
const proceeds = qty * s.price;
const pnl = proceeds - totalCost;
const avgEntry = totalCost / qty;
const pnlPct = avgEntry > 0 ? (s.price - avgEntry) / avgEntry : 0;
cash += proceeds;
tradeId += 1;
trades.push({
id: tradeId,
time: s.time,
symbol,
side: 'sell',
price: s.price,
quantity: qty,
pnlPct,
pnlAmount: pnl,
});
qty = 0;
totalCost = 0;
buyIndexInCycle = 0;
markEquity(s.time, s.price);
} else {
markEquity(s.time, s.price);
}
}
if (qty > 0) {
markEquity(sorted[sorted.length - 1].time, lastPrice);
} else if (curve.length === 1) {
const last = sorted[sorted.length - 1];
markEquity(last.time, last.price);
}
return { curve, trades, finalCash: cash, finalQty: qty, lastPrice };
}
function maxDrawdownFraction(curve: number[]): number {
if (curve.length < 2) return 0;
let peak = curve[0];
let mdd = 0;
for (const v of curve) {
if (v > peak) peak = v;
if (peak > 0) {
const dd = (peak - v) / peak;
if (dd > mdd) mdd = dd;
}
}
return mdd;
}
function maxRunupFraction(curve: number[]): number {
if (curve.length < 2) return 0;
let trough = curve[0];
let runup = 0;
for (const v of curve) {
if (v < trough) trough = v;
if (trough > 0) {
const ru = (v - trough) / trough;
if (ru > runup) runup = ru;
}
}
return runup;
}
function sharpeFromCurve(curve: number[]): number {
if (curve.length < 3) return 0;
const rets: number[] = [];
for (let i = 1; i < curve.length; i++) {
const prev = curve[i - 1];
if (prev > 0) rets.push((curve[i] - prev) / prev);
}
if (!rets.length) return 0;
const mean = rets.reduce((a, b) => a + b, 0) / rets.length;
const variance = rets.reduce((a, r) => a + (r - mean) ** 2, 0) / rets.length;
const std = Math.sqrt(variance);
if (std === 0) return mean > 0 ? 2.45 : 0;
return (mean / std) * Math.sqrt(252);
}
function buildAnalysisFromSimulation(
sim: EvaluationSimulationResult,
initialCapital: number,
buyAndHoldReturnPct: number,
stillHolding: boolean,
): BacktestAnalysis {
const curveValues = sim.curve.map(p => p.equity);
const finalEquity = curveValues.length ? curveValues[curveValues.length - 1] : initialCapital;
const holdingsValue = sim.finalQty > 0 ? sim.finalQty * sim.lastPrice : 0;
const cashBalance = sim.finalCash;
const sells = sim.trades.filter(t => t.side === 'sell');
let wins = 0;
let grossProfit = 0;
let grossLoss = 0;
let breakEven = 0;
for (const t of sells) {
const pnl = t.pnlAmount ?? 0;
if (pnl > 0) {
wins += 1;
grossProfit += pnl;
} else if (pnl < 0) {
grossLoss += Math.abs(pnl);
} else {
breakEven += 1;
}
}
const total = sells.length;
const profitLossRatio = grossLoss > 0 ? grossProfit / grossLoss : grossProfit > 0 ? 99 : 0;
const mdd = maxDrawdownFraction(curveValues);
const sharpe = sharpeFromCurve(curveValues);
const totalReturnPct = initialCapital > 0 ? (finalEquity - initialCapital) / initialCapital : 0;
const totalPnl = finalEquity - initialCapital;
const realizedFromSells = grossProfit - grossLoss; // net realized from closed rounds
return {
initialCapital,
finalEquity,
analysisMethod: stillHolding ? 'MARK_TO_MARKET' : 'REALIZED_ONLY',
cashBalance: stillHolding ? cashBalance : finalEquity,
holdingsValue: stillHolding && holdingsValue > 0 ? holdingsValue : undefined,
realizedPnl: stillHolding ? realizedFromSells : totalPnl,
unrealizedPnl: stillHolding ? totalPnl - realizedFromSells : undefined,
totalReturnPct,
totalProfitLoss: finalEquity - initialCapital,
grossProfit,
grossLoss,
avgReturnPct: total > 0 ? totalReturnPct / total : 0,
profitLossRatio: Math.min(profitLossRatio, 99),
numberOfPositions: total,
numberOfWinning: wins,
numberOfLosing: Math.max(0, total - wins - breakEven),
numberOfBreakEven: breakEven,
winRate: total > 0 ? wins / total : 0,
maxDrawdownPct: mdd,
maxRunupPct: maxRunupFraction(curveValues),
sharpeRatio: sharpe,
sortinoRatio: 0,
calmarRatio: mdd > 0 ? totalReturnPct / mdd : 0,
valueAtRisk95: 0,
expectedShortfall: 0,
buyAndHoldReturnPct,
vsBuyAndHold: buyAndHoldReturnPct !== 0 ? totalReturnPct / buyAndHoldReturnPct : 0,
};
}
function isStillHolding(signals: BacktestSignal[]): boolean {
let holding = false;
for (const s of sortSignals(signals)) {
if (BUY_TYPES.has(s.type)) holding = true;
else if (SELL_TYPES.has(s.type)) holding = false;
}
return holding;
}
const ALLOCATION_LABELS: Record<BuyAllocationMode, string> = {
full: '일괄매수 (최초 매수 시 전액)',
split: '분할매수 (1차 40% · 2차 30% · 3차 잔여 전액)',
};
/** 전략평가 화면 — 필터·시뮬레이션 기반 분석 레포트 모델 */
export function buildStrategyEvaluationReportModel(opts: {
signals: BacktestSignal[];
initialCapital: number;
strategyName: string;
symbol: string;
timeframe: string;
barCount: number;
createdAt?: string;
firstBarClose?: number;
lastBarClose?: number;
}): BacktestAnalysisReportModel | null {
const filtered = filterSignalsForEvaluationReport(opts.signals);
const stillHolding = isStillHolding(filtered);
const firstClose = opts.firstBarClose ?? filtered[0]?.price ?? 0;
const lastClose = opts.lastBarClose ?? filtered[filtered.length - 1]?.price ?? 0;
const buyAndHoldReturnPct = firstClose > 0 ? (lastClose - firstClose) / firstClose : 0;
const modes: BuyAllocationMode[] = ['full', 'split'];
const evaluationAllocations: EvaluationAllocationReport[] = modes.map(mode => {
const sim = simulateEvaluationPortfolio(filtered, opts.initialCapital, opts.symbol, mode);
return {
mode,
label: ALLOCATION_LABELS[mode],
analysis: buildAnalysisFromSimulation(sim, opts.initialCapital, buyAndHoldReturnPct, stillHolding),
};
});
const primary = evaluationAllocations.find(a => a.mode === 'split') ?? evaluationAllocations[0];
if (!primary) return null;
const market = toUpbitMarket(opts.symbol);
return {
analysis: primary.analysis,
signals: filtered,
strategyName: repairUtf8Mojibake(opts.strategyName || '전략'),
symbol: opts.symbol,
symbolKo: repairUtf8Mojibake(getKoreanName(market)),
timeframe: opts.timeframe,
barCount: opts.barCount || 300,
createdAt: opts.createdAt,
reportKind: 'backtest',
evaluationAllocations,
reportSignalFilterNote:
'분석 기간 전체 캔들 기준으로, 최초 매수 이전 매도·최종 매도 이후 매수 시그널은 제외했습니다. 매도는 보유 물량 전량 매도로 가정합니다.',
};
}