kalshi prediction market backtesting framework with: - trading pipeline (sources, filters, scorers, selectors) - position sizing with kelly criterion - multiple scoring strategies (momentum, mean reversion, etc) - random baseline for comparison refactoring includes: - extract shared resolve_closed_positions() function - reduce RandomBaseline::run() nesting with helper functions - move MarketCandidate Default impl to types.rs - add explanatory comments to complex logic
344 lines
8.9 KiB
Rust
344 lines
8.9 KiB
Rust
use chrono::{DateTime, Utc};
|
|
use rust_decimal::Decimal;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum Side {
|
|
Yes,
|
|
No,
|
|
}
|
|
|
|
impl Side {
|
|
pub fn opposite(&self) -> Self {
|
|
match self {
|
|
Side::Yes => Side::No,
|
|
Side::No => Side::Yes,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum MarketResult {
|
|
Yes,
|
|
No,
|
|
Cancelled,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct MarketCandidate {
|
|
pub ticker: String,
|
|
pub title: String,
|
|
pub category: String,
|
|
pub current_yes_price: Decimal,
|
|
pub current_no_price: Decimal,
|
|
pub volume_24h: u64,
|
|
pub total_volume: u64,
|
|
pub buy_volume_24h: u64,
|
|
pub sell_volume_24h: u64,
|
|
pub open_time: DateTime<Utc>,
|
|
pub close_time: DateTime<Utc>,
|
|
pub result: Option<MarketResult>,
|
|
pub price_history: Vec<PricePoint>,
|
|
|
|
pub scores: HashMap<String, f64>,
|
|
pub final_score: f64,
|
|
}
|
|
|
|
impl MarketCandidate {
|
|
pub fn time_to_close(&self, now: DateTime<Utc>) -> chrono::Duration {
|
|
self.close_time - now
|
|
}
|
|
|
|
pub fn is_open(&self, now: DateTime<Utc>) -> bool {
|
|
now >= self.open_time && now < self.close_time
|
|
}
|
|
}
|
|
|
|
impl Default for MarketCandidate {
|
|
fn default() -> Self {
|
|
Self {
|
|
ticker: String::new(),
|
|
title: String::new(),
|
|
category: String::new(),
|
|
current_yes_price: Decimal::ZERO,
|
|
current_no_price: Decimal::ZERO,
|
|
volume_24h: 0,
|
|
total_volume: 0,
|
|
buy_volume_24h: 0,
|
|
sell_volume_24h: 0,
|
|
open_time: Utc::now(),
|
|
close_time: Utc::now(),
|
|
result: None,
|
|
price_history: Vec::new(),
|
|
scores: HashMap::new(),
|
|
final_score: 0.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PricePoint {
|
|
pub timestamp: DateTime<Utc>,
|
|
pub yes_price: Decimal,
|
|
pub volume: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TradingContext {
|
|
pub request_id: String,
|
|
pub timestamp: DateTime<Utc>,
|
|
pub portfolio: Portfolio,
|
|
pub trading_history: Vec<Trade>,
|
|
}
|
|
|
|
impl TradingContext {
|
|
pub fn new(initial_capital: Decimal, start_time: DateTime<Utc>) -> Self {
|
|
Self {
|
|
request_id: uuid::Uuid::new_v4().to_string(),
|
|
timestamp: start_time,
|
|
portfolio: Portfolio::new(initial_capital),
|
|
trading_history: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn request_id(&self) -> &str {
|
|
&self.request_id
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Portfolio {
|
|
pub positions: HashMap<String, Position>,
|
|
pub cash: Decimal,
|
|
pub initial_capital: Decimal,
|
|
}
|
|
|
|
impl Portfolio {
|
|
pub fn new(initial_capital: Decimal) -> Self {
|
|
Self {
|
|
positions: HashMap::new(),
|
|
cash: initial_capital,
|
|
initial_capital,
|
|
}
|
|
}
|
|
|
|
pub fn total_value(&self, market_prices: &HashMap<String, Decimal>) -> Decimal {
|
|
let position_value: Decimal = self
|
|
.positions
|
|
.values()
|
|
.map(|p| {
|
|
let price = market_prices.get(&p.ticker).copied().unwrap_or(p.avg_entry_price);
|
|
let effective_price = match p.side {
|
|
Side::Yes => price,
|
|
Side::No => Decimal::ONE - price,
|
|
};
|
|
effective_price * Decimal::from(p.quantity)
|
|
})
|
|
.sum();
|
|
self.cash + position_value
|
|
}
|
|
|
|
pub fn has_position(&self, ticker: &str) -> bool {
|
|
self.positions.contains_key(ticker)
|
|
}
|
|
|
|
pub fn get_position(&self, ticker: &str) -> Option<&Position> {
|
|
self.positions.get(ticker)
|
|
}
|
|
|
|
pub fn apply_fill(&mut self, fill: &Fill) {
|
|
let cost = fill.price * Decimal::from(fill.quantity);
|
|
|
|
match fill.side {
|
|
Side::Yes | Side::No => {
|
|
self.cash -= cost;
|
|
let position = self.positions.entry(fill.ticker.clone()).or_insert_with(|| {
|
|
Position {
|
|
ticker: fill.ticker.clone(),
|
|
side: fill.side,
|
|
quantity: 0,
|
|
avg_entry_price: Decimal::ZERO,
|
|
entry_time: fill.timestamp,
|
|
}
|
|
});
|
|
|
|
let total_cost =
|
|
position.avg_entry_price * Decimal::from(position.quantity) + cost;
|
|
position.quantity += fill.quantity;
|
|
if position.quantity > 0 {
|
|
position.avg_entry_price = total_cost / Decimal::from(position.quantity);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn resolve_position(&mut self, ticker: &str, result: MarketResult) -> Option<Decimal> {
|
|
let position = self.positions.remove(ticker)?;
|
|
|
|
let payout = match (result, position.side) {
|
|
(MarketResult::Yes, Side::Yes) | (MarketResult::No, Side::No) => {
|
|
Decimal::from(position.quantity)
|
|
}
|
|
(MarketResult::Cancelled, _) => {
|
|
position.avg_entry_price * Decimal::from(position.quantity)
|
|
}
|
|
_ => Decimal::ZERO,
|
|
};
|
|
|
|
self.cash += payout;
|
|
|
|
let cost = position.avg_entry_price * Decimal::from(position.quantity);
|
|
Some(payout - cost)
|
|
}
|
|
|
|
pub fn close_position(&mut self, ticker: &str, exit_price: Decimal) -> Option<Decimal> {
|
|
let position = self.positions.remove(ticker)?;
|
|
|
|
let effective_exit_price = match position.side {
|
|
Side::Yes => exit_price,
|
|
Side::No => Decimal::ONE - exit_price,
|
|
};
|
|
|
|
let exit_value = effective_exit_price * Decimal::from(position.quantity);
|
|
self.cash += exit_value;
|
|
|
|
let cost = position.avg_entry_price * Decimal::from(position.quantity);
|
|
Some(exit_value - cost)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Position {
|
|
pub ticker: String,
|
|
pub side: Side,
|
|
pub quantity: u64,
|
|
pub avg_entry_price: Decimal,
|
|
pub entry_time: DateTime<Utc>,
|
|
}
|
|
|
|
impl Position {
|
|
pub fn cost_basis(&self) -> Decimal {
|
|
self.avg_entry_price * Decimal::from(self.quantity)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Trade {
|
|
pub ticker: String,
|
|
pub side: Side,
|
|
pub quantity: u64,
|
|
pub price: Decimal,
|
|
pub timestamp: DateTime<Utc>,
|
|
pub trade_type: TradeType,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum TradeType {
|
|
Open,
|
|
Close,
|
|
Resolution,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
|
pub enum ExitReason {
|
|
Resolution(MarketResult),
|
|
TakeProfit { pnl_pct: f64 },
|
|
StopLoss { pnl_pct: f64 },
|
|
TimeStop { hours_held: i64 },
|
|
ScoreReversal { new_score: f64 },
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ExitSignal {
|
|
pub ticker: String,
|
|
pub reason: ExitReason,
|
|
pub current_price: Decimal,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ExitConfig {
|
|
pub take_profit_pct: f64,
|
|
pub stop_loss_pct: f64,
|
|
pub max_hold_hours: i64,
|
|
pub score_reversal_threshold: f64,
|
|
}
|
|
|
|
impl Default for ExitConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
take_profit_pct: 0.20,
|
|
stop_loss_pct: 0.15,
|
|
max_hold_hours: 72,
|
|
score_reversal_threshold: -0.3,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ExitConfig {
|
|
pub fn conservative() -> Self {
|
|
Self {
|
|
take_profit_pct: 0.15,
|
|
stop_loss_pct: 0.10,
|
|
max_hold_hours: 48,
|
|
score_reversal_threshold: -0.2,
|
|
}
|
|
}
|
|
|
|
pub fn aggressive() -> Self {
|
|
Self {
|
|
take_profit_pct: 0.30,
|
|
stop_loss_pct: 0.20,
|
|
max_hold_hours: 120,
|
|
score_reversal_threshold: -0.5,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Fill {
|
|
pub ticker: String,
|
|
pub side: Side,
|
|
pub quantity: u64,
|
|
pub price: Decimal,
|
|
pub timestamp: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Signal {
|
|
pub ticker: String,
|
|
pub side: Side,
|
|
pub quantity: u64,
|
|
pub limit_price: Option<Decimal>,
|
|
pub reason: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct MarketData {
|
|
pub ticker: String,
|
|
pub title: String,
|
|
pub category: String,
|
|
pub open_time: DateTime<Utc>,
|
|
pub close_time: DateTime<Utc>,
|
|
pub result: Option<MarketResult>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TradeData {
|
|
pub timestamp: DateTime<Utc>,
|
|
pub ticker: String,
|
|
pub price: Decimal,
|
|
pub volume: u64,
|
|
pub taker_side: Side,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct BacktestConfig {
|
|
pub start_time: DateTime<Utc>,
|
|
pub end_time: DateTime<Utc>,
|
|
pub interval: chrono::Duration,
|
|
pub initial_capital: Decimal,
|
|
pub max_position_size: u64,
|
|
pub max_positions: usize,
|
|
}
|