전략분봉 저장 에러 문제 수정
This commit is contained in:
@@ -281,9 +281,9 @@ export class ChartManager {
|
||||
this.indicators.clear();
|
||||
this.patternMarkers = [];
|
||||
|
||||
this._disposeMainMarkersPlugin();
|
||||
if (this.mainSeries) { try { this.chart.removeSeries(this.mainSeries); } catch { /* ok */ } this.mainSeries = null; }
|
||||
if (this.volumeSeries) { try { this.chart.removeSeries(this.volumeSeries); } catch { /* ok */ } this.volumeSeries = null; }
|
||||
this.mainMarkersPlugin = null;
|
||||
|
||||
this.mainSeries = this._createMainSeries(chartType, t);
|
||||
const mainData = bars.map(b => {
|
||||
@@ -586,6 +586,13 @@ export class ChartManager {
|
||||
this._notifyPaneLayout();
|
||||
}
|
||||
|
||||
/** 메인 시리즈 마커 플러그인 해제 (setData 등 시리즈 교체 전 호출) */
|
||||
private _disposeMainMarkersPlugin(): void {
|
||||
if (!this.mainMarkersPlugin) return;
|
||||
try { this.mainMarkersPlugin.detach(); } catch { /* ok */ }
|
||||
this.mainMarkersPlugin = null;
|
||||
}
|
||||
|
||||
private _reapplyAllPatternMarkers(): void {
|
||||
if (!this.mainSeries) return;
|
||||
const patternAll = this.patternMarkers.flatMap(({ markers }) =>
|
||||
@@ -612,6 +619,15 @@ export class ChartManager {
|
||||
text: m.text,
|
||||
}));
|
||||
const all = [...patternAll, ...backtestAll, ...liveAll];
|
||||
if (this.mainMarkersPlugin) {
|
||||
try {
|
||||
if (this.mainMarkersPlugin.getSeries() !== this.mainSeries) {
|
||||
this._disposeMainMarkersPlugin();
|
||||
}
|
||||
} catch {
|
||||
this._disposeMainMarkersPlugin();
|
||||
}
|
||||
}
|
||||
if (!this.mainMarkersPlugin) {
|
||||
this.mainMarkersPlugin = createSeriesMarkers(this.mainSeries, all);
|
||||
} else {
|
||||
@@ -648,6 +664,8 @@ export class ChartManager {
|
||||
text,
|
||||
};
|
||||
});
|
||||
// 시리즈 교체·detach 이후 stale 플러그인 방지 — 백테스트 마커는 항상 재생성
|
||||
this._disposeMainMarkersPlugin();
|
||||
this._reapplyAllPatternMarkers();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { BacktestSignal } from './backendApi';
|
||||
|
||||
export interface EquityPoint {
|
||||
time: number;
|
||||
equity: number;
|
||||
}
|
||||
|
||||
export interface TradeMarker {
|
||||
time: number;
|
||||
equity: number;
|
||||
type: 'buy' | 'sell';
|
||||
price: number;
|
||||
pnlPct?: number;
|
||||
}
|
||||
|
||||
export interface TradeHistoryRow {
|
||||
id: number;
|
||||
time: number;
|
||||
symbol: string;
|
||||
side: 'buy' | 'sell';
|
||||
price: number;
|
||||
quantity: number;
|
||||
pnlPct?: number;
|
||||
}
|
||||
|
||||
const BUY_TYPES = new Set(['BUY']);
|
||||
const SELL_TYPES = new Set(['SELL', 'SHORT_EXIT', 'PARTIAL_SELL']);
|
||||
|
||||
export function buildEquityFromSignals(
|
||||
signals: BacktestSignal[],
|
||||
initialCapital: number,
|
||||
symbol: string,
|
||||
): { curve: EquityPoint[]; markers: TradeMarker[]; trades: TradeHistoryRow[] } {
|
||||
const sorted = [...signals].sort((a, b) => a.time - b.time);
|
||||
const curve: EquityPoint[] = [];
|
||||
const markers: TradeMarker[] = [];
|
||||
const trades: TradeHistoryRow[] = [];
|
||||
|
||||
if (sorted.length === 0) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
curve: [{ time: now, equity: initialCapital }],
|
||||
markers: [],
|
||||
trades: [],
|
||||
};
|
||||
}
|
||||
|
||||
let cash = initialCapital;
|
||||
let qty = 0;
|
||||
let entryPrice = 0;
|
||||
let tradeId = 0;
|
||||
|
||||
const pushCurve = (time: number, price: number) => {
|
||||
const equity = qty > 0 ? qty * price : cash;
|
||||
const last = curve[curve.length - 1];
|
||||
if (!last || last.time !== time || Math.abs(last.equity - equity) > 0.01) {
|
||||
curve.push({ time, equity });
|
||||
}
|
||||
};
|
||||
|
||||
pushCurve(sorted[0].time, sorted[0].price);
|
||||
|
||||
for (const s of sorted) {
|
||||
if (BUY_TYPES.has(s.type) && qty === 0 && cash > 0) {
|
||||
qty = cash / s.price;
|
||||
entryPrice = s.price;
|
||||
cash = 0;
|
||||
tradeId += 1;
|
||||
const eq = qty * s.price;
|
||||
trades.push({
|
||||
id: tradeId,
|
||||
time: s.time,
|
||||
symbol,
|
||||
side: 'buy',
|
||||
price: s.price,
|
||||
quantity: qty,
|
||||
});
|
||||
markers.push({ time: s.time, equity: eq, type: 'buy', price: s.price });
|
||||
pushCurve(s.time, s.price);
|
||||
} else if (SELL_TYPES.has(s.type) && qty > 0) {
|
||||
const pnlPct = entryPrice > 0 ? (s.price - entryPrice) / entryPrice : 0;
|
||||
cash = qty * s.price;
|
||||
tradeId += 1;
|
||||
trades.push({
|
||||
id: tradeId,
|
||||
time: s.time,
|
||||
symbol,
|
||||
side: 'sell',
|
||||
price: s.price,
|
||||
quantity: qty,
|
||||
pnlPct,
|
||||
});
|
||||
markers.push({ time: s.time, equity: cash, type: 'sell', price: s.price, pnlPct });
|
||||
qty = 0;
|
||||
entryPrice = 0;
|
||||
pushCurve(s.time, s.price);
|
||||
} else {
|
||||
pushCurve(s.time, s.price);
|
||||
}
|
||||
}
|
||||
|
||||
if (curve.length === 1 && sorted.length > 0) {
|
||||
const last = sorted[sorted.length - 1];
|
||||
const equity = qty > 0 ? qty * last.price : cash;
|
||||
curve.push({ time: last.time, equity });
|
||||
}
|
||||
|
||||
return { curve, markers, trades };
|
||||
}
|
||||
|
||||
export function sparklinePath(values: number[], w: number, h: number): string {
|
||||
if (values.length < 2) {
|
||||
const y = h * 0.5;
|
||||
return `M0,${y} L${w},${y}`;
|
||||
}
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const range = max - min || 1;
|
||||
return values.map((v, i) => {
|
||||
const x = (i / (values.length - 1)) * w;
|
||||
const y = h - 4 - ((v - min) / range) * (h - 8);
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
export function equityToSparkline(curve: EquityPoint[]): number[] {
|
||||
if (curve.length <= 1) return [0, 1];
|
||||
return curve.map(p => p.equity);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { PaperSummaryDto, PaperTradeDto } from './backendApi';
|
||||
|
||||
export interface PaperPerformanceMetrics {
|
||||
mddPct: number;
|
||||
sharpeRatio: number;
|
||||
winRatePct: number;
|
||||
profitFactor: number;
|
||||
}
|
||||
|
||||
function buildEquityCurve(
|
||||
trades: PaperTradeDto[],
|
||||
initialCapital: number,
|
||||
): number[] {
|
||||
const sorted = [...trades].sort((a, b) =>
|
||||
(a.createdAt ?? '').localeCompare(b.createdAt ?? ''),
|
||||
);
|
||||
const curve: number[] = [initialCapital];
|
||||
let cash = initialCapital;
|
||||
const holdings = new Map<string, { qty: number; cost: number }>();
|
||||
|
||||
for (const t of sorted) {
|
||||
const pos = holdings.get(t.symbol) ?? { qty: 0, cost: 0 };
|
||||
if (t.side === 'BUY') {
|
||||
cash -= t.netAmount;
|
||||
pos.qty += t.quantity;
|
||||
pos.cost += t.netAmount;
|
||||
} else {
|
||||
cash += t.netAmount;
|
||||
const avg = pos.qty > 0 ? pos.cost / pos.qty : 0;
|
||||
pos.qty = Math.max(0, pos.qty - t.quantity);
|
||||
pos.cost = pos.qty > 0 ? avg * pos.qty : 0;
|
||||
}
|
||||
holdings.set(t.symbol, pos);
|
||||
let stockVal = 0;
|
||||
holdings.forEach(p => {
|
||||
if (p.qty > 0) stockVal += p.cost;
|
||||
});
|
||||
curve.push(cash + stockVal);
|
||||
}
|
||||
return curve;
|
||||
}
|
||||
|
||||
function maxDrawdownPct(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) * 100;
|
||||
if (dd > mdd) mdd = dd;
|
||||
}
|
||||
}
|
||||
return mdd;
|
||||
}
|
||||
|
||||
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 roundTripStats(trades: PaperTradeDto[]): { wins: number; total: number; grossProfit: number; grossLoss: number } {
|
||||
const bySymbol = new Map<string, PaperTradeDto[]>();
|
||||
for (const t of trades) {
|
||||
const list = bySymbol.get(t.symbol) ?? [];
|
||||
list.push(t);
|
||||
bySymbol.set(t.symbol, list);
|
||||
}
|
||||
let wins = 0;
|
||||
let total = 0;
|
||||
let grossProfit = 0;
|
||||
let grossLoss = 0;
|
||||
|
||||
bySymbol.forEach(list => {
|
||||
const sorted = [...list].sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''));
|
||||
let buyCost = 0;
|
||||
let buyQty = 0;
|
||||
for (const t of sorted) {
|
||||
if (t.side === 'BUY') {
|
||||
buyCost += t.netAmount;
|
||||
buyQty += t.quantity;
|
||||
} else if (buyQty > 0) {
|
||||
const avg = buyCost / buyQty;
|
||||
const pnl = (t.price - avg) * t.quantity - t.feeAmount;
|
||||
total += 1;
|
||||
if (pnl >= 0) {
|
||||
wins += 1;
|
||||
grossProfit += pnl;
|
||||
} else {
|
||||
grossLoss += Math.abs(pnl);
|
||||
}
|
||||
buyQty = Math.max(0, buyQty - t.quantity);
|
||||
buyCost = buyQty > 0 ? avg * buyQty : 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { wins, total, grossProfit, grossLoss };
|
||||
}
|
||||
|
||||
export function computePaperMetrics(
|
||||
trades: PaperTradeDto[],
|
||||
summary: PaperSummaryDto | null,
|
||||
): PaperPerformanceMetrics {
|
||||
const initial = summary?.initialCapital ?? 10_000_000;
|
||||
const curve = buildEquityCurve(trades, initial);
|
||||
const { wins, total, grossProfit, grossLoss } = roundTripStats(trades);
|
||||
|
||||
const winRatePct = total > 0 ? (wins / total) * 100 : 0;
|
||||
const profitFactor = grossLoss > 0 ? grossProfit / grossLoss : grossProfit > 0 ? 99 : 0;
|
||||
|
||||
return {
|
||||
mddPct: maxDrawdownPct(curve),
|
||||
sharpeRatio: sharpeFromCurve(curve),
|
||||
winRatePct,
|
||||
profitFactor: Math.min(profitFactor, 99),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user