Skip to main content

Overview

The ExchangeSimulator provides realistic trading behavior without risking capital. It simulates order matching, position lifecycle, margin requirements, and automated exit execution.

Account State Management

Account Structure

Each account maintains:
  • Cash balance - Current cash position (can go negative with leverage)
  • Available cash - Free capital available for new positions
  • Margin balance - Total margin locked in open positions
  • Equity - Total account value (cash + unrealized P&L)
  • Positions - Open position details with real-time P&L
interface AccountSnapshot {
  cashBalance: number;
  availableCash: number;
  borrowedBalance: number;
  equity: number;
  marginBalance: number;
  quoteCurrency: string;
  positions: PositionSummary[];
  totalRealizedPnl: number;
  totalUnrealizedPnl: number;
}

Retrieving Account State

import { orpc } from "@/server/orpc/client";

const { account } = await orpc.simulator.getAccount({
  accountId: "model-apex-123",
});

console.log(`Equity: ${account.equity} ${account.quoteCurrency}`);
console.log(`Available: ${account.availableCash}`);
console.log(`Margin Used: ${account.marginBalance}`);
console.log(`Open Positions: ${account.positions.length}`);

Balance Calculations

The simulator tracks capital allocation with leverage support:
src/server/features/simulator/accountState.ts
getSnapshot(): AccountSnapshot {
  const positions: PositionSummary[] = [];
  let unrealizedTotal = 0;
  let netPositionValue = 0;
  let totalMargin = 0;

  for (const [symbol, position] of this.positions.entries()) {
    if (position.quantity === 0) continue;

    const side = position.quantity >= 0 ? "LONG" : "SHORT";
    const absoluteQuantity = Math.abs(position.quantity);

    // Calculate unrealized P&L
    const unrealized =
      side === "LONG"
        ? (position.markPrice - position.avgEntryPrice) * absoluteQuantity
        : (position.avgEntryPrice - position.markPrice) * absoluteQuantity;
    unrealizedTotal += unrealized;

    netPositionValue += position.markPrice * position.quantity;
    totalMargin += position.margin;

    // Calculate effective leverage
    const notional = Math.abs(position.quantity) * position.avgEntryPrice;
    const leverage = position.margin > 0 ? notional / position.margin : null;

    positions.push({
      symbol,
      quantity: absoluteQuantity,
      side,
      avgEntryPrice: position.avgEntryPrice,
      realizedPnl: position.realizedPnl,
      unrealizedPnl: unrealized,
      markPrice: position.markPrice,
      margin: position.margin,
      notional,
      leverage,
      exitPlan: position.exitPlan,
    });
  }

  const equity = this.cashBalance + netPositionValue;
  const borrowedBalance = Math.max(-this.cashBalance, 0);
  
  // Available cash = initial capital - margin used + realized P&L
  // Note: Unrealized P&L does NOT affect available cash
  const availableCash = Math.max(
    this.options.initialCapital - totalMargin + this.totalRealized,
    0,
  );

  return {
    cashBalance: this.cashBalance,
    availableCash,
    borrowedBalance,
    equity,
    marginBalance: totalMargin,
    quoteCurrency: this.quoteCurrency,
    positions,
    totalRealizedPnl: this.totalRealized,
    totalUnrealizedPnl: unrealizedTotal,
  };
}
Available cash only increases when positions are closed (realized P&L). Unrealized P&L does not affect buying power.

Order Execution

Order Types

The simulator supports two order types:
market
order type
Executes immediately at best available price by walking the order book. May result in partial fills if liquidity is insufficient.
limit
order type
Places order at specified price. If price crosses the spread, acts as taker (immediate execution). Otherwise, fills at limit price as maker.

Placing Orders

import { orpc } from "@/server/orpc/client";

// Market order - immediate execution
const { order: marketOrder } = await orpc.simulator.placeOrder({
  accountId: "model-apex-123",
  symbol: "BTC",
  side: "buy",
  quantity: 0.5,
  type: "market",
  leverage: 2.0,
  confidence: 0.75,
});

// Limit order - specified price
const { order: limitOrder } = await orpc.simulator.placeOrder({
  accountId: "model-apex-123",
  symbol: "ETH",
  side: "sell",
  quantity: 10,
  type: "limit",
  limitPrice: 2500.00,
  leverage: 1.5,
});

Order Matching Logic

The simulator uses realistic matching with order book depth:
src/server/features/simulator/orderMatching.ts
function asTaker(ctx: MatchingContext): OrderExecution {
  const { order, book } = ctx;
  const levels = order.side === "buy" ? book.asks : book.bids;

  if (!levels || levels.length === 0) {
    return {
      fills: [],
      averagePrice: 0,
      totalQuantity: 0,
      totalFees: 0,
      status: "rejected",
      reason: "no liquidity available",
    };
  }

  let remaining = order.quantity;
  const fills: FillDetail[] = [];
  let totalNotional = 0;

  // Walk the order book
  for (const level of levels) {
    if (remaining <= 0) break;
    const executable = Math.min(remaining, level.quantity);
    if (executable <= 0) continue;

    fills.push({
      quantity: executable,
      price: level.price,
    });
    remaining -= executable;
    totalNotional += level.price * executable;
  }

  const totalQuantity = fills.reduce((sum, fill) => sum + fill.quantity, 0);
  const averagePrice = totalNotional / totalQuantity;
  const status = remaining > 0 ? "partial" : "filled";

  return {
    fills,
    averagePrice,
    totalQuantity,
    totalFees: 0,
    status,
    reason: status === "partial" ? "insufficient book depth" : undefined,
  };
}

Execution Statuses

filled
status
Order fully executed at one or more price levels
partial
status
Order partially executed - insufficient liquidity to fill complete quantity
rejected
status
Order failed validation (insufficient cash, no liquidity, invalid parameters)

Fill Details

Each execution returns detailed fill information:
interface SimulatedOrderResult {
  symbol: string;
  side: OrderSide;
  type: OrderType;
  fills: FillDetail[];           // Price/quantity for each fill level
  averagePrice: number;          // Volume-weighted average
  totalQuantity: number;         // Total executed quantity
  totalFees: number;             // Transaction fees (currently 0)
  status: "filled" | "partial" | "rejected";
  reason?: string;               // Rejection reason if applicable
}

Position Management

Position Lifecycle

Positions are created and modified through order execution:
  1. Opening - Buy order creates LONG position, sell order creates SHORT position
  2. Adding - Same-side order increases position size (average entry price recalculated)
  3. Reducing - Opposite-side order decreases position size (realizes P&L)
  4. Flipping - Large opposite-side order closes position and opens reverse position
  5. Closing - Opposite-side order equal to position size fully closes position

Position Tracking

interface PositionSummary {
  symbol: string;
  quantity: number;              // Absolute quantity (always positive)
  side: "LONG" | "SHORT";        // Position direction
  avgEntryPrice: number;         // Volume-weighted average entry
  realizedPnl: number;           // Cumulative realized P&L for this position
  unrealizedPnl: number;         // Current mark-to-market P&L
  markPrice: number;             // Current market price
  margin: number;                // Margin allocated to this position
  notional: number;              // Position value (quantity * price)
  leverage: number | null;       // Effective leverage (notional / margin)
  exitPlan: PositionExitPlan | null;  // Stop-loss/take-profit settings
}

Retrieving Open Positions

import { orpc } from "@/server/orpc/client";

const { account } = await orpc.simulator.getAccount({
  accountId: "model-apex-123",
});

for (const position of account.positions) {
  console.log(`${position.symbol} ${position.side}:`);
  console.log(`  Quantity: ${position.quantity}`);
  console.log(`  Entry: ${position.avgEntryPrice}`);
  console.log(`  Mark: ${position.markPrice}`);
  console.log(`  Unrealized P&L: ${position.unrealizedPnl}`);
  console.log(`  Leverage: ${position.leverage}x`);
}

Position State Updates

The simulator applies executions with precise P&L tracking:
src/server/features/simulator/accountState.ts
applyExecution(
  symbol: string,
  side: OrderSide,
  execution: OrderExecution,
  leverage?: number | null,
) {
  const direction = side === "buy" ? 1 : -1;
  let position = this.positions.get(symbol) || {
    quantity: 0,
    avgEntryPrice: 0,
    realizedPnl: 0,
    markPrice: execution.fills[0]?.price ?? 0,
    margin: 0,
    exitPlan: null,
  };

  // Reset realized P&L if starting a new position from 0
  if (position.quantity === 0) {
    position.realizedPnl = 0;
  }

  for (const fill of execution.fills) {
    const signedQty = direction * fill.quantity;
    const notional = fill.quantity * fill.price;
    const leverageFactor = this.resolveLeverage(leverage, position, fill.price);
    const startingQuantity = position.quantity;

    // Update cash balance
    this.cashBalance -= signedQty * fill.price;

    if (
      startingQuantity === 0 ||
      Math.sign(startingQuantity) === Math.sign(signedQty)
    ) {
      // Adding to position or opening new position
      const totalQty = startingQuantity + signedQty;
      const prevNotional = position.avgEntryPrice * Math.abs(startingQuantity);
      const newNotional = fill.price * Math.abs(signedQty);
      position.quantity = totalQty;
      position.avgEntryPrice =
        totalQty !== 0 ? (prevNotional + newNotional) / Math.abs(totalQty) : 0;
      position.margin += notional / leverageFactor;
    } else {
      // Reducing position or flipping
      const existingAbs = Math.abs(startingQuantity);
      const closingQty = Math.min(existingAbs, Math.abs(signedQty));

      // Release margin proportionally
      if (existingAbs > 0) {
        const marginRelease = position.margin * (closingQty / existingAbs);
        position.margin -= marginRelease;
      }

      // Calculate realized P&L
      const realized =
        startingQuantity > 0
          ? (fill.price - position.avgEntryPrice) * closingQty
          : (position.avgEntryPrice - fill.price) * closingQty;

      position.realizedPnl += realized;
      this.totalRealized += realized;

      const remainingQty = startingQuantity + signedQty;

      if (remainingQty === 0) {
        // Position fully closed
        position.quantity = 0;
        position.avgEntryPrice = 0;
        position.margin = 0;
      } else if (Math.sign(remainingQty) !== Math.sign(startingQuantity)) {
        // Position flipped to opposite side
        const openedQty = Math.abs(remainingQty);
        const marginForFlip = (openedQty * fill.price) / leverageFactor;
        position.quantity = remainingQty;
        position.avgEntryPrice = fill.price;
        position.margin = marginForFlip;
        position.realizedPnl = 0;  // Reset for new position
      } else {
        // Position reduced but not closed
        position.quantity = remainingQty;
      }
    }

    position.markPrice = fill.price;
  }

  // Remove position if fully closed with negligible realized P&L
  if (position.quantity === 0 && Math.abs(position.realizedPnl) < 0.01) {
    this.positions.delete(symbol);
  } else {
    if (position.quantity === 0) {
      position.exitPlan = null;
      position.autoClosePending = false;
    }
    this.positions.set(symbol, position);
  }
}

Exit Plans (Stop-Loss / Take-Profit)

Exit Plan Structure

interface PositionExitPlan {
  stop: number | null;              // Stop-loss price
  target: number | null;            // Take-profit price
  invalidation: string | null;      // Text description of invalidation condition
  invalidationPrice?: number | null;  // Optional invalidation price level
  timeExit?: string | null;         // Optional time-based exit
  cooldownUntil?: string | null;    // Optional cooldown period
}

Setting Exit Plans

Exit plans can be set when opening positions or updated later:
import { orpc } from "@/server/orpc/client";

// Set exit plan when opening position
const { order } = await orpc.simulator.placeOrder({
  accountId: "model-apex-123",
  symbol: "BTC",
  side: "buy",
  quantity: 0.5,
  type: "market",
  exitPlan: {
    stop: 42000,      // Stop-loss at $42k
    target: 48000,    // Take-profit at $48k
    invalidation: "Daily close below $41k invalidates bullish thesis",
  },
});

Automatic Exit Execution

The simulator monitors exit plans during each market refresh cycle:
src/server/features/simulator/accountState.ts
collectExitPlanTriggers(): { symbol: string; trigger: "STOP" | "TARGET" }[] {
  const triggers: { symbol: string; trigger: "STOP" | "TARGET" }[] = [];

  for (const [symbol, position] of this.positions.entries()) {
    if (position.quantity === 0 || position.autoClosePending) {
      continue;
    }

    const exitPlan = position.exitPlan;
    if (!exitPlan) continue;

    const markPrice = position.markPrice;
    const isLong = position.quantity > 0;
    const stop = exitPlan.stop;
    const target = exitPlan.target;

    if (isLong) {
      // Long position: stop if price drops below stop, target if price rises above target
      if (stop != null && markPrice <= stop) {
        position.autoClosePending = true;
        triggers.push({ symbol, trigger: "STOP" });
        continue;
      }
      if (target != null && markPrice >= target) {
        position.autoClosePending = true;
        triggers.push({ symbol, trigger: "TARGET" });
      }
    } else {
      // Short position: stop if price rises above stop, target if price drops below target
      if (stop != null && markPrice >= stop) {
        position.autoClosePending = true;
        triggers.push({ symbol, trigger: "STOP" });
        continue;
      }
      if (target != null && markPrice <= target) {
        position.autoClosePending = true;
        triggers.push({ symbol, trigger: "TARGET" });
      }
    }
  }

  return triggers;
}
When a trigger is detected, the simulator:
  1. Collects triggers during market refresh
  2. Queues auto-close operations
  3. Executes market order to close position
  4. Updates database - Records exit in Orders table with closeTrigger field
  5. Logs trade - Creates CLOSE_POSITION entry in ToolCalls table with autoTrigger metadata
  6. Emits events - Notifies clients of position closure
Exit plans execute at market price when triggered. Actual fill price may differ slightly from trigger price due to spread and order book depth.

Margin & Leverage

Margin Calculation

Margin is allocated based on position notional and leverage:
const notional = quantity * price;           // Position value
const margin = notional / leverage;          // Margin required
const availableCash = initialCapital - totalMarginUsed + realizedPnl;

Leverage Rules

  • Default leverage: 1x (no borrowing)
  • Maximum leverage: No hard limit enforced by simulator (exchange limits apply in live mode)
  • Margin requirement: notional / leverage must not exceed available cash
  • Liquidation: Not implemented in simulator (positions can go deeply underwater)

Cash Sufficiency Check

Before executing orders, the simulator validates sufficient capital:
src/server/features/simulator/accountState.ts
hasSufficientCash(
  symbol: string,
  side: OrderSide,
  execution: OrderExecution,
  leverage?: number | null,
): boolean {
  // Clone account and apply execution
  const preview = this.clone();
  preview.applyExecution(symbol, side, execution, leverage);

  const projectedEquity = preview.computeEquityValue();
  const projectedMargin = preview.calculateTotalMargin();

  // Allow small epsilon for floating point precision
  return projectedEquity + AccountState.CASH_EPSILON >= projectedMargin;
}
The simulator allows negative cash balance (borrowing) as long as equity remains above total margin requirement.

Event System

The simulator emits events for real-time updates:

Event Types

type MarketEvent =
  | { type: "book"; payload: OrderBookSnapshot }
  | { type: "trade"; payload: TradeEventPayload }
  | { type: "account"; payload: AccountEventPayload };

Subscribing to Events

import { ExchangeSimulator } from "@/server/features/simulator/exchangeSimulator";

const simulator = await ExchangeSimulator.bootstrap();

// Listen for trade executions
simulator.on("trade", (event) => {
  if (event.type === "trade") {
    const { symbol, result, realizedPnl, accountValue } = event.payload;
    console.log(`Trade: ${symbol} @ ${result.averagePrice}`);
    console.log(`Realized P&L: ${realizedPnl}`);
    console.log(`Account Value: ${accountValue}`);
  }
});

// Listen for account updates
simulator.on("account", (event) => {
  if (event.type === "account") {
    const { accountId, snapshot } = event.payload;
    console.log(`Account ${accountId}: ${snapshot.equity}`);
  }
});

Database Integration

Orders Table as Source of Truth

All positions are persisted to the Orders table:
  • OPEN status = Active position
  • CLOSED status = Completed trade
  • Fields: symbol, side, quantity, entryPrice, exitPrice, leverage, exitPlan, closeTrigger
The simulator rehydrates state from this table on startup (see Position Rehydration).

Trade History Tracking

Completed trades are logged in the ToolCalls table:
import { orpc } from "@/server/orpc/client";

const { trades, stats } = await orpc.simulator.getCompletedTradesFromDB({
  modelId: "model-apex-123",
  limit: 50,
});

console.log(`Total trades: ${stats.tradeCount}`);
console.log(`Total realized P&L: ${stats.totalRealized}`);
console.log(`Expectancy: ${stats.expectancy}`);
console.log(`Average leverage: ${stats.averageLeverage}x`);

for (const trade of trades) {
  console.log(`${trade.symbol} ${trade.direction}: ${trade.realizedPnl}`);
}

Next Steps

Order Execution

Learn to place orders and track fills

Position Management

Detailed guide to position lifecycle and P&L calculation

Build docs developers (and LLMs) love