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 :
AccountState : Tracks cash, positions, P&L, and exit plans per trading account
MarketState : Fetches and caches live orderbook data from exchanges
OrderMatching : Simulates realistic fills with slippage and partial fills
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:
Fetches latest market prices
Checks all open positions for stop/target triggers
Queues auto-close orders
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:
Set USE_LIVE_TRADING=true in .env.local
Configure Lighter API key: LIGHTER_API_KEY=your_key
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