kalshi-backtest/src/execution.rs
2026-01-25 01:20:44 -07:00

412 lines
10 KiB
Rust

use crate::data::HistoricalData;
use crate::types::{
ExitConfig, ExitReason, ExitSignal, Fill, MarketCandidate, Side,
Signal, TradingContext,
};
use async_trait::async_trait;
use rust_decimal::Decimal;
use rust_decimal::prelude::ToPrimitive;
use std::collections::HashMap;
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 {
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,
}
}
}
#[async_trait]
pub trait OrderExecutor: Send + Sync {
async fn execute_signal(
&self,
signal: &Signal,
context: &TradingContext,
) -> Option<Fill>;
fn generate_signals(
&self,
candidates: &[MarketCandidate],
context: &TradingContext,
) -> Vec<Signal>;
fn generate_exit_signals(
&self,
context: &TradingContext,
candidate_scores: &HashMap<String, f64>,
) -> Vec<ExitSignal>;
}
/// maps scoring edge [-inf, +inf] to win probability [0, 1]
pub fn edge_to_win_probability(edge: f64) -> f64 {
(1.0 + edge.tanh()) / 2.0
}
pub 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 fn candidate_to_signal(
candidate: &MarketCandidate,
context: &TradingContext,
sizing_config: &PositionSizingConfig,
max_position_size: u64,
) -> 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 >= max_position_size {
return None;
}
let yes_price =
candidate.current_yes_price.to_f64().unwrap_or(0.5);
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,
sizing_config,
);
let max_affordable = (available_cash / price_f64) as u64;
let quantity = kelly_qty
.min(max_affordable)
.min(max_position_size - current_qty);
if quantity < 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 compute_exit_signals(
context: &TradingContext,
candidate_scores: &HashMap<String, f64>,
exit_config: &ExitConfig,
price_lookup: &dyn Fn(&str) -> Option<Decimal>,
) -> Vec<ExitSignal> {
let mut exits = Vec::new();
for (ticker, position) in &context.portfolio.positions {
let current_price = match price_lookup(ticker) {
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 >= exit_config.take_profit_pct {
exits.push(ExitSignal {
ticker: ticker.clone(),
reason: ExitReason::TakeProfit { pnl_pct },
current_price,
});
continue;
}
if pnl_pct <= -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 >= 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 < exit_config.score_reversal_threshold {
exits.push(ExitSignal {
ticker: ticker.clone(),
reason: ExitReason::ScoreReversal { new_score },
current_price,
});
}
}
}
exits
}
pub struct BacktestExecutor {
data: Arc<HistoricalData>,
slippage_bps: u32,
max_position_size: u64,
sizing_config: PositionSizingConfig,
exit_config: ExitConfig,
}
impl BacktestExecutor {
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
}
}
#[async_trait]
impl OrderExecutor for BacktestExecutor {
async 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,
})
}
fn generate_signals(
&self,
candidates: &[MarketCandidate],
context: &TradingContext,
) -> Vec<Signal> {
candidates
.iter()
.filter_map(|c| {
candidate_to_signal(
c,
context,
&self.sizing_config,
self.max_position_size,
)
})
.collect()
}
fn generate_exit_signals(
&self,
context: &TradingContext,
candidate_scores: &HashMap<String, f64>,
) -> Vec<ExitSignal> {
let data = self.data.clone();
let timestamp = context.timestamp;
compute_exit_signals(
context,
candidate_scores,
&self.exit_config,
&|ticker| data.get_current_price(ticker, 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()
}