전략분봉 저장 에러 문제 수정

This commit is contained in:
Macbook
2026-05-23 21:08:46 +09:00
parent eaf067db1c
commit 70ac67afe7
18 changed files with 2445 additions and 498 deletions
+19 -1
View File
@@ -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();
}
+129
View File
@@ -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);
}
+128
View File
@@ -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),
};
}