분석레포트 로직 수정

This commit is contained in:
Macbook
2026-06-08 10:08:39 +09:00
parent e11e1a46fa
commit c20c806c19
22 changed files with 254 additions and 108 deletions
+5 -27
View File
@@ -8,6 +8,7 @@ import type { LiveExecutionItem } from './liveExecutionGroups';
import type { EquityPoint, TradeHistoryRow } from './backtestEquity';
import { buildEquityFromSignals } from './backtestEquity';
import { paperTradesToSignals } from './liveExecutionGroups';
import { buildAnalysisFromPaperTrades } from './paperMetrics';
import { repairUtf8Mojibake } from './textEncoding';
export type AnalysisSourceTab = 'backtest' | 'live';
@@ -72,37 +73,14 @@ export function buildContextFromLive(item: LiveExecutionItem): AnalysisReportCon
const signals = paperTradesToSignals(item.trades);
const cap = 10_000_000;
const eq = buildEquityFromSignals(signals, cap, item.symbol);
const finalEquity = cap * (1 + item.totalReturnPct);
const analysis: BacktestAnalysis = {
initialCapital: cap,
finalEquity,
totalReturnPct: item.totalReturnPct,
totalProfitLoss: finalEquity - cap,
grossProfit: 0,
grossLoss: 0,
avgReturnPct: item.tradeCount > 0 ? item.totalReturnPct / item.tradeCount : 0,
profitLossRatio: item.profitFactor,
numberOfPositions: item.tradeCount,
numberOfWinning: Math.round(item.tradeCount * item.winRatePct),
numberOfLosing: Math.max(0, item.tradeCount - Math.round(item.tradeCount * item.winRatePct)),
numberOfBreakEven: 0,
winRate: item.winRatePct,
maxDrawdownPct: item.mddPct,
maxRunupPct: 0,
sharpeRatio: item.sharpeRatio,
sortinoRatio: 0,
calmarRatio: 0,
valueAtRisk95: 0,
expectedShortfall: 0,
buyAndHoldReturnPct: 0,
vsBuyAndHold: 0,
};
const analysis = buildAnalysisFromPaperTrades(item.trades, cap);
const timeframe = item.timeframe !== 'unknown' ? item.timeframe : '—';
return {
sourceTab: 'live',
strategyName: repairUtf8Mojibake(item.strategyLabel),
symbol: item.symbol,
timeframe: '1h',
barCount: 300,
timeframe,
barCount: item.trades.length,
createdAt: item.createdAt,
analysis,
signals,
+4
View File
@@ -702,6 +702,9 @@ export interface PaperTradeDto {
orderKind: string;
source: string;
strategyId?: number | null;
candleType?: string | null;
strategyName?: string | null;
executionType?: string | null;
price: number;
quantity: number;
grossAmount: number;
@@ -1208,6 +1211,7 @@ export interface BacktestResultRecord {
fromTime: number;
toTime: number;
settingsJson: string;
executionSnapshotJson?: string;
signalsJson: string;
analysisJson: string;
totalReturn: number;
+7 -34
View File
@@ -6,41 +6,17 @@ import type {
} from './backendApi';
import type { BacktestAnalysisReportModel } from '../components/backtest/BacktestAnalysisReportModal';
import type { LiveExecutionItem } from './liveExecutionGroups';
import { buildAnalysisFromPaperTrades } from './paperMetrics';
import { fmtListTimestamp, toUpbitMarket } from './backtestUiUtils';
import { getKoreanName } from './marketNameCache';
import { repairUtf8Mojibake } from './textEncoding';
/** 실시간 매매 탭 — KPI만 있는 경우 최소 분석 객체 */
/** 실시간 매매 탭 — 체결 이력 기반 분석 */
export function buildAnalysisFromLive(
item: LiveExecutionItem,
initialCapital = 10_000_000,
): BacktestAnalysis {
const finalEquity = initialCapital * (1 + item.totalReturnPct);
const winCount = Math.round(item.tradeCount * item.winRatePct);
return {
initialCapital,
finalEquity,
totalReturnPct: item.totalReturnPct,
totalProfitLoss: finalEquity - initialCapital,
grossProfit: 0,
grossLoss: 0,
avgReturnPct: item.tradeCount > 0 ? item.totalReturnPct / item.tradeCount : 0,
profitLossRatio: item.profitFactor,
numberOfPositions: item.tradeCount,
numberOfWinning: winCount,
numberOfLosing: Math.max(0, item.tradeCount - winCount),
numberOfBreakEven: 0,
winRate: item.winRatePct,
maxDrawdownPct: item.mddPct,
maxRunupPct: 0,
sharpeRatio: item.sharpeRatio,
sortinoRatio: 0,
calmarRatio: 0,
valueAtRisk95: 0,
expectedShortfall: 0,
buyAndHoldReturnPct: 0,
vsBuyAndHold: 0,
};
return buildAnalysisFromPaperTrades(item.trades, initialCapital);
}
function buildAnalysisFromStats(s: BacktestStats): BacktestAnalysis {
@@ -101,14 +77,11 @@ export function buildBacktestReportModel(
record: BacktestResultRecord,
analysis: BacktestAnalysis,
signals: BacktestSignal[],
strategyNamesById: Record<number, string> = {},
_strategyNamesById: Record<number, string> = {},
): BacktestAnalysisReportModel {
const market = toUpbitMarket(record.symbol);
const freshName = record.strategyId != null
? strategyNamesById[record.strategyId]
: undefined;
const strategyName = repairUtf8Mojibake(
freshName || record.strategyName || '전략 없음',
record.strategyName || '전략 없음',
);
return {
analysis,
@@ -126,10 +99,10 @@ export function buildBacktestReportModel(
export function buildLiveReportModel(
item: LiveExecutionItem,
signals: BacktestSignal[],
timeframe: string,
initialCapital = 10_000_000,
): BacktestAnalysisReportModel {
const market = toUpbitMarket(item.symbol);
const timeframe = item.timeframe !== 'unknown' ? item.timeframe : '—';
return {
analysis: buildAnalysisFromLive(item, initialCapital),
signals,
@@ -137,7 +110,7 @@ export function buildLiveReportModel(
symbol: item.symbol,
symbolKo: getKoreanName(market),
timeframe,
barCount: 300,
barCount: item.trades.length,
createdAt: item.createdAt ? fmtListTimestamp(item.createdAt) : undefined,
reportKind: 'live',
};
+39 -13
View File
@@ -1,16 +1,21 @@
import type { PaperSummaryDto, PaperTradeDto } from './backendApi';
import { computePaperMetrics } from './paperMetrics';
import { buildAnalysisFromPaperTrades, computePaperMetrics } from './paperMetrics';
export interface LiveExecutionItem {
id: string;
symbol: string;
strategyLabel: string;
sourceLabel: string;
/** 체결 시점 평가 시간봉 */
timeframe: string;
executionType?: string;
strategyId?: number | null;
trades: PaperTradeDto[];
createdAt: string;
totalReturnPct: number;
winRatePct: number;
tradeCount: number;
roundTripCount: number;
mddPct: number;
sharpeRatio: number;
profitFactor: number;
@@ -26,6 +31,27 @@ function sourceLabel(source: string): string {
return '수동';
}
function resolveStrategyLabel(
t: PaperTradeDto,
strategyNames: Record<number, string>,
): string {
if (t.strategyName) return t.strategyName;
if (t.strategyId != null) {
return strategyNames[t.strategyId] ?? `전략 #${t.strategyId}`;
}
return '수동 매매';
}
/** 자동매매: 종목·전략·시간봉·실행방식 기준 / 수동: 종목·일자 기준 */
function groupKey(t: PaperTradeDto): string {
if (t.source === 'STRATEGY') {
const tf = t.candleType ?? 'unknown';
const exec = t.executionType ?? '';
return `${t.symbol}|${t.strategyId ?? 0}|${tf}|${exec}|STRATEGY`;
}
return `${t.symbol}|manual|${dayKey(t.createdAt)}|MANUAL`;
}
/** 가상투자·모의투자 체결 이력을 실행 단위 목록으로 그룹 */
export function buildLiveExecutionItems(
trades: PaperTradeDto[],
@@ -36,7 +62,7 @@ export function buildLiveExecutionItems(
const groups = new Map<string, PaperTradeDto[]>();
for (const t of trades) {
const key = `${t.symbol}|${t.strategyId ?? 0}|${dayKey(t.createdAt)}|${t.source}`;
const key = groupKey(t);
const list = groups.get(key) ?? [];
list.push(t);
groups.set(key, list);
@@ -51,26 +77,25 @@ export function buildLiveExecutionItems(
);
const first = sorted[0];
const last = sorted[sorted.length - 1];
const analysis = buildAnalysisFromPaperTrades(sorted, initial);
const metrics = computePaperMetrics(sorted, summary);
const buyVol = sorted.filter(t => t.side === 'BUY').reduce((s, t) => s + t.netAmount, 0);
const sellVol = sorted.filter(t => t.side === 'SELL').reduce((s, t) => s + t.netAmount, 0);
const pnl = sellVol - buyVol;
const totalReturnPct = initial > 0 ? pnl / initial : 0;
return {
id: key,
symbol: first.symbol,
strategyLabel: first.strategyId != null
? (strategyNames[first.strategyId] ?? `전략 #${first.strategyId}`)
: '수동 매매',
strategyLabel: resolveStrategyLabel(first, strategyNames),
sourceLabel: sourceLabel(first.source),
timeframe: first.candleType ?? 'unknown',
executionType: first.executionType ?? undefined,
strategyId: first.strategyId,
trades: sorted,
createdAt: last.createdAt ?? first.createdAt ?? new Date().toISOString(),
totalReturnPct,
winRatePct: metrics.winRatePct / 100,
totalReturnPct: analysis.totalReturnPct,
winRatePct: analysis.winRate,
tradeCount: sorted.length,
mddPct: metrics.mddPct / 100,
sharpeRatio: metrics.sharpeRatio,
roundTripCount: analysis.numberOfPositions,
mddPct: analysis.maxDrawdownPct,
sharpeRatio: analysis.sharpeRatio,
profitFactor: metrics.profitFactor,
};
})
@@ -84,6 +109,7 @@ export function paperTradesToSignals(trades: PaperTradeDto[]) {
time: Math.floor(new Date(t.createdAt!).getTime() / 1000),
type: t.side as 'BUY' | 'SELL',
price: t.price,
quantity: t.quantity,
barIndex: 0,
}));
}
+41 -1
View File
@@ -1,4 +1,4 @@
import type { PaperSummaryDto, PaperTradeDto } from './backendApi';
import type { PaperSummaryDto, PaperTradeDto, BacktestAnalysis } from './backendApi';
export interface PaperPerformanceMetrics {
mddPct: number;
@@ -126,3 +126,43 @@ export function computePaperMetrics(
profitFactor: Math.min(profitFactor, 99),
};
}
/** 실시간 매매 체결 이력 → 백테스트 분석 DTO (ledger·라운드트립 기준) */
export function buildAnalysisFromPaperTrades(
trades: PaperTradeDto[],
initialCapital = 10_000_000,
): BacktestAnalysis {
const sorted = [...trades].sort((a, b) =>
(a.createdAt ?? '').localeCompare(b.createdAt ?? ''),
);
const metrics = computePaperMetrics(sorted, { initialCapital } as PaperSummaryDto);
const curve = buildEquityCurve(sorted, initialCapital);
const finalEquity = curve.length > 0 ? curve[curve.length - 1] : initialCapital;
const { wins, total, grossProfit, grossLoss } = roundTripStats(sorted);
const profitLossRatio = grossLoss > 0 ? grossProfit / grossLoss : grossProfit > 0 ? 99 : 0;
return {
initialCapital,
finalEquity,
totalReturnPct: initialCapital > 0 ? (finalEquity - initialCapital) / initialCapital : 0,
totalProfitLoss: finalEquity - initialCapital,
grossProfit,
grossLoss,
avgReturnPct: total > 0 ? (finalEquity - initialCapital) / initialCapital / total : 0,
profitLossRatio: Math.min(profitLossRatio, 99),
numberOfPositions: total,
numberOfWinning: wins,
numberOfLosing: Math.max(0, total - wins),
numberOfBreakEven: 0,
winRate: total > 0 ? wins / total : 0,
maxDrawdownPct: metrics.mddPct / 100,
maxRunupPct: 0,
sharpeRatio: metrics.sharpeRatio,
sortinoRatio: 0,
calmarRatio: 0,
valueAtRisk95: 0,
expectedShortfall: 0,
buyAndHoldReturnPct: 0,
vsBuyAndHold: 0,
};
}