Skip to content

Custom Executor

An Executor translates the orders generated by a strategy into confirmed fills. The backtest engine never executes trades directly — it delegates everything to the Executor you supply, which means you can swap in a broker adapter, a paper-trading layer, or a logging wrapper without touching your strategy code. This page covers the interface contract, the BacktestExecutor reference implementation, and how to write your own.

Contract

The Executor interface has a single method:

ts
interface Executor {
  submit(
    orders: ReadonlyArray<Order>,
    t: Date,
    portfolio: Portfolio,
  ): Promise<ReadonlyArray<Fill>>;
}

Invariants

  • One fill per executed order. submit must return exactly one Fill for each order that results in non-zero quantity. Orders that produce zero quantity (e.g. a rebalance order where the position is already at target) must be omitted from the result — do not emit a fill with quantity: 0.
  • Idempotent per order.id. Submitting the same Order array twice with the same order.id values must not double-fill positions. Use order.id as the idempotency key when calling a broker API.
  • Fill timestamps. fill.t must be >= t. For next-bar fill semantics (the most common backtest model), fill.t is the open of the following session.
  • async. The method is always asynchronous. For live trading, resolve only after execution is confirmed by the broker. For backtesting, resolve after the next-bar price has been looked up.

Order variants

The Order type is a discriminated union. Your submit implementation must handle all four variants:

order.kindMeaning
'open'Open a new position: asset, side ('long'/'short'), quantity
'close'Close an existing position by positionId; optional quantity for partial close
'adjust'Change size of an existing position by positionId; changes.quantity is the new target
'rebalance'Apply a signed delta to an asset's exposure: asset, delta (positive = buy, negative = sell)

The portfolio parameter gives you the current positions. For 'close' and 'adjust' orders you'll need to look up the position by positionId to resolve the asset and current quantity.

Reference: BacktestExecutor

BacktestExecutor fills at next-open with configurable friction:

ts
const executor = new BacktestExecutor({
  calendar,
  nextOpen: async (asset, t) => {
    // Return the open price of the first bar after t.
    const bar = bars.find(b => b.t.getTime() > t.getTime());
    return { t: bar.t, price: bar.open };
  },
  slippageBps: 5,    // 5 basis points of market impact
  perShareFee: 0.005, // $0.005 per share commission
});

slippageBps adjusts the fill price upward for buys and downward for sells. perShareFee is added to fill.fees. Both default to 0.

Live trading: mapping orders to a broker SDK

For a real broker adapter, submit would:

  1. Loop over orders, resolving each variant as above.
  2. Translate each order into the broker SDK's order type (market order, limit order, etc.).
  3. await the broker's acknowledgement (order ID returned) — this is the idempotency point. Record the mapping from order.id to broker order ID.
  4. Optionally poll or subscribe to fills until execution is confirmed.
  5. Return an array of Fill objects keyed by fill.orderRef = order.id.

Latency: for live execution, submit may take hundreds of milliseconds. The backtest engine awaits submit sequentially per rebalance session, so latency only matters in live mode.

Sample: LoggingExecutor

The sample at scripts/docs/guides-runtime/custom-executor.ts wraps BacktestExecutor with a LoggingExecutor that prints every order and fill to stdout:

sh
npx tsx scripts/docs/guides-runtime/custom-executor.ts
ts
// Custom Executor — guide sample
// Demonstrates a LoggingExecutor that wraps BacktestExecutor to print every
// order and fill, illustrating the decorator/wrapper pattern for Executor.
//
//   npx tsx scripts/docs/guides-runtime/custom-executor.ts

import {
  fromSpec,
  runBacktest,
  FeatureRuntime,
  NYSEExchangeCalendar,
  MemoryFeatureCache,
  BacktestExecutor,
} from '@livefolio/sdk';
import type {
  Executor,
  Order,
  Fill,
  Portfolio,
  DataFeed,
  Asset,
  Bar,
  DateRange,
  Frequency,
  TacticalSpec,
} from '@livefolio/sdk';

// ─── 1. Implement Executor ────────────────────────────────────────────────────
//
// Contract checklist:
//   - submit() returns one Fill per executed order (zero-qty orders omitted)
//   - submit() is idempotent per unique order.id — the same id must not double-fill
//   - fill.t must be >= t (the submission timestamp)
//   - submit() is async — broker calls or next-bar price lookups happen inside

/**
 * LoggingExecutor wraps any Executor to print every order and fill.
 * Useful for debugging and for audit trails in paper-trading setups.
 */
class LoggingExecutor implements Executor {
  constructor(private readonly inner: Executor) {}

  async submit(orders: ReadonlyArray<Order>, t: Date, portfolio: Portfolio): Promise<ReadonlyArray<Fill>> {
    console.log(`\n[executor] submit at ${t.toISOString()} — ${orders.length} order(s)`);

    for (const order of orders) {
      switch (order.kind) {
        case 'open':
          console.log(`  open   id=${order.id} asset=${order.asset.symbol} side=${order.side} qty=${order.quantity}`);
          break;
        case 'close':
          console.log(`  close  id=${order.id} positionId=${order.positionId} qty=${order.quantity ?? 'all'}`);
          break;
        case 'adjust':
          console.log(`  adjust id=${order.id} positionId=${order.positionId}`);
          break;
        case 'rebalance':
          console.log(`  rebal  id=${order.id} asset=${order.asset.symbol} delta=${order.delta}`);
          break;
      }
    }

    const fills = await this.inner.submit(orders, t, portfolio);

    for (const fill of fills) {
      console.log(
        `  fill   ref=${fill.orderRef} qty=${fill.quantity} price=${fill.price.toFixed(4)} fees=${fill.fees.toFixed(4)} t=${fill.t.toISOString()}`,
      );
    }

    return fills;
  }
}

// ─── 2. Synthetic DataFeed ────────────────────────────────────────────────────

const MS_DAY = 86_400_000;

function makeBars(startIso: string, count: number, base: number, drift: number): Bar[] {
  const bars: Bar[] = [];
  let price = base;
  let t = new Date(`${startIso}T00:00:00Z`);
  for (let i = 0; i < count; i++) {
    const dow = t.getUTCDay();
    if (dow !== 0 && dow !== 6) {
      price = price * (1 + drift + Math.sin(i / 10) * 0.005);
      bars.push({
        t: new Date(t),
        open: price,
        high: price * 1.005,
        low: price * 0.995,
        close: price,
        volume: 1_000_000,
      });
    }
    t = new Date(t.getTime() + MS_DAY);
  }
  return bars;
}

const FIXTURES: Record<string, Bar[]> = {
  'us:SPY': makeBars('2024-01-02', 300, 480, 0.0004),
  'us:IEF': makeBars('2024-01-02', 300, 95, 0.0001),
};

const dataFeed: DataFeed = {
  bars: async function* (asset: Asset, range: DateRange, _freq: Frequency) {
    const all = FIXTURES[asset.id];
    if (!all) throw new Error(`no fixture for ${asset.id}`);
    for (const b of all) {
      if (b.t >= range.from && b.t < range.to) yield b;
    }
  },
};

// ─── 3. Wire into a backtest ──────────────────────────────────────────────────

const SPY = { id: 'us:SPY', symbol: 'SPY' };
const IEF = { id: 'us:IEF', symbol: 'IEF' };

const spec: TacticalSpec = {
  kind: 'tactical/v1',
  universe: [SPY, IEF],
  rebalance: { frequency: 'Monthly' },
  features: [{ id: 'spy_price', kind: 'price', asset: SPY }],
  rules: { op: 'allocate', weights: { 'us:SPY': 0.6, 'us:IEF': 0.4 } },
};

const calendar = new NYSEExchangeCalendar();
const featureCache = new MemoryFeatureCache();
const range: DateRange = { from: new Date('2024-02-01T00:00:00Z'), to: new Date('2024-06-01T00:00:00Z') };
const runtime = new FeatureRuntime({ dataFeed, featureCache, range, freq: '1d' });

// Wrap the BacktestExecutor with LoggingExecutor.
const inner = new BacktestExecutor({
  calendar,
  nextOpen: async (asset, t) => {
    const bars = FIXTURES[asset.id];
    if (!bars) throw new Error(`no fixture for ${asset.id}`);
    const next = bars.find((b) => b.t.getTime() > t.getTime());
    if (!next) throw new Error(`no bar after ${t.toISOString()} for ${asset.id}`);
    return { t: next.t, price: next.open };
  },
  slippageBps: 5,
  perShareFee: 0.005,
});

const executor = new LoggingExecutor(inner);
const strategy = fromSpec(spec, { runtime, calendar });

const result = await runBacktest({
  strategy,
  range,
  initialPortfolio: { cash: 50_000, positions: [], t: range.from },
  dataFeed,
  executor,
  calendar,
});

console.log(`\nsessions   : ${result.snapshots.length}`);
console.log(`rebalances : ${result.snapshots.filter((s) => s.orders.length > 0).length}`);

Things to verify

  • [ ] Zero-quantity orders are not emitted as fills.
  • [ ] fill.orderRef matches order.id for every fill returned.
  • [ ] fill.t >= t — fills are never backdated.
  • [ ] Submitting the same orders twice does not double-fill (idempotency).
  • [ ] Your implementation compiles: npm run docs:check.
  • [ ] Integration: inspect result.snapshots[n].fills to confirm fill prices and fees match expected values.

What's next

  • DataFeedBacktestExecutor requires a nextOpen function that reads from your data source. Make sure the feed you supply to the backtest also backs the nextOpen lookup. See Custom DataFeed.
  • Portfolio accounting — fills are applied to the portfolio by the engine via applyFills. See applyFills.
  • API referenceExecutor · BacktestExecutor · Order · Fill.

Released under the MIT License.