분석레포트 로직 수정
This commit is contained in:
@@ -251,14 +251,10 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
|
||||
);
|
||||
}
|
||||
if (tab === 'live' && selectedLive) {
|
||||
return buildLiveReportModel(
|
||||
selectedLive,
|
||||
activeSignals,
|
||||
chartProps?.timeframe ?? '1h',
|
||||
);
|
||||
return buildLiveReportModel(selectedLive, activeSignals);
|
||||
}
|
||||
return null;
|
||||
}, [tab, selectedBacktest, backtestDetail, selectedLive, activeSignals, chartProps?.timeframe, strategyNamesById]);
|
||||
}, [tab, selectedBacktest, backtestDetail, selectedLive, activeSignals, strategyNamesById]);
|
||||
|
||||
const showRightDetail = tab === 'backtest'
|
||||
? backtestDetail?.analysis != null
|
||||
|
||||
@@ -119,7 +119,7 @@ export function AnalysisReportPage({ theme = 'dark' }: Props) {
|
||||
);
|
||||
}
|
||||
if (sourceTab === 'live' && selectedLive) {
|
||||
return buildLiveReportModel(selectedLive, ctx?.signals ?? [], '1h');
|
||||
return buildLiveReportModel(selectedLive, ctx?.signals ?? []);
|
||||
}
|
||||
return null;
|
||||
}, [sourceTab, selectedBacktest, selectedLive, ctx, strategyNamesById]);
|
||||
|
||||
@@ -110,10 +110,12 @@ export default function BacktestExecutionList({
|
||||
const code = item.symbol.replace(/^KRW-/i, '').toLowerCase();
|
||||
const strategy = item.strategyLabel.toLowerCase();
|
||||
const source = item.sourceLabel.toLowerCase();
|
||||
const tf = item.timeframe?.toLowerCase() ?? '';
|
||||
return ko.includes(normalizedQuery)
|
||||
|| code.includes(normalizedQuery)
|
||||
|| strategy.includes(normalizedQuery)
|
||||
|| source.includes(normalizedQuery);
|
||||
|| source.includes(normalizedQuery)
|
||||
|| tf.includes(normalizedQuery);
|
||||
});
|
||||
}, [liveItems, normalizedQuery]);
|
||||
|
||||
@@ -133,7 +135,7 @@ export default function BacktestExecutionList({
|
||||
<input
|
||||
type="text"
|
||||
className="btd-exec-search-input"
|
||||
placeholder={tab === 'backtest' ? '종목·전략·타임프레임 검색…' : '종목·전략 검색…'}
|
||||
placeholder={tab === 'backtest' ? '종목·전략·타임프레임 검색…' : '종목·전략·타임프레임 검색…'}
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
aria-label="목록 검색"
|
||||
@@ -242,12 +244,16 @@ export default function BacktestExecutionList({
|
||||
<div className="btd-history-card-row">
|
||||
<div className="btd-history-card-main">
|
||||
<span className="btd-history-ko">{ko}</span>
|
||||
<span className="btd-history-strategy">{item.strategyLabel} · {item.sourceLabel}</span>
|
||||
<span className="btd-history-strategy">
|
||||
{item.strategyLabel}
|
||||
{item.timeframe !== 'unknown' ? ` · ${formatTimeframeKo(item.timeframe)}` : ''}
|
||||
{item.sourceLabel !== '자동' ? ` · ${item.sourceLabel}` : ''}
|
||||
</span>
|
||||
<span className="btd-history-date">{fmtShortTimestamp(item.createdAt)}</span>
|
||||
</div>
|
||||
<div className="btd-history-card-side">
|
||||
<BacktestSparkline curve={parseLiveSpark(item)} positive={positive} width={56} height={22} />
|
||||
<span className="btd-history-win">체결 {item.tradeCount}건</span>
|
||||
<span className="btd-history-win">라운드 {item.roundTripCount} · 체결 {item.tradeCount}건</span>
|
||||
<span className={`btd-history-roi${positive ? ' up' : ' down'}`}>{pct(item.totalReturnPct)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user