분석결과 레포트 수정

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
+2
View File
@@ -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:
'분석 기간 전체 캔들 기준으로, 최초 매수 이전 매도·최종 매도 이후 매수 시그널은 제외했습니다. 매도는 보유 물량 전량 매도로 가정합니다.',
};
}