분석결과 레포트 수정
This commit is contained in:
@@ -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