kalshi-backtest/src/execution.rs
Nicholai 3621d93643 feat(backtest): optimize exit strategy and position sizing
6 iterations of backtest refinements with key discoveries:
- stop losses don't work for prediction markets (prices gap)
- 50% take profit, no stop loss yields +9.37% vs +4.04% baseline
- diversification beats concentration: 100 positions → +18.98%
- added kalman filter, VPIN, regime detection scorers (research)

exit config: take_profit 50%, stop_loss disabled, 48h max hold
position sizing: kelly 0.40, max 30% per position, 100 max positions
2026-01-22 11:16:23 -07:00

329 lines
9.8 KiB
Rust

use crate::data::HistoricalData;
use crate::types::{ExitConfig, ExitReason, ExitSignal, Fill, MarketCandidate, Side, Signal, TradingContext};
use rust_decimal::Decimal;
use rust_decimal::prelude::ToPrimitive;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct PositionSizingConfig {
pub kelly_fraction: f64,
pub max_position_pct: f64,
pub min_position_size: u64,
pub max_position_size: u64,
}
impl Default for PositionSizingConfig {
fn default() -> Self {
// iteration 4: increased kelly from 0.25 to 0.40
// research shows half-kelly to full-kelly range works well
// with 100% win rate on closed trades, we can be more aggressive
Self {
kelly_fraction: 0.40,
max_position_pct: 0.30,
min_position_size: 10,
max_position_size: 1000,
}
}
}
impl PositionSizingConfig {
pub fn conservative() -> Self {
Self {
kelly_fraction: 0.1,
max_position_pct: 0.1,
min_position_size: 10,
max_position_size: 500,
}
}
pub fn aggressive() -> Self {
Self {
kelly_fraction: 0.5,
max_position_pct: 0.4,
min_position_size: 10,
max_position_size: 2000,
}
}
}
/// maps scoring edge [-inf, +inf] to win probability [0, 1]
/// tanh squashes extreme values smoothly; +1)/2 shifts from [-1,1] to [0,1]
fn edge_to_win_probability(edge: f64) -> f64 {
(1.0 + edge.tanh()) / 2.0
}
fn kelly_size(
edge: f64,
price: f64,
bankroll: f64,
config: &PositionSizingConfig,
) -> u64 {
if edge.abs() < 0.01 || price <= 0.0 || price >= 1.0 {
return 0;
}
let win_prob = edge_to_win_probability(edge);
let odds = (1.0 - price) / price;
if odds <= 0.0 {
return 0;
}
let kelly = (odds * win_prob - (1.0 - win_prob)) / odds;
let safe_kelly = (kelly * config.kelly_fraction).max(0.0);
let position_value = bankroll * safe_kelly.min(config.max_position_pct);
let shares = (position_value / price).floor() as u64;
shares.max(config.min_position_size).min(config.max_position_size)
}
pub struct Executor {
data: Arc<HistoricalData>,
slippage_bps: u32,
max_position_size: u64,
sizing_config: PositionSizingConfig,
exit_config: ExitConfig,
}
impl Executor {
pub fn new(data: Arc<HistoricalData>, slippage_bps: u32, max_position_size: u64) -> Self {
Self {
data,
slippage_bps,
max_position_size,
sizing_config: PositionSizingConfig::default(),
exit_config: ExitConfig::default(),
}
}
pub fn with_sizing_config(mut self, config: PositionSizingConfig) -> Self {
self.sizing_config = config;
self
}
pub fn with_exit_config(mut self, config: ExitConfig) -> Self {
self.exit_config = config;
self
}
pub fn generate_exit_signals(
&self,
context: &TradingContext,
candidate_scores: &std::collections::HashMap<String, f64>,
) -> Vec<ExitSignal> {
let mut exits = Vec::new();
for (ticker, position) in &context.portfolio.positions {
let current_price = match self.data.get_current_price(ticker, context.timestamp) {
Some(p) => p,
None => continue,
};
let effective_price = match position.side {
Side::Yes => current_price,
Side::No => Decimal::ONE - current_price,
};
let entry_price_f64 = position.avg_entry_price.to_f64().unwrap_or(0.5);
let current_price_f64 = effective_price.to_f64().unwrap_or(0.5);
if entry_price_f64 <= 0.0 {
continue;
}
let pnl_pct = (current_price_f64 - entry_price_f64) / entry_price_f64;
if pnl_pct >= self.exit_config.take_profit_pct {
exits.push(ExitSignal {
ticker: ticker.clone(),
reason: ExitReason::TakeProfit { pnl_pct },
current_price,
});
continue;
}
if pnl_pct <= -self.exit_config.stop_loss_pct {
exits.push(ExitSignal {
ticker: ticker.clone(),
reason: ExitReason::StopLoss { pnl_pct },
current_price,
});
continue;
}
let hours_held = (context.timestamp - position.entry_time).num_hours();
if hours_held >= self.exit_config.max_hold_hours {
exits.push(ExitSignal {
ticker: ticker.clone(),
reason: ExitReason::TimeStop { hours_held },
current_price,
});
continue;
}
if let Some(&new_score) = candidate_scores.get(ticker) {
if new_score < self.exit_config.score_reversal_threshold {
exits.push(ExitSignal {
ticker: ticker.clone(),
reason: ExitReason::ScoreReversal { new_score },
current_price,
});
}
}
}
exits
}
pub fn generate_signals(
&self,
candidates: &[MarketCandidate],
context: &TradingContext,
) -> Vec<Signal> {
candidates
.iter()
.filter_map(|c| self.candidate_to_signal(c, context))
.collect()
}
fn candidate_to_signal(
&self,
candidate: &MarketCandidate,
context: &TradingContext,
) -> Option<Signal> {
let current_position = context.portfolio.get_position(&candidate.ticker);
let current_qty = current_position.map(|p| p.quantity).unwrap_or(0);
if current_qty >= self.max_position_size {
return None;
}
let yes_price = candidate.current_yes_price.to_f64().unwrap_or(0.5);
// positive score = bullish signal, so buy the cheaper side (better risk/reward)
// negative score = bearish signal, so buy against the expensive side
let side = if candidate.final_score > 0.0 {
if yes_price < 0.5 { Side::Yes } else { Side::No }
} else if candidate.final_score < 0.0 {
if yes_price > 0.5 { Side::No } else { Side::Yes }
} else {
return None;
};
let price = match side {
Side::Yes => candidate.current_yes_price,
Side::No => candidate.current_no_price,
};
let available_cash = context.portfolio.cash.to_f64().unwrap_or(0.0);
let price_f64 = price.to_f64().unwrap_or(0.5);
if price_f64 <= 0.0 {
return None;
}
let kelly_qty = kelly_size(
candidate.final_score,
price_f64,
available_cash,
&self.sizing_config,
);
let max_affordable = (available_cash / price_f64) as u64;
let quantity = kelly_qty
.min(max_affordable)
.min(self.max_position_size - current_qty);
if quantity < self.sizing_config.min_position_size {
return None;
}
Some(Signal {
ticker: candidate.ticker.clone(),
side,
quantity,
limit_price: Some(price),
reason: format!(
"score={:.3}, side={:?}, price={:.2}",
candidate.final_score, side, price_f64
),
})
}
pub fn execute_signal(
&self,
signal: &Signal,
context: &TradingContext,
) -> Option<Fill> {
let market_price = self.data.get_current_price(&signal.ticker, context.timestamp)?;
let effective_price = match signal.side {
Side::Yes => market_price,
Side::No => Decimal::ONE - market_price,
};
let slippage = Decimal::from(self.slippage_bps) / Decimal::from(10000);
let fill_price = effective_price * (Decimal::ONE + slippage);
if let Some(limit) = signal.limit_price {
if fill_price > limit * (Decimal::ONE + slippage * Decimal::from(2)) {
return None;
}
}
let cost = fill_price * Decimal::from(signal.quantity);
if cost > context.portfolio.cash {
let affordable = (context.portfolio.cash / fill_price)
.to_u64()
.unwrap_or(0);
if affordable == 0 {
return None;
}
return Some(Fill {
ticker: signal.ticker.clone(),
side: signal.side,
quantity: affordable,
price: fill_price,
timestamp: context.timestamp,
});
}
Some(Fill {
ticker: signal.ticker.clone(),
side: signal.side,
quantity: signal.quantity,
price: fill_price,
timestamp: context.timestamp,
})
}
}
pub fn simple_signal_generator(
candidates: &[MarketCandidate],
context: &TradingContext,
position_size: u64,
) -> Vec<Signal> {
candidates
.iter()
.filter(|c| c.final_score > 0.0)
.filter(|c| !context.portfolio.has_position(&c.ticker))
.map(|c| {
let yes_price = c.current_yes_price.to_f64().unwrap_or(0.5);
let (side, price) = if yes_price < 0.5 {
(Side::Yes, c.current_yes_price)
} else {
(Side::No, c.current_no_price)
};
Signal {
ticker: c.ticker.clone(),
side,
quantity: position_size,
limit_price: Some(price),
reason: format!("simple: score={:.3}", c.final_score),
}
})
.collect()
}