132 lines
3.5 KiB
TypeScript
132 lines
3.5 KiB
TypeScript
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) {
|
|
const qtyHint = s.quantity != null && s.quantity > 0 ? s.quantity : undefined;
|
|
if (BUY_TYPES.has(s.type) && qty === 0 && cash > 0) {
|
|
qty = qtyHint ?? 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 sellQty = qtyHint ?? qty;
|
|
const pnlPct = entryPrice > 0 ? (s.price - entryPrice) / entryPrice : 0;
|
|
cash = sellQty * s.price;
|
|
tradeId += 1;
|
|
trades.push({
|
|
id: tradeId,
|
|
time: s.time,
|
|
symbol,
|
|
side: 'sell',
|
|
price: s.price,
|
|
quantity: sellQty,
|
|
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);
|
|
}
|