Разработка движка бэктестинга на JavaScript/TypeScript

Проектируем и разрабатываем блокчейн-решения полного цикла: от архитектуры смарт-контрактов до запуска DeFi-протоколов, NFT-маркетплейсов и криптобирж. Аудит безопасности, токеномика, интеграция с существующей инфраструктурой.
Показано 1 из 1 услугВсе 1306 услуг
Разработка движка бэктестинга на JavaScript/TypeScript
Сложная
~1-2 недели
Часто задаваемые вопросы
Направления блокчейн-разработки
Этапы блокчейн-разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1221
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1163
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    855
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1062
  • image_logo-advance_0.png
    Разработка логотипа компании B2B Advance
    561
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    828

Разработка движка бэктестинга на 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);