/// @module std::finance::backtest::engine
/// Backtest Engine
///
/// Main backtesting wrapper that uses the high-performance simulation engine.
/// Wraps simulate() with finance-specific state management and order processing.
// Import state and fill modules
// from std::finance::backtest::state use { initial_state, is_flat, is_long, is_short }
// from std::finance::backtest::fills use { simulate_fill, fixed_slippage, no_commission }
// ===== Backtest Configuration =====
/// Backtest configuration
pub type BacktestConfig = {
initial_capital: number; // Starting capital
position_sizing: string; // "fixed" | "percent" | "kelly"
fixed_size: number; // Fixed position size (for "fixed" mode)
percent_size: number; // Position size as % of equity (for "percent" mode)
max_position: number; // Maximum position size
slippage_bps: number; // Slippage in basis points
commission_pct: number; // Commission as percentage
allow_short: bool; // Whether to allow short selling
};
/// Create default backtest configuration
pub fn default_config() {
{
initial_capital: 100000.0,
position_sizing: "percent",
fixed_size: 100.0,
percent_size: 10.0,
max_position: 1000.0,
slippage_bps: 5.0,
commission_pct: 0.1,
allow_short: true
}
}
// ===== Position Sizing =====
/// Calculate position size based on config and current state
pub fn calculate_position_size(state, price, config) {
let size = 0.0;
if config.position_sizing == "fixed" {
size = config.fixed_size;
} else if config.position_sizing == "percent" {
// Percent of equity
let equity = state.cash + abs(state.position) * price;
size = floor((equity * config.percent_size / 100.0) / price);
} else {
// Default to fixed
size = config.fixed_size;
}
// Apply maximum constraint
if size > config.max_position {
size = config.max_position;
}
// Ensure we can afford it
let cost = size * price;
if cost > state.cash {
size = floor(state.cash / price);
}
size
}
// ===== Core Backtest Functions =====
/// Process a buy signal
/// Updates state and returns { state, result } for simulation
pub fn process_buy(candle, state, config) {
// Skip if already long
if state.position > 0 {
return state;
}
// Close short position first if exists
if state.position < 0 {
let close_result = close_position(candle, state, config);
state = close_result;
}
// Calculate position size
let size = calculate_position_size(state, candle.close, config);
if size <= 0 {
return state;
}
// Apply slippage
let slip = candle.close * config.slippage_bps / 10000.0;
let fill_price = candle.close + slip;
// Calculate commission
let commission = fill_price * size * config.commission_pct / 100.0;
// Update state
let cost = fill_price * size + commission;
{
cash: state.cash - cost,
position: size,
entry_price: fill_price,
equity: state.cash - cost + size * candle.close,
trades: state.trades,
wins: state.wins,
losses: state.losses,
peak_equity: state.peak_equity,
max_drawdown: state.max_drawdown,
total_pnl: state.total_pnl,
unrealized_pnl: 0.0
}
}
/// Process a sell signal
/// Updates state and returns { state, result } for simulation
pub fn process_sell(candle, state, config) {
// If long, close position
if state.position > 0 {
return close_position(candle, state, config);
}
// If flat and shorting allowed, open short
if state.position == 0 && config.allow_short {
let size = calculate_position_size(state, candle.close, config);
if size <= 0 {
return state;
}
// Apply slippage (favorable for shorts)
let slip = candle.close * config.slippage_bps / 10000.0;
let fill_price = candle.close - slip;
// Calculate commission
let commission = fill_price * size * config.commission_pct / 100.0;
// For shorts, we receive proceeds minus commission
let proceeds = fill_price * size - commission;
return {
cash: state.cash + proceeds,
position: -size,
entry_price: fill_price,
equity: state.equity,
trades: state.trades,
wins: state.wins,
losses: state.losses,
peak_equity: state.peak_equity,
max_drawdown: state.max_drawdown,
total_pnl: state.total_pnl,
unrealized_pnl: 0.0
};
}
state
}
/// Close current position
pub fn close_position(candle, state, config) {
if state.position == 0 {
return state;
}
let size = abs(state.position);
let is_long_pos = state.position > 0;
// Apply slippage
let slip = candle.close * config.slippage_bps / 10000.0;
let fill_price = if is_long_pos {
candle.close - slip // Selling, so worse price
} else {
candle.close + slip // Covering short, so worse price
};
// Calculate commission
let commission = fill_price * size * config.commission_pct / 100.0;
// Calculate P&L
let pnl = if is_long_pos {
(fill_price - state.entry_price) * size - commission
} else {
(state.entry_price - fill_price) * size - commission
};
// Update win/loss counters
let new_wins = state.wins;
let new_losses = state.losses;
if pnl > 0 {
new_wins = new_wins + 1;
} else {
new_losses = new_losses + 1;
}
// Calculate new equity
let proceeds = if is_long_pos {
fill_price * size - commission
} else {
// For short: we need to buy back shares
-(fill_price * size + commission)
};
let new_cash = state.cash + proceeds;
let new_equity = new_cash;
// Update peak and drawdown
let new_peak = state.peak_equity;
let new_dd = state.max_drawdown;
if new_equity > new_peak {
new_peak = new_equity;
}
let current_dd = (new_peak - new_equity) / new_peak;
if current_dd > new_dd {
new_dd = current_dd;
}
{
cash: new_cash,
position: 0.0,
entry_price: 0.0,
equity: new_equity,
trades: state.trades + 1,
wins: new_wins,
losses: new_losses,
peak_equity: new_peak,
max_drawdown: new_dd,
total_pnl: state.total_pnl + pnl,
unrealized_pnl: 0.0
}
}
/// Update unrealized P&L and equity based on current price
pub fn update_equity(candle, state) {
if state.position == 0 {
return state;
}
let current_value = abs(state.position) * candle.close;
let unrealized = if state.position > 0 {
(candle.close - state.entry_price) * state.position
} else {
(state.entry_price - candle.close) * abs(state.position)
};
let new_equity = state.cash + current_value;
// Update peak and drawdown
let new_peak = state.peak_equity;
let new_dd = state.max_drawdown;
if new_equity > new_peak {
new_peak = new_equity;
}
let current_dd = (new_peak - new_equity) / new_peak;
if current_dd > new_dd {
new_dd = current_dd;
}
{
cash: state.cash,
position: state.position,
entry_price: state.entry_price,
equity: new_equity,
trades: state.trades,
wins: state.wins,
losses: state.losses,
peak_equity: new_peak,
max_drawdown: new_dd,
total_pnl: state.total_pnl,
unrealized_pnl: unrealized
}
}
// ===== Main Backtest Function =====
/// Run a backtest using the simulation engine
///
/// @param data - Price series (must have open, high, low, close, volume)
/// @param strategy - Strategy function: (candle, state, idx) => signal
/// signal can be: "buy", "sell", "close", or None/none
/// @param config - BacktestConfig (optional, uses defaults if not provided)
///
/// @returns Simulation result with final_state containing all backtest metrics
///
/// @example
/// let result = backtest(prices, (candle, state, idx) => {
/// let sma = prices.rolling(20).mean();
/// if candle.close > sma.get(idx) && state.position == 0 {
/// "buy"
/// } else if candle.close < sma.get(idx) && state.position > 0 {
/// "sell"
/// } else {
/// None
/// }
/// });
pub fn backtest(data, strategy, config = None) {
// Use default config if not provided
let cfg = if config == None {
default_config()
} else {
config
};
// Create initial state
let init_state = {
cash: cfg.initial_capital,
position: 0.0,
entry_price: 0.0,
equity: cfg.initial_capital,
trades: 0,
wins: 0,
losses: 0,
peak_equity: cfg.initial_capital,
max_drawdown: 0.0,
total_pnl: 0.0,
unrealized_pnl: 0.0
};
// Run simulation with our step function
data.simulate(
|candle, state, idx| {
// Get signal from strategy
let signal = strategy(candle, state, idx);
// Process signal
let new_state = if signal == "buy" {
process_buy(candle, state, cfg)
} else if signal == "sell" {
process_sell(candle, state, cfg)
} else if signal == "close" {
close_position(candle, state, cfg)
} else {
// No signal - just update equity
update_equity(candle, state)
};
new_state
},
{ initial_state: init_state }
)
}
/// Run a multi-asset backtest using simulate_correlated
///
/// @param series_map - Object mapping names to series: { "spy": spy_data, "vix": vix_data }
/// @param strategy - Strategy function: (context, state, idx) => signal
/// @param config - BacktestConfig (optional)
///
/// @example
/// let result = backtest_correlated(
/// { spy: spy_prices, vix: vix_prices },
/// (ctx, state, idx) => {
/// if ctx.vix.close > 25 && state.position == 0 {
/// "buy"
/// } else if ctx.vix.close < 15 && state.position > 0 {
/// "sell"
/// } else {
/// None
/// }
/// }
/// );
pub fn backtest_correlated(series_map, strategy, config = None) {
let cfg = if config == None {
default_config()
} else {
config
};
let init_state = {
cash: cfg.initial_capital,
position: 0.0,
entry_price: 0.0,
equity: cfg.initial_capital,
trades: 0,
wins: 0,
losses: 0,
peak_equity: cfg.initial_capital,
max_drawdown: 0.0,
total_pnl: 0.0,
unrealized_pnl: 0.0,
asset: None
};
let asset_keys = keys(series_map);
let default_asset = if asset_keys.len() > 0 { asset_keys[0] } else { None };
simulate_correlated(
series_map,
|ctx, state, idx| {
let strat_result = strategy(ctx, state, idx);
let signal = strat_result;
let asset = if state.asset != None { state.asset } else { default_asset };
let price_override = None;
if strat_result != None && is_object(strat_result) {
if strat_result.signal != None { signal = strat_result.signal; }
if strat_result.asset != None { asset = strat_result.asset; }
if strat_result.price != None { price_override = strat_result.price; }
}
let candle = if price_override != None {
{ close: price_override }
} else if asset != None && ctx[asset] != None {
ctx[asset]
} else if ctx.row != None {
ctx.row
} else {
ctx
};
let updated = if signal == "buy" {
let s = process_buy(candle, state, cfg);
{ ...s, asset: asset }
} else if signal == "sell" {
let s = process_sell(candle, state, cfg);
{ ...s, asset: asset }
} else if signal == "close" {
let s = close_position(candle, state, cfg);
{ ...s, asset: None }
} else {
let equity_asset = if state.asset != None { state.asset } else { asset };
let equity_candle = if equity_asset != None && ctx[equity_asset] != None {
ctx[equity_asset]
} else {
candle
};
let s = update_equity(equity_candle, state);
{ ...s, asset: equity_asset }
};
updated
},
{ initial_state: init_state }
)
}