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:
Executes immediately at best available price by walking the order book. May result in partial fills if liquidity is insufficient.
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
Order fully executed at one or more price levels
Order partially executed - insufficient liquidity to fill complete quantity
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:
Opening - Buy order creates LONG position, sell order creates SHORT position
Adding - Same-side order increases position size (average entry price recalculated)
Reducing - Opposite-side order decreases position size (realizes P&L)
Flipping - Large opposite-side order closes position and opens reverse position
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:
Collects triggers during market refresh
Queues auto-close operations
Executes market order to close position
Updates database - Records exit in Orders table with closeTrigger field
Logs trade - Creates CLOSE_POSITION entry in ToolCalls table with autoTrigger metadata
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