Разработка движка бэктестинга на JavaScript/TypeScript
JavaScript/TypeScript для бэктестинга — неочевидный, но вполне обоснованный выбор для команд с frontend-экспертизой или проектов, где бэктест выполняется прямо в браузере. Node.js позволяет переиспользовать логику между frontend и backend, а TypeScript обеспечивает типобезопасность, критичную для финансовых вычислений.
Архитектура TypeScript бэктест-движка
// Основные интерфейсы
interface Bar {
timestamp: number; // Unix milliseconds
open: number;
high: number;
low: number;
close: number;
volume: number;
}
interface Order {
id: string;
symbol: string;
side: 'BUY' | 'SELL';
type: 'MARKET' | 'LIMIT' | 'STOP_LIMIT';
quantity: number;
price?: number;
stopPrice?: number;
status: 'PENDING' | 'FILLED' | 'CANCELLED' | 'EXPIRED';
}
interface Position {
symbol: string;
quantity: number;
avgEntryPrice: number;
unrealizedPnl: number;
realizedPnl: number;
}
interface Trade {
entryTime: number;
exitTime: number;
symbol: string;
side: 'LONG' | 'SHORT';
entryPrice: number;
exitPrice: number;
quantity: number;
pnl: number;
commission: number;
}
abstract class Strategy {
protected ctx: BacktestContext;
constructor(ctx: BacktestContext) {
this.ctx = ctx;
}
abstract onBar(bar: Bar): void;
protected buy(quantity: number, price?: number): Order {
return this.ctx.submitOrder({
id: crypto.randomUUID(),
symbol: this.ctx.symbol,
side: 'BUY',
type: price ? 'LIMIT' : 'MARKET',
quantity,
price,
status: 'PENDING',
});
}
protected sell(quantity: number, price?: number): Order {
return this.ctx.submitOrder({
id: crypto.randomUUID(),
symbol: this.ctx.symbol,
side: 'SELL',
type: price ? 'LIMIT' : 'MARKET',
quantity,
price,
status: 'PENDING',
});
}
protected get position(): Position | null {
return this.ctx.getPosition(this.ctx.symbol);
}
protected get cash(): number {
return this.ctx.portfolio.cash;
}
}
Portfolio класс
class Portfolio {
cash: number;
positions = new Map<string, Position>();
trades: Trade[] = [];
equityCurve: { timestamp: number; equity: number }[] = [];
constructor(initialCash: number) {
this.cash = initialCash;
}
processFill(order: Order, fillPrice: number, commission: number, timestamp: number): void {
const cost = fillPrice * order.quantity;
if (order.side === 'BUY') {
this.cash -= cost + commission;
const existing = this.positions.get(order.symbol);
if (existing) {
const totalQty = existing.quantity + order.quantity;
existing.avgEntryPrice =
(existing.avgEntryPrice * existing.quantity + fillPrice * order.quantity) / totalQty;
existing.quantity = totalQty;
} else {
this.positions.set(order.symbol, {
symbol: order.symbol,
quantity: order.quantity,
avgEntryPrice: fillPrice,
unrealizedPnl: 0,
realizedPnl: 0,
});
}
} else if (order.side === 'SELL') {
this.cash += cost - commission;
const pos = this.positions.get(order.symbol);
if (pos) {
const pnl = (fillPrice - pos.avgEntryPrice) * order.quantity - commission;
pos.realizedPnl += pnl;
pos.quantity -= order.quantity;
this.trades.push({
entryTime: 0, // обновляется в реальной реализации
exitTime: timestamp,
symbol: order.symbol,
side: 'LONG',
entryPrice: pos.avgEntryPrice,
exitPrice: fillPrice,
quantity: order.quantity,
pnl,
commission,
});
if (pos.quantity <= 1e-10) {
this.positions.delete(order.symbol);
}
}
}
}
getEquity(prices: Map<string, number>): number {
let positionsValue = 0;
for (const [symbol, pos] of this.positions) {
const price = prices.get(symbol) ?? pos.avgEntryPrice;
positionsValue += pos.quantity * price;
}
return this.cash + positionsValue;
}
}
Backtester класс
class Backtester {
private broker: SimulatedBroker;
constructor(options: BrokerOptions = {}) {
this.broker = new SimulatedBroker(options);
}
run<T extends Record<string, unknown>>(
StrategyClass: new (ctx: BacktestContext, params: T) => Strategy,
params: T,
data: Bar[],
symbol: string,
initialCash = 100_000,
): BacktestResult {
const portfolio = new Portfolio(initialCash);
const pendingOrders: Order[] = [];
const context = new BacktestContext(portfolio, symbol);
const strategy = new StrategyClass(context, params);
for (const bar of data) {
// Обрабатываем pending ордера
const stillPending: Order[] = [];
for (const order of pendingOrders) {
const fill = this.broker.processOrder(order, bar);
if (fill) {
portfolio.processFill(order, fill.fillPrice, fill.commission, bar.timestamp);
order.status = 'FILLED';
} else {
stillPending.push(order);
}
}
pendingOrders.length = 0;
pendingOrders.push(...stillPending);
// Обновляем unrealized PnL
for (const [, pos] of portfolio.positions) {
pos.unrealizedPnl = (bar.close - pos.avgEntryPrice) * pos.quantity;
}
// Вызываем стратегию
context.currentBar = bar;
strategy.onBar(bar);
// Добавляем новые ордера в очередь
pendingOrders.push(...context.popNewOrders());
// Equity snapshot
const prices = new Map([[symbol, bar.close]]);
portfolio.equityCurve.push({ timestamp: bar.timestamp, equity: portfolio.getEquity(prices) });
}
return this.buildResult(portfolio);
}
private buildResult(portfolio: Portfolio): BacktestResult {
const equitySeries = portfolio.equityCurve.map(e => e.equity);
return {
trades: portfolio.trades,
equityCurve: portfolio.equityCurve,
metrics: calculateMetrics(equitySeries, portfolio.trades),
};
}
}
Технические индикаторы
// Чистый TypeScript, без внешних зависимостей
export function ema(values: number[], period: number): number[] {
const k = 2 / (period + 1);
const result: number[] = new Array(values.length).fill(NaN);
// Первое значение = SMA
let sum = 0;
for (let i = 0; i < period; i++) sum += values[i];
result[period - 1] = sum / period;
for (let i = period; i < values.length; i++) {
result[i] = values[i] * k + result[i - 1] * (1 - k);
}
return result;
}
export function rsi(closes: number[], period = 14): number[] {
const gains: number[] = [];
const losses: number[] = [];
for (let i = 1; i < closes.length; i++) {
const diff = closes[i] - closes[i - 1];
gains.push(Math.max(diff, 0));
losses.push(Math.max(-diff, 0));
}
const avgGain = sma(gains, period);
const avgLoss = sma(losses, period);
return avgGain.map((ag, i) => {
if (isNaN(ag) || isNaN(avgLoss[i])) return NaN;
if (avgLoss[i] === 0) return 100;
const rs = ag / avgLoss[i];
return 100 - 100 / (1 + rs);
});
}
Browser-side backtesting
Одно из главных преимуществ JS движка — запуск прямо в браузере без серверного рендеринга. С Web Workers UI не блокируется:
// backtest.worker.ts
self.onmessage = async (e: MessageEvent) => {
const { strategyCode, params, data, symbol } = e.data;
// Динамически создаём класс стратегии из строки кода
const StrategyClass = new Function('Strategy', 'return ' + strategyCode)(StrategyBase);
const backtester = new Backtester({ commissionPct: 0.001, slippagePct: 0.0005 });
const result = backtester.run(StrategyClass, params, data, symbol);
self.postMessage({ type: 'COMPLETE', result });
};
// Основной поток
const worker = new Worker(new URL('./backtest.worker.ts', import.meta.url));
worker.onmessage = (e) => {
if (e.data.type === 'COMPLETE') {
displayResults(e.data.result);
}
};
worker.postMessage({ strategyCode, params, data, symbol });
Для Node.js на сервере — Worker Threads из node:worker_threads вместо Web Workers. Параллельная оптимизация параметров через worker pool.
Числовая точность
Важный момент: JavaScript числа — IEEE 754 double precision. Для финансовых вычислений это иногда недостаточно. Для критичных расчётов используем Decimal.js:
import Decimal from 'decimal.js';
const commission = new Decimal(fillPrice).times(quantity).times('0.001');
const pnl = new Decimal(exitPrice).minus(entryPrice).times(quantity).minus(commission);







