Skip to main content

Overview

The ExchangeSimulator is a stateful, event-driven simulator that mirrors live trading behavior without risking real capital. It maintains realistic orderbook state, applies slippage and fees, and automatically triggers stop-loss and take-profit orders.
The simulator is the default execution backend in development. Production uses the Lighter API for real trading.

Architecture

// src/server/features/simulator/exchangeSimulator.ts:101
export class ExchangeSimulator {
  private readonly accounts = new Map<string, AccountState>();  // Per-model account state
  private readonly markets = new Map<string, MarketState>();    // Per-symbol orderbook
  private readonly emitter = new SimpleEmitter();               // Event bus
  private readonly pendingAutoCloses = new Set<string>();       // Debounce auto-close triggers
  private refreshHandle?: NodeJS.Timeout;                       // Polling timer

  static async bootstrap(options?: Partial<ExchangeSimulatorOptions>): Promise<ExchangeSimulator> {
    if (!globalThis.__exchangeSimulator) {
      globalThis.__exchangeSimulator = ExchangeSimulator.create({
        ...DEFAULT_SIMULATOR_OPTIONS,
        ...options,
      });
    }
    return globalThis.__exchangeSimulator;
  }
}
Key Components:
  1. AccountState: Tracks cash, positions, P&L, and exit plans per trading account
  2. MarketState: Fetches and caches live orderbook data from exchanges
  3. OrderMatching: Simulates realistic fills with slippage and partial fills
  4. Auto-Close Engine: Monitors positions and triggers exits when stop/target hit

Initialization & Bootstrap

The simulator is initialized once per server lifecycle:
// src/server/features/simulator/exchangeSimulator.ts:178
private async initialise() {
  // Initialize market states with live orderbook data
  for (const metadata of buildMarketMetadata()) {
    const market = new MarketState(metadata, orderApi);
    try {
      await market.refresh();  // Fetch initial orderbook
    } catch (error) {
      console.error(`[Simulator] Failed to initialize market ${metadata.symbol}:`, error);
    }
    this.markets.set(metadata.symbol, market);
  }

  // Restore open positions from database
  await this.restorePositionsFromDb();

  // Start polling for price updates and auto-close triggers
  this.startPolling();
}
Database Rehydration: On startup, the simulator restores all open positions from the Orders table:
// src/server/features/simulator/exchangeSimulator.ts:203
private async restorePositionsFromDb() {
  const openOrders = await getAllOpenOrders();

  for (const order of openOrders) {
    const account = this.getOrCreateAccount(order.modelId);
    const symbol = normalizeSymbol(order.symbol);
    const market = this.markets.get(symbol);
    const markPrice = market?.getMidPrice() ?? parseFloat(order.entryPrice);

    const quantity = parseFloat(order.quantity);
    const entryPrice = parseFloat(order.entryPrice);
    const leverage = order.leverage ? parseFloat(order.leverage) : 1;
    const side = order.side === "LONG" ? "buy" : "sell";

    // Restore position via synthetic execution
    account.applyExecution(
      symbol,
      side,
      {
        fills: [{ quantity, price: entryPrice }],
        averagePrice: entryPrice,
        totalQuantity: quantity,
        totalFees: 0,
        status: "filled",
      },
      leverage,
    );

    // Restore exit plan
    if (order.exitPlan) {
      account.setExitPlan(symbol, order.exitPlan);
    }

    // Update mark price
    account.updateMarkPrice(symbol, markPrice);
  }
}
This ensures auto-close triggers work correctly even after server restarts. The Orders table is the single source of truth.

Account State Management

Each trading account has its own AccountState instance:
// src/server/features/simulator/accountState.ts (conceptual)
export class AccountState {
  private cash: number = INITIAL_CAPITAL;
  private positions = new Map<string, Position>();
  private exitPlans = new Map<string, PositionExitPlan>();
  private markPrices = new Map<string, number>();
  private totalRealizedPnl: number = 0;

  applyExecution(
    symbol: string,
    side: "buy" | "sell",
    execution: ExecutionResult,
    leverage: number,
  ): void {
    const existing = this.positions.get(symbol);
    const notional = execution.totalQuantity * execution.averagePrice;
    const margin = notional / leverage;

    if (!existing) {
      // Open new position
      this.positions.set(symbol, {
        symbol,
        side: side === "buy" ? "LONG" : "SHORT",
        quantity: execution.totalQuantity,
        avgEntryPrice: execution.averagePrice,
        leverage,
      });
      this.cash -= margin;  // Allocate margin
    } else {
      // Close or reduce position
      const closeQty = Math.min(execution.totalQuantity, existing.quantity);
      const isLong = existing.side === "LONG";
      const pnl = isLong
        ? (execution.averagePrice - existing.avgEntryPrice) * closeQty
        : (existing.avgEntryPrice - execution.averagePrice) * closeQty;

      this.totalRealizedPnl += pnl;
      this.cash += (existing.avgEntryPrice * closeQty / existing.leverage) + pnl;

      existing.quantity -= closeQty;
      if (existing.quantity <= 0) {
        this.positions.delete(symbol);
        this.exitPlans.delete(symbol);
      }
    }
  }

  getSnapshot(): AccountSnapshot {
    let allocatedCash = 0;
    let unrealizedPnl = 0;

    for (const [symbol, position] of this.positions) {
      const markPrice = this.markPrices.get(symbol) ?? position.avgEntryPrice;
      const notional = position.quantity * position.avgEntryPrice;
      allocatedCash += notional / position.leverage;

      const pnl = position.side === "LONG"
        ? (markPrice - position.avgEntryPrice) * position.quantity
        : (position.avgEntryPrice - markPrice) * position.quantity;
      unrealizedPnl += pnl;
    }

    const equity = this.cash + allocatedCash + unrealizedPnl;

    return {
      cash: this.cash,
      allocatedCash,
      equity,
      unrealizedPnl,
      totalRealizedPnl: this.totalRealizedPnl,
      positions: Array.from(this.positions.values()),
    };
  }
}

Order Matching Engine

The simulator applies realistic orderbook matching:
// src/server/features/simulator/orderMatching.ts (conceptual)
export function matchOrder(
  book: OrderBookSnapshot,
  request: SimulatedOrderRequest,
  options: ExchangeSimulatorOptions,
  rng: RandomSource,
): ExecutionResult {
  const { slippageBps, feeRateBps, partialFillProbability } = options;

  if (request.type === "market") {
    // Walk the orderbook
    const fills = [];
    let remaining = request.quantity;
    const levels = request.side === "buy" ? book.asks : book.bids;

    for (const [price, size] of levels) {
      const fillQty = Math.min(remaining, size);
      const slippageAdjusted = applySlippage(price, slippageBps, rng);
      fills.push({ quantity: fillQty, price: slippageAdjusted });
      remaining -= fillQty;
      if (remaining <= 0) break;
    }

    // Simulate partial fills
    if (rng.next() < partialFillProbability) {
      fills.splice(Math.floor(fills.length / 2));
    }

    const totalQuantity = fills.reduce((sum, f) => sum + f.quantity, 0);
    const totalCost = fills.reduce((sum, f) => sum + f.quantity * f.price, 0);
    const averagePrice = totalCost / totalQuantity;
    const totalFees = totalCost * (feeRateBps / 10000);

    return {
      fills,
      averagePrice,
      totalQuantity,
      totalFees,
      status: totalQuantity === request.quantity ? "filled" : "partial",
    };
  }

  // Limit orders not yet implemented
  return { fills: [], averagePrice: 0, totalQuantity: 0, totalFees: 0, status: "rejected" };
}
Slippage Simulation:
function applySlippage(price: number, slippageBps: number, rng: RandomSource): number {
  const slippageFactor = 1 + (rng.next() * slippageBps / 10000);
  return price * slippageFactor;
}
Default slippage is 10 bps (0.1%). This models liquidity taking cost and price impact.

Auto-Close Engine

Every refreshIntervalMs (default: 5s), the simulator:
  1. Fetches latest market prices
  2. Checks all open positions for stop/target triggers
  3. Queues auto-close orders
  4. Executes closes and updates database
// src/server/features/simulator/exchangeSimulator.ts:260
private async refreshAll() {
  // Update market prices
  for (const [symbol, market] of this.markets) {
    try {
      const snapshot = await market.refresh();
      for (const account of this.accounts.values()) {
        account.updateMarkPrice(symbol, snapshot.midPrice);
      }
    } catch (error) {
      console.warn(`[Simulator] Market refresh failed for ${symbol}:`, error);
    }
  }

  // Collect auto-close triggers
  const autoCloseQueue = [];
  for (const [accountId, account] of this.accounts.entries()) {
    const triggers = account.collectExitPlanTriggers();
    for (const trigger of triggers) {
      const key = `${accountId}:${trigger.symbol}`;
      if (this.pendingAutoCloses.has(key)) continue;  // Debounce
      this.pendingAutoCloses.add(key);
      autoCloseQueue.push({ accountId, symbol: trigger.symbol, trigger: trigger.trigger });
    }
    this.emitAccountSnapshot(accountId);
  }

  // Execute auto-closes
  for (const request of autoCloseQueue) {
    try {
      const outcomes = await this.closePositions([request.symbol], request.accountId);
      const outcome = outcomes[request.symbol];

      if (outcome?.status === "rejected") {
        console.warn(`[Simulator] Auto-close rejected:`, outcome.reason);
        const account = this.accounts.get(request.accountId);
        account?.clearPendingExit(request.symbol);
      } else if (outcome) {
        // Update Orders table
        const dbOrder = await getOpenOrderBySymbol(request.accountId, request.symbol);
        if (dbOrder) {
          await closeOrder({
            orderId: dbOrder.id,
            exitPrice: outcome.averagePrice.toString(),
            realizedPnl: pnl.toString(),
            closeTrigger: request.trigger,
          });
        }

        // Record in ToolCalls for audit
        await createToolCallMutation({
          invocationId: invocation.id,
          type: ToolCallType.CLOSE_POSITION,
          metadata: JSON.stringify({
            symbols: [request.symbol],
            autoTrigger: request.trigger,
          }),
        });

        await emitAllDataChanged(request.accountId);
      }
    } finally {
      this.pendingAutoCloses.delete(`${request.accountId}:${request.symbol}`);
    }
  }
}
Trigger Logic:
// src/server/features/simulator/accountState.ts (conceptual)
collectExitPlanTriggers(): ExitTrigger[] {
  const triggers = [];
  for (const [symbol, position] of this.positions) {
    const exitPlan = this.exitPlans.get(symbol);
    if (!exitPlan) continue;

    const markPrice = this.markPrices.get(symbol) ?? position.avgEntryPrice;
    const isLong = position.side === "LONG";

    // Check stop loss
    if (exitPlan.stop) {
      if ((isLong && markPrice <= exitPlan.stop) || (!isLong && markPrice >= exitPlan.stop)) {
        triggers.push({ symbol, trigger: "STOP" });
      }
    }

    // Check take profit
    if (exitPlan.target) {
      if ((isLong && markPrice >= exitPlan.target) || (!isLong && markPrice <= exitPlan.target)) {
        triggers.push({ symbol, trigger: "TARGET" });
      }
    }
  }
  return triggers;
}
Auto-closes are fire-and-forget. If an auto-close fails (e.g., orderbook unavailable), the position remains open and the trigger is cleared to prevent retry loops.

Event Bus

The simulator emits events for real-time UI updates:
export type MarketEvent =
  | { type: "trade"; payload: TradePayload }
  | { type: "account"; payload: AccountPayload };

// Subscribe to events
simulator.on("trade", (event) => {
  console.log(`Trade executed: ${event.payload.symbol} ${event.payload.side}`);
});

simulator.on("account", (event) => {
  console.log(`Account updated: equity = ${event.payload.snapshot.equity}`);
});

Configuration Options

export interface ExchangeSimulatorOptions {
  initialCapital: number;           // Starting cash (default: 10,000)
  slippageBps: number;              // Slippage in basis points (default: 10)
  feeRateBps: number;               // Taker fee in basis points (default: 6)
  partialFillProbability: number;   // Chance of partial fill (default: 0.05)
  refreshIntervalMs: number;        // Polling interval (default: 5000)
}

// src/env.ts
export const DEFAULT_SIMULATOR_OPTIONS: ExchangeSimulatorOptions = {
  initialCapital: 10_000,
  slippageBps: 10,         // 0.1% slippage
  feeRateBps: 6,           // 0.06% taker fee (matches Lighter)
  partialFillProbability: 0.05,
  refreshIntervalMs: 5_000,
};

Switching to Live Trading

To use the Lighter API instead of the simulator:
  1. Set USE_LIVE_TRADING=true in .env.local
  2. Configure Lighter API key: LIGHTER_API_KEY=your_key
  3. The trading logic automatically uses lighterApi.placeOrder() instead of simulator.placeOrder()
// src/server/features/trading/createPosition.ts (conceptual)
const isLive = process.env.USE_LIVE_TRADING === "true";

if (isLive) {
  const result = await lighterApi.placeOrder({
    symbol: req.symbol,
    side: req.side,
    quantity: req.quantity,
    orderType: "market",
  });
} else {
  const simulator = await ExchangeSimulator.bootstrap();
  const result = await simulator.placeOrder(req, account.id);
}
Both backends use the same Orders table schema, so switching between simulator and live trading is seamless.

Testing & Debugging

Reset a specific account’s simulator state:
const simulator = await ExchangeSimulator.bootstrap();
const snapshot = simulator.resetAccount("model-123");
console.log(snapshot);  // { cash: 10000, positions: [], equity: 10000, ... }
Get current account snapshot:
const snapshot = simulator.getAccountSnapshot("model-123");
console.log(`Equity: $${snapshot.equity.toFixed(2)}`);

Autonomous Trading Loop

How agents interact with the simulator

Database Schema

Orders table structure and exit plans

Order Execution

Order placement and fill tracking

Deployment Guide

Deploy the backend with trading configuration

Build docs developers (and LLMs) love