분석결과 레포트 수정
This commit is contained in:
@@ -9287,6 +9287,70 @@ html.desktop-client .tmb-logo-version {
|
||||
.brd-pnl-net-label{ font-size: 11.5px; color: var(--text3); }
|
||||
.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-table { width: 100%; border-collapse: collapse; font-size: 11.5px; }
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
* BacktestDashboard — 카드 기반 대시보드 (재사용 가능)
|
||||
* BacktestResultModal — 드래그 팝업으로 Dashboard를 감싸는 래퍼
|
||||
*/
|
||||
import React from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { BacktestAnalysis, BacktestSignal, BacktestStats } from '../utils/backendApi';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
import { repairUtf8Mojibake } from '../utils/textEncoding';
|
||||
import type { EvaluationAllocationReport } from './backtest/BacktestAnalysisReportModal';
|
||||
import {
|
||||
pct,
|
||||
pctAbs,
|
||||
@@ -58,6 +59,9 @@ export interface BacktestDashboardProps {
|
||||
reportMode?: boolean;
|
||||
/** false — 거래 시그널 섹션 숨김 (별도 패널에 표시) */
|
||||
hideSignals?: boolean;
|
||||
/** 전략평가 — 일괄/분할 매수 방식별 분석 */
|
||||
evaluationAllocations?: EvaluationAllocationReport[];
|
||||
reportSignalFilterNote?: string;
|
||||
}
|
||||
|
||||
// ── 시그널 타입 ───────────────────────────────────────────────────────────
|
||||
@@ -70,8 +74,17 @@ export function BacktestDashboard({
|
||||
analysis, signals, strategyName, symbol, timeframe, barCount, createdAt,
|
||||
reportMode = false,
|
||||
hideSignals = false,
|
||||
evaluationAllocations,
|
||||
reportSignalFilterNote,
|
||||
}: 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) {
|
||||
return (
|
||||
<div className="brd-empty-state">
|
||||
@@ -120,6 +133,49 @@ export function BacktestDashboard({
|
||||
</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개 ── */}
|
||||
<div className="brd-row">
|
||||
<div className="brd-kpi-grid">
|
||||
|
||||
@@ -48,7 +48,7 @@ import { resolveBarSignalHighlight } from '../utils/strategyEvaluationBarSignals
|
||||
import type { OHLCVBar } from '../types';
|
||||
import BacktestAnalysisReportModal 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 StrategyEvaluationAiVerifyPanel from './strategyEvaluation/StrategyEvaluationAiVerifyPanel';
|
||||
import StrategyEvaluationChartSettingsTab from './strategyEvaluation/StrategyEvaluationChartSettingsTab';
|
||||
@@ -604,7 +604,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
||||
timeframe: chartTimeframe,
|
||||
symbol: market,
|
||||
strategyName: selectedStrategy.name,
|
||||
settings: btSettings,
|
||||
settings: { ...btSettings, positionMode: 'SIGNAL_ONLY' },
|
||||
indicatorParams,
|
||||
evaluationBarCount,
|
||||
});
|
||||
@@ -614,14 +614,16 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
const model = buildChartBacktestReportModel({
|
||||
analysis: res.analysis ?? null,
|
||||
stats: res.stats ?? null,
|
||||
const initialCapital = btSettings.initialCapital ?? res.analysis?.initialCapital ?? 10_000_000;
|
||||
const model = buildStrategyEvaluationReportModel({
|
||||
signals: res.signals,
|
||||
initialCapital,
|
||||
strategyName: selectedStrategy.name ?? strategyLabel,
|
||||
symbol: market.replace(/^KRW-/, ''),
|
||||
timeframe: chartTimeframe,
|
||||
barCount: bars.length,
|
||||
firstBarClose: bars[0]?.close,
|
||||
lastBarClose: bars[bars.length - 1]?.close,
|
||||
});
|
||||
if (!model) {
|
||||
window.alert('분석 데이터를 생성할 수 없습니다.');
|
||||
|
||||
@@ -8,6 +8,14 @@ import { BacktestDashboard } from '../BacktestResultModal';
|
||||
import { printReportDocument } from '../../utils/printReportDocument';
|
||||
import '../../styles/backtestReportPrint.css';
|
||||
|
||||
export type EvaluationAllocationMode = 'full' | 'split';
|
||||
|
||||
export interface EvaluationAllocationReport {
|
||||
mode: EvaluationAllocationMode;
|
||||
label: string;
|
||||
analysis: BacktestAnalysis;
|
||||
}
|
||||
|
||||
export interface BacktestAnalysisReportModel {
|
||||
analysis: BacktestAnalysis;
|
||||
signals: BacktestSignal[];
|
||||
@@ -18,6 +26,9 @@ export interface BacktestAnalysisReportModel {
|
||||
barCount: number;
|
||||
createdAt?: string;
|
||||
reportKind: 'backtest' | 'live';
|
||||
/** 전략평가 — 일괄/분할 매수 방식별 분석 */
|
||||
evaluationAllocations?: EvaluationAllocationReport[];
|
||||
reportSignalFilterNote?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -135,6 +146,8 @@ export default function BacktestAnalysisReportModal({ open, onClose, model }: Pr
|
||||
barCount={model.barCount}
|
||||
createdAt={model.createdAt}
|
||||
reportMode
|
||||
evaluationAllocations={model.evaluationAllocations}
|
||||
reportSignalFilterNote={model.reportSignalFilterNote}
|
||||
/>
|
||||
<footer className="btd-report-print-footer">
|
||||
<p>본 레포트는 GoldenChart 백테스팅·투자분석 결과이며 투자 권유가 아닙니다.</p>
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface TradeHistoryRow {
|
||||
price: number;
|
||||
quantity: number;
|
||||
pnlPct?: number;
|
||||
/** 매도 체결 시 실현 손익 (원) */
|
||||
pnlAmount?: number;
|
||||
}
|
||||
|
||||
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:
|
||||
'분석 기간 전체 캔들 기준으로, 최초 매수 이전 매도·최종 매도 이후 매수 시그널은 제외했습니다. 매도는 보유 물량 전량 매도로 가정합니다.',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user