From 5dc05ba1852bbfea60d67e6907bba4075919bc80 Mon Sep 17 00:00:00 2001 From: Nicholai Date: Sun, 25 Jan 2026 01:20:44 -0700 Subject: [PATCH] backup for jake --- Cargo.toml | 7 + config.toml | 31 + kalshi-paper.db | Bin 0 -> 196608 bytes src/api/client.rs | 168 +++++ src/api/mod.rs | 5 + src/api/types.rs | 119 ++++ src/backtest.rs | 44 +- src/config.rs | 124 ++++ src/engine/circuit_breaker.rs | 240 +++++++ src/engine/mod.rs | 506 +++++++++++++++ src/engine/state.rs | 25 + src/execution.rs | 411 +++++++----- src/main.rs | 198 +++++- src/paper_executor.rs | 143 +++++ src/pipeline/sources.rs | 79 ++- src/store/mod.rs | 4 + src/store/queries.rs | 372 +++++++++++ src/store/schema.rs | 56 ++ src/web/handlers.rs | 514 +++++++++++++++ src/web/mod.rs | 103 +++ static/index.html | 1124 +++++++++++++++++++++++++++++++++ 21 files changed, 4084 insertions(+), 189 deletions(-) create mode 100644 config.toml create mode 100644 kalshi-paper.db create mode 100644 src/api/client.rs create mode 100644 src/api/mod.rs create mode 100644 src/api/types.rs create mode 100644 src/config.rs create mode 100644 src/engine/circuit_breaker.rs create mode 100644 src/engine/mod.rs create mode 100644 src/engine/state.rs create mode 100644 src/paper_executor.rs create mode 100644 src/store/mod.rs create mode 100644 src/store/queries.rs create mode 100644 src/store/schema.rs create mode 100644 src/web/handlers.rs create mode 100644 src/web/mod.rs create mode 100644 static/index.html diff --git a/Cargo.toml b/Cargo.toml index 5bd8ad8..5d94ade 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,13 @@ uuid = { version = "1", features = ["v4"] } rust_decimal = { version = "1", features = ["serde"] } rust_decimal_macros = "1" +reqwest = { version = "0.12", features = ["json"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "chrono"] } +axum = "0.8" +tower-http = { version = "0.6", features = ["cors", "fs"] } +toml = "0.8" +governor = "0.8" + ort = { version = "2.0.0-rc.11", optional = true } ndarray = { version = "0.16", optional = true } diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..2e1a77f --- /dev/null +++ b/config.toml @@ -0,0 +1,31 @@ +mode = "paper" + +[kalshi] +base_url = "https://api.elections.kalshi.com/trade-api/v2" +poll_interval_secs = 60 +rate_limit_per_sec = 2 + +[trading] +initial_capital = 10000.0 +max_positions = 100 +kelly_fraction = 0.25 +max_position_pct = 0.10 +take_profit_pct = 0.50 +stop_loss_pct = 0.99 +max_hold_hours = 48 + +[persistence] +db_path = "kalshi-paper.db" + +[web] +enabled = true +bind_addr = "127.0.0.1:3030" + +[circuit_breaker] +max_drawdown_pct = 0.15 +max_daily_loss_pct = 0.05 +max_positions = 100 +max_single_position_pct = 0.10 +max_consecutive_errors = 5 +max_fills_per_hour = 200 +max_fills_per_day = 1000 diff --git a/kalshi-paper.db b/kalshi-paper.db new file mode 100644 index 0000000000000000000000000000000000000000..415337a859169d7779e9a57c4933c1b95a9be97f GIT binary patch literal 196608 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μ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, + >, +} + +impl KalshiClient { + pub fn new(config: &KalshiConfig) -> Self { + let quota = Quota::per_second( + NonZeroU32::new(config.rate_limit_per_sec).unwrap_or( + NonZeroU32::new(5).unwrap(), + ), + ); + let limiter = Arc::new(RateLimiter::direct(quota)); + + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("failed to build http client"); + + Self { + http, + base_url: config.base_url.clone(), + limiter, + } + } + + async fn rate_limit(&self) { + self.limiter.until_ready().await; + } + + async fn request_with_retry( + &self, + url: &str, + ) -> anyhow::Result { + for attempt in 0..5u32 { + if attempt > 0 { + let delay = std::time::Duration::from_secs( + 3u64.pow(attempt), + ); + debug!( + attempt = attempt, + delay_secs = delay.as_secs(), + "retrying after rate limit" + ); + tokio::time::sleep(delay).await; + } + + self.rate_limit().await; + let resp = self.http.get(url).send().await?; + + if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { + warn!( + attempt = attempt, + "rate limited (429), backing off" + ); + continue; + } + + if !resp.status().is_success() { + let status = resp.status(); + let body = + resp.text().await.unwrap_or_default(); + anyhow::bail!( + "kalshi API error {}: {}", + status, + body + ); + } + + return Ok(resp); + } + + anyhow::bail!("exhausted retries after rate limiting") + } + + pub async fn get_open_markets( + &self, + ) -> anyhow::Result> { + let mut all_markets = Vec::new(); + let mut cursor: Option = None; + let max_pages = 5; // cap at 1000 markets to avoid rate limiting + + for page_num in 0..max_pages { + self.rate_limit().await; + + let mut url = format!( + "{}/markets?status=open&limit=200", + self.base_url + ); + if let Some(ref c) = cursor { + url.push_str(&format!("&cursor={}", c)); + } + + debug!( + url = %url, + page = page_num, + "fetching markets" + ); + + let resp = self + .request_with_retry(&url) + .await?; + + let page: MarketsResponse = resp.json().await?; + let count = page.markets.len(); + all_markets.extend(page.markets); + + if !page.cursor.is_empty() && count > 0 { + cursor = Some(page.cursor); + } else { + break; + } + } + + debug!( + total = all_markets.len(), + "fetched open markets" + ); + Ok(all_markets) + } + + pub async fn get_market_trades( + &self, + ticker: &str, + limit: u32, + ) -> anyhow::Result> { + self.rate_limit().await; + + let url = format!( + "{}/markets/trades?ticker={}&limit={}", + self.base_url, ticker, limit + ); + + debug!(ticker = %ticker, "fetching trades"); + + let resp = match self + .request_with_retry(&url) + .await + { + Ok(r) => r, + Err(e) => { + warn!( + ticker = %ticker, + error = %e, + "failed to fetch trades" + ); + return Ok(Vec::new()); + } + }; + + let data: TradesResponse = resp.json().await?; + Ok(data.trades) + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..f89331d --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,5 @@ +pub mod client; +pub mod types; + +pub use client::KalshiClient; +pub use types::*; diff --git a/src/api/types.rs b/src/api/types.rs new file mode 100644 index 0000000..e6887b3 --- /dev/null +++ b/src/api/types.rs @@ -0,0 +1,119 @@ +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +pub struct MarketsResponse { + pub markets: Vec, + #[serde(default)] + pub cursor: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ApiMarket { + pub ticker: String, + #[serde(default)] + pub title: String, + #[serde(default)] + pub event_ticker: String, + #[serde(default)] + pub status: String, + pub open_time: DateTime, + pub close_time: DateTime, + #[serde(default)] + pub yes_ask: i64, + #[serde(default)] + pub yes_bid: i64, + #[serde(default)] + pub no_ask: i64, + #[serde(default)] + pub no_bid: i64, + #[serde(default)] + pub last_price: i64, + #[serde(default)] + pub volume: i64, + #[serde(default)] + pub volume_24h: i64, + #[serde(default)] + pub result: String, + #[serde(default)] + pub subtitle: String, +} + +impl ApiMarket { + /// returns yes price as a fraction (0.0 - 1.0) + /// prices from API are in cents (0-100) + pub fn mid_yes_price(&self) -> f64 { + let bid = self.yes_bid as f64 / 100.0; + let ask = self.yes_ask as f64 / 100.0; + + if bid > 0.0 && ask > 0.0 { + (bid + ask) / 2.0 + } else if bid > 0.0 { + bid + } else if ask > 0.0 { + ask + } else if self.last_price > 0 { + self.last_price as f64 / 100.0 + } else { + 0.0 + } + } + + pub fn category_from_event(&self) -> String { + let lower = self.event_ticker.to_lowercase(); + if lower.contains("nba") + || lower.contains("nfl") + || lower.contains("sport") + { + "sports".to_string() + } else if lower.contains("btc") + || lower.contains("crypto") + || lower.contains("eth") + { + "crypto".to_string() + } else if lower.contains("weather") + || lower.contains("temp") + { + "weather".to_string() + } else if lower.contains("econ") + || lower.contains("fed") + || lower.contains("cpi") + || lower.contains("gdp") + { + "economics".to_string() + } else if lower.contains("elect") + || lower.contains("polit") + || lower.contains("trump") + || lower.contains("biden") + { + "politics".to_string() + } else { + "other".to_string() + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TradesResponse { + #[serde(default)] + pub trades: Vec, + #[serde(default)] + pub cursor: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ApiTrade { + #[serde(default)] + pub trade_id: String, + #[serde(default)] + pub ticker: String, + pub created_time: DateTime, + #[serde(default)] + pub yes_price: i64, + #[serde(default)] + pub no_price: i64, + #[serde(default)] + pub count: i64, + #[serde(default)] + pub taker_side: String, +} diff --git a/src/backtest.rs b/src/backtest.rs index 43292c3..258a767 100644 --- a/src/backtest.rs +++ b/src/backtest.rs @@ -1,5 +1,5 @@ use crate::data::HistoricalData; -use crate::execution::{Executor, PositionSizingConfig}; +use crate::execution::{BacktestExecutor, OrderExecutor, PositionSizingConfig}; use crate::metrics::{BacktestResult, MetricsCollector}; use crate::pipeline::{ AlreadyPositionedFilter, BollingerMeanReversionScorer, CategoryWeightedScorer, @@ -11,11 +11,13 @@ use crate::types::{ BacktestConfig, ExitConfig, Fill, MarketResult, Portfolio, Side, Trade, TradeType, TradingContext, }; +use crate::web::BacktestProgress; use chrono::{DateTime, Utc}; use rust_decimal::Decimal; use rust_decimal::prelude::ToPrimitive; use std::collections::{HashMap, HashSet}; use std::sync::Arc; +use std::sync::atomic::Ordering; use tracing::info; /// resolves any positions in markets that have closed @@ -86,19 +88,21 @@ pub struct Backtester { config: BacktestConfig, data: Arc, pipeline: TradingPipeline, - executor: Executor, + executor: BacktestExecutor, + progress: Option>, } impl Backtester { pub fn new(config: BacktestConfig, data: Arc) -> Self { let pipeline = Self::build_default_pipeline(data.clone(), &config); - let executor = Executor::new(data.clone(), 10, config.max_position_size); + let executor = BacktestExecutor::new(data.clone(), 10, config.max_position_size); Self { config, data, pipeline, executor, + progress: None, } } @@ -109,7 +113,7 @@ impl Backtester { exit_config: ExitConfig, ) -> Self { let pipeline = Self::build_default_pipeline(data.clone(), &config); - let executor = Executor::new(data.clone(), 10, config.max_position_size) + let executor = BacktestExecutor::new(data.clone(), 10, config.max_position_size) .with_sizing_config(sizing_config) .with_exit_config(exit_config); @@ -118,9 +122,15 @@ impl Backtester { data, pipeline, executor, + progress: None, } } + pub fn with_progress(mut self, progress: Arc) -> Self { + self.progress = Some(progress); + self + } + pub fn with_pipeline(mut self, pipeline: TradingPipeline) -> Self { self.pipeline = pipeline; self @@ -160,13 +170,30 @@ impl Backtester { let mut current_time = self.config.start_time; + let total_steps = (self.config.end_time - self.config.start_time) + .num_seconds() + / self.config.interval.num_seconds().max(1); + + if let Some(ref progress) = self.progress { + progress.total_steps.store( + total_steps as u64, + Ordering::Relaxed, + ); + progress.phase.store( + BacktestProgress::PHASE_RUNNING, + Ordering::Relaxed, + ); + } + info!( start = %self.config.start_time, end = %self.config.end_time, interval_hours = self.config.interval.num_hours(), + total_steps = total_steps, "starting backtest" ); + let mut step: u64 = 0; while current_time < self.config.end_time { context.timestamp = current_time; context.request_id = uuid::Uuid::new_v4().to_string(); @@ -237,7 +264,7 @@ impl Backtester { break; } - if let Some(fill) = self.executor.execute_signal(&signal, &context) { + if let Some(fill) = self.executor.execute_signal(&signal, &context).await { info!( ticker = %fill.ticker, side = ?fill.side, @@ -272,6 +299,13 @@ impl Backtester { let market_prices = self.get_current_prices(current_time); metrics.record(current_time, &context.portfolio, &market_prices); + step += 1; + if let Some(ref progress) = self.progress { + progress + .current_step + .store(step, Ordering::Relaxed); + } + current_time = current_time + self.config.interval; } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..bc5e0cb --- /dev/null +++ b/src/config.rs @@ -0,0 +1,124 @@ +use serde::Deserialize; +use std::path::Path; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RunMode { + Backtest, + Paper, + Live, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AppConfig { + pub mode: RunMode, + pub kalshi: KalshiConfig, + pub trading: TradingConfig, + pub persistence: PersistenceConfig, + pub web: WebConfig, + pub circuit_breaker: CircuitBreakerConfig, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct KalshiConfig { + pub base_url: String, + pub poll_interval_secs: u64, + pub rate_limit_per_sec: u32, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TradingConfig { + pub initial_capital: f64, + pub max_positions: usize, + pub kelly_fraction: f64, + pub max_position_pct: f64, + pub take_profit_pct: Option, + pub stop_loss_pct: Option, + pub max_hold_hours: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PersistenceConfig { + pub db_path: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct WebConfig { + pub enabled: bool, + pub bind_addr: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CircuitBreakerConfig { + pub max_drawdown_pct: f64, + pub max_daily_loss_pct: f64, + pub max_positions: Option, + pub max_single_position_pct: Option, + pub max_consecutive_errors: Option, + pub max_fills_per_hour: Option, + pub max_fills_per_day: Option, +} + +impl AppConfig { + pub fn load(path: &Path) -> anyhow::Result { + let content = std::fs::read_to_string(path)?; + let config: Self = toml::from_str(&content)?; + Ok(config) + } +} + +impl Default for KalshiConfig { + fn default() -> Self { + Self { + base_url: "https://api.elections.kalshi.com/trade-api/v2" + .to_string(), + poll_interval_secs: 300, + rate_limit_per_sec: 5, + } + } +} + +impl Default for TradingConfig { + fn default() -> Self { + Self { + initial_capital: 10000.0, + max_positions: 100, + kelly_fraction: 0.25, + max_position_pct: 0.10, + take_profit_pct: Some(0.50), + stop_loss_pct: Some(0.99), + max_hold_hours: Some(48), + } + } +} + +impl Default for PersistenceConfig { + fn default() -> Self { + Self { + db_path: "kalshi-paper.db".to_string(), + } + } +} + +impl Default for WebConfig { + fn default() -> Self { + Self { + enabled: true, + bind_addr: "127.0.0.1:3030".to_string(), + } + } +} + +impl Default for CircuitBreakerConfig { + fn default() -> Self { + Self { + max_drawdown_pct: 0.15, + max_daily_loss_pct: 0.05, + max_positions: Some(100), + max_single_position_pct: Some(0.10), + max_consecutive_errors: Some(5), + max_fills_per_hour: Some(50), + max_fills_per_day: Some(200), + } + } +} diff --git a/src/engine/circuit_breaker.rs b/src/engine/circuit_breaker.rs new file mode 100644 index 0000000..f9f132e --- /dev/null +++ b/src/engine/circuit_breaker.rs @@ -0,0 +1,240 @@ +use crate::config::CircuitBreakerConfig; +use crate::store::SqliteStore; +use chrono::{Duration, Utc}; +use rust_decimal::Decimal; +use rust_decimal::prelude::ToPrimitive; +use std::sync::Arc; +use tracing::warn; + +#[derive(Debug, Clone, PartialEq)] +pub enum CbStatus { + Ok, + Tripped(String), +} + +pub struct CircuitBreaker { + config: CircuitBreakerConfig, + store: Arc, + consecutive_errors: u32, +} + +impl CircuitBreaker { + pub fn new( + config: CircuitBreakerConfig, + store: Arc, + ) -> Self { + Self { + config, + store, + consecutive_errors: 0, + } + } + + pub fn record_error(&mut self) { + self.consecutive_errors += 1; + } + + pub fn record_success(&mut self) { + self.consecutive_errors = 0; + } + + pub async fn check( + &self, + current_equity: Decimal, + peak_equity: Decimal, + positions_count: usize, + ) -> CbStatus { + if let Some(status) = + self.check_drawdown(current_equity, peak_equity) + { + return status; + } + + if let Some(status) = self.check_daily_loss().await { + return status; + } + + if let Some(status) = + self.check_max_positions(positions_count) + { + return status; + } + + if let Some(status) = self.check_consecutive_errors() { + return status; + } + + if let Some(status) = self.check_fill_rate().await { + return status; + } + + CbStatus::Ok + } + + fn check_drawdown( + &self, + current_equity: Decimal, + peak_equity: Decimal, + ) -> Option { + if peak_equity <= Decimal::ZERO { + return None; + } + + let drawdown = (peak_equity - current_equity) + .to_f64() + .unwrap_or(0.0) + / peak_equity.to_f64().unwrap_or(1.0); + + if drawdown >= self.config.max_drawdown_pct { + let msg = format!( + "drawdown {:.1}% exceeds max {:.1}%", + drawdown * 100.0, + self.config.max_drawdown_pct * 100.0 + ); + warn!(rule = "max_drawdown", %msg); + self.log_event("max_drawdown", &msg, "pause"); + return Some(CbStatus::Tripped(msg)); + } + None + } + + async fn check_daily_loss(&self) -> Option { + let today_start = + Utc::now().date_naive().and_hms_opt(0, 0, 0)?; + let today_utc = chrono::TimeZone::from_utc_datetime( + &Utc, + &today_start, + ); + + let fills = self + .store + .get_recent_fills(1000) + .await + .unwrap_or_default(); + + let daily_pnl: f64 = fills + .iter() + .filter(|f| f.timestamp >= today_utc) + .filter_map(|f| { + f.pnl.as_ref()?.to_f64() + }) + .sum(); + + let peak = self + .store + .get_peak_equity() + .await + .ok() + .flatten() + .unwrap_or(Decimal::new(10000, 0)); + + let peak_f64 = peak.to_f64().unwrap_or(10000.0); + if peak_f64 <= 0.0 { + return None; + } + + let daily_loss_pct = (-daily_pnl) / peak_f64; + if daily_loss_pct >= self.config.max_daily_loss_pct { + let msg = format!( + "daily loss {:.1}% exceeds max {:.1}%", + daily_loss_pct * 100.0, + self.config.max_daily_loss_pct * 100.0 + ); + warn!(rule = "max_daily_loss", %msg); + self.log_event("max_daily_loss", &msg, "pause"); + return Some(CbStatus::Tripped(msg)); + } + None + } + + fn check_max_positions( + &self, + count: usize, + ) -> Option { + let max = self.config.max_positions.unwrap_or(100); + if count >= max { + let msg = format!( + "positions {} at max {}", + count, max + ); + warn!(rule = "max_positions", %msg); + return Some(CbStatus::Tripped(msg)); + } + None + } + + fn check_consecutive_errors(&self) -> Option { + let max = self.config.max_consecutive_errors.unwrap_or(5); + if self.consecutive_errors >= max { + let msg = format!( + "{} consecutive errors (max {})", + self.consecutive_errors, max + ); + warn!(rule = "consecutive_errors", %msg); + self.log_event( + "consecutive_errors", + &msg, + "pause", + ); + return Some(CbStatus::Tripped(msg)); + } + None + } + + async fn check_fill_rate(&self) -> Option { + let max_hourly = + self.config.max_fills_per_hour.unwrap_or(50); + let max_daily = + self.config.max_fills_per_day.unwrap_or(200); + + let one_hour_ago = Utc::now() - Duration::hours(1); + let hourly_fills = self + .store + .get_fills_since(one_hour_ago) + .await + .unwrap_or(0); + + if hourly_fills >= max_hourly { + let msg = format!( + "{} fills/hour exceeds max {}", + hourly_fills, max_hourly + ); + warn!(rule = "fill_rate_hourly", %msg); + self.log_event("fill_rate_hourly", &msg, "pause"); + return Some(CbStatus::Tripped(msg)); + } + + let today_start = Utc::now() - Duration::hours(24); + let daily_fills = self + .store + .get_fills_since(today_start) + .await + .unwrap_or(0); + + if daily_fills >= max_daily { + let msg = format!( + "{} fills/day exceeds max {}", + daily_fills, max_daily + ); + warn!(rule = "fill_rate_daily", %msg); + self.log_event("fill_rate_daily", &msg, "pause"); + return Some(CbStatus::Tripped(msg)); + } + + None + } + + fn log_event(&self, rule: &str, details: &str, action: &str) { + let store = self.store.clone(); + let rule = rule.to_string(); + let details = details.to_string(); + let action = action.to_string(); + tokio::spawn(async move { + let _ = store + .record_circuit_breaker_event( + &rule, &details, &action, + ) + .await; + }); + } +} diff --git a/src/engine/mod.rs b/src/engine/mod.rs new file mode 100644 index 0000000..38e52c2 --- /dev/null +++ b/src/engine/mod.rs @@ -0,0 +1,506 @@ +pub mod circuit_breaker; +pub mod state; + +pub use circuit_breaker::{CbStatus, CircuitBreaker}; +pub use state::EngineState; + +use crate::api::KalshiClient; +use crate::config::AppConfig; +use crate::execution::OrderExecutor; +use crate::paper_executor::PaperExecutor; +use crate::pipeline::{ + AlreadyPositionedFilter, BollingerMeanReversionScorer, + CategoryWeightedScorer, Filter, LiveKalshiSource, + LiquidityFilter, MeanReversionScorer, MomentumScorer, + MultiTimeframeMomentumScorer, OrderFlowScorer, Scorer, + Selector, Source, TimeDecayScorer, TimeToCloseFilter, + TopKSelector, TradingPipeline, VolumeScorer, +}; +use crate::store::SqliteStore; +use crate::types::{Portfolio, Trade, TradeType, TradingContext}; +use chrono::Utc; +use rust_decimal::Decimal; +use rust_decimal::prelude::ToPrimitive; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::{broadcast, Mutex, RwLock}; +use tracing::{error, info, warn}; + +pub struct EngineStatus { + pub state: EngineState, + pub uptime_secs: u64, + pub last_tick: Option>, + pub ticks_completed: u64, +} + +pub struct PaperTradingEngine { + config: AppConfig, + store: Arc, + executor: Arc, + pipeline: Mutex, + circuit_breaker: Mutex, + state: RwLock, + context: RwLock, + shutdown_tx: broadcast::Sender<()>, + start_time: Instant, + ticks: RwLock, + last_tick: RwLock>>, +} + +impl PaperTradingEngine { + pub async fn new( + config: AppConfig, + store: Arc, + executor: Arc, + client: Arc, + ) -> anyhow::Result { + let (shutdown_tx, _) = broadcast::channel(1); + + let pipeline = + Self::build_pipeline(client, &config); + + let circuit_breaker = CircuitBreaker::new( + config.circuit_breaker.clone(), + store.clone(), + ); + + let initial_capital = Decimal::try_from( + config.trading.initial_capital, + ) + .unwrap_or(Decimal::new(10000, 0)); + + let portfolio = store + .load_portfolio() + .await? + .unwrap_or_else(|| Portfolio::new(initial_capital)); + + let mut ctx = TradingContext::new( + portfolio.initial_capital, + Utc::now(), + ); + ctx.portfolio = portfolio; + + Ok(Self { + config, + store, + executor, + pipeline: Mutex::new(pipeline), + circuit_breaker: Mutex::new(circuit_breaker), + state: RwLock::new(EngineState::Starting), + context: RwLock::new(ctx), + shutdown_tx, + start_time: Instant::now(), + ticks: RwLock::new(0), + last_tick: RwLock::new(None), + }) + } + + fn build_pipeline( + client: Arc, + config: &AppConfig, + ) -> TradingPipeline { + let sources: Vec> = + vec![Box::new(LiveKalshiSource::new(client))]; + + let max_pos_size = + (config.trading.initial_capital + * config.trading.max_position_pct) + as u64; + + let filters: Vec> = vec![ + Box::new(TimeToCloseFilter::new(2, Some(720))), + Box::new(AlreadyPositionedFilter::new( + max_pos_size.max(100), + )), + ]; + + let scorers: Vec> = vec![ + Box::new(MomentumScorer::new(6)), + Box::new( + MultiTimeframeMomentumScorer::default_windows(), + ), + Box::new(MeanReversionScorer::new(24)), + Box::new( + BollingerMeanReversionScorer::default_config(), + ), + Box::new(VolumeScorer::new(6)), + Box::new(OrderFlowScorer::new()), + Box::new(TimeDecayScorer::new()), + Box::new(CategoryWeightedScorer::with_defaults()), + ]; + + let max_positions = config.trading.max_positions; + let selector: Box = + Box::new(TopKSelector::new(max_positions)); + + TradingPipeline::new( + sources, + filters, + scorers, + selector, + max_positions, + ) + } + + pub fn shutdown_handle(&self) -> broadcast::Sender<()> { + self.shutdown_tx.clone() + } + + pub async fn get_status(&self) -> EngineStatus { + EngineStatus { + state: self.state.read().await.clone(), + uptime_secs: self.start_time.elapsed().as_secs(), + last_tick: *self.last_tick.read().await, + ticks_completed: *self.ticks.read().await, + } + } + + pub async fn get_context(&self) -> TradingContext { + self.context.read().await.clone() + } + + pub async fn pause(&self, reason: String) { + let mut state = self.state.write().await; + *state = EngineState::Paused(reason); + } + + pub async fn resume(&self) { + let mut state = self.state.write().await; + if matches!(*state, EngineState::Paused(_)) { + *state = EngineState::Running; + } + } + + pub async fn run(&self) -> anyhow::Result<()> { + let mut shutdown_rx = self.shutdown_tx.subscribe(); + let poll_interval = std::time::Duration::from_secs( + self.config.kalshi.poll_interval_secs, + ); + + { + let mut state = self.state.write().await; + *state = EngineState::Recovering; + } + + info!("recovering state from SQLite"); + if let Ok(Some(portfolio)) = + self.store.load_portfolio().await + { + let mut ctx = self.context.write().await; + ctx.portfolio = portfolio; + info!( + positions = ctx.portfolio.positions.len(), + cash = %ctx.portfolio.cash, + "state recovered" + ); + } + + { + let mut state = self.state.write().await; + *state = EngineState::Running; + } + + info!( + interval_secs = self.config.kalshi.poll_interval_secs, + "engine running" + ); + + // run first tick immediately + self.tick().await; + + loop { + tokio::select! { + _ = shutdown_rx.recv() => { + info!("shutdown signal received"); + let mut state = self.state.write().await; + *state = EngineState::ShuttingDown; + break; + } + _ = tokio::time::sleep(poll_interval) => { + let current_state = + self.state.read().await.clone(); + match current_state { + EngineState::Running => { + self.tick().await; + } + EngineState::Paused(ref reason) => { + info!( + reason = %reason, + "engine paused, skipping tick" + ); + } + _ => {} + } + } + } + } + + info!("persisting final state"); + let ctx = self.context.read().await; + if let Err(e) = + self.store.save_portfolio(&ctx.portfolio).await + { + error!(error = %e, "failed to persist final state"); + } + + info!("engine shutdown complete"); + Ok(()) + } + + async fn tick(&self) { + let tick_start = Instant::now(); + let now = Utc::now(); + + let context_snapshot = { + let mut ctx = self.context.write().await; + ctx.timestamp = now; + ctx.request_id = + uuid::Uuid::new_v4().to_string(); + ctx.clone() + }; + + let result = { + let pipeline = self.pipeline.lock().await; + pipeline.execute(context_snapshot.clone()).await + }; + + let candidates_fetched = + result.retrieved_candidates.len(); + let candidates_filtered = + result.filtered_candidates.len(); + let candidates_selected = + result.selected_candidates.len(); + + let candidate_scores: HashMap = result + .selected_candidates + .iter() + .map(|c| (c.ticker.clone(), c.final_score)) + .collect(); + + let current_prices: HashMap = result + .selected_candidates + .iter() + .map(|c| (c.ticker.clone(), c.current_yes_price)) + .collect(); + self.executor.update_prices(current_prices).await; + + let exit_signals = self.executor.generate_exit_signals( + &context_snapshot, + &candidate_scores, + ); + + let mut ctx = self.context.write().await; + let mut fills_executed = 0u32; + + for exit in &exit_signals { + if let Some(position) = ctx + .portfolio + .positions + .get(&exit.ticker) + .cloned() + { + let pnl = ctx.portfolio.close_position( + &exit.ticker, + exit.current_price, + ); + + info!( + ticker = %exit.ticker, + reason = ?exit.reason, + pnl = ?pnl, + "paper exit" + ); + + let exit_fill = crate::types::Fill { + ticker: exit.ticker.clone(), + side: position.side, + quantity: position.quantity, + price: exit.current_price, + timestamp: now, + }; + + let reason_str = + format!("{:?}", exit.reason); + let _ = self + .store + .record_fill( + &exit_fill, + pnl, + Some(&reason_str), + ) + .await; + + ctx.trading_history.push(Trade { + ticker: exit.ticker.clone(), + side: position.side, + quantity: position.quantity, + price: exit.current_price, + timestamp: now, + trade_type: TradeType::Close, + }); + + fills_executed += 1; + } + } + + let signals = self.executor.generate_signals( + &result.selected_candidates, + &*ctx, + ); + let signals_generated = signals.len(); + + let peak_equity = self + .store + .get_peak_equity() + .await + .ok() + .flatten() + .unwrap_or(ctx.portfolio.initial_capital); + + let positions_value: Decimal = ctx + .portfolio + .positions + .values() + .map(|p| { + p.avg_entry_price * Decimal::from(p.quantity) + }) + .sum(); + let current_equity = ctx.portfolio.cash + positions_value; + + let cb_status = { + let cb = self.circuit_breaker.lock().await; + cb.check( + current_equity, + peak_equity, + ctx.portfolio.positions.len(), + ) + .await + }; + + if let CbStatus::Tripped(reason) = cb_status { + warn!( + reason = %reason, + "circuit breaker tripped, pausing" + ); + drop(ctx); + let mut state = self.state.write().await; + *state = EngineState::Paused(reason); + return; + } + + { + let mut cb = self.circuit_breaker.lock().await; + cb.record_success(); + } + + for signal in signals { + if ctx.portfolio.positions.len() + >= self.config.trading.max_positions + { + break; + } + + let context_for_exec = (*ctx).clone(); + if let Some(fill) = self + .executor + .execute_signal(&signal, &context_for_exec) + .await + { + info!( + ticker = %fill.ticker, + side = ?fill.side, + qty = fill.quantity, + price = %fill.price, + "paper fill" + ); + + ctx.portfolio.apply_fill(&fill); + ctx.trading_history.push(Trade { + ticker: fill.ticker.clone(), + side: fill.side, + quantity: fill.quantity, + price: fill.price, + timestamp: fill.timestamp, + trade_type: TradeType::Open, + }); + + fills_executed += 1; + } + } + + if let Err(e) = + self.store.save_portfolio(&ctx.portfolio).await + { + error!(error = %e, "failed to persist portfolio"); + let mut cb = self.circuit_breaker.lock().await; + cb.record_error(); + } + + let positions_value: Decimal = ctx + .portfolio + .positions + .values() + .map(|p| { + p.avg_entry_price * Decimal::from(p.quantity) + }) + .sum(); + let equity = ctx.portfolio.cash + positions_value; + let drawdown = if peak_equity > Decimal::ZERO { + ((peak_equity - equity) + .to_f64() + .unwrap_or(0.0)) + / peak_equity.to_f64().unwrap_or(1.0) + } else { + 0.0 + }; + + let _ = self + .store + .snapshot_equity( + now, + equity, + ctx.portfolio.cash, + positions_value, + drawdown.max(0.0), + ) + .await; + + let duration_ms = + tick_start.elapsed().as_millis() as u64; + + let _ = self + .store + .record_pipeline_run( + now, + duration_ms, + candidates_fetched, + candidates_filtered, + candidates_selected, + signals_generated, + fills_executed as usize, + None, + ) + .await; + + { + let mut ticks = self.ticks.write().await; + *ticks += 1; + } + { + let mut last = self.last_tick.write().await; + *last = Some(now); + } + + info!( + fetched = candidates_fetched, + filtered = candidates_filtered, + selected = candidates_selected, + signals = signals_generated, + fills = fills_executed, + equity = %equity, + duration_ms = duration_ms, + "tick complete" + ); + } +} diff --git a/src/engine/state.rs b/src/engine/state.rs new file mode 100644 index 0000000..1c8a6ac --- /dev/null +++ b/src/engine/state.rs @@ -0,0 +1,25 @@ +use serde::Serialize; + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum EngineState { + Starting, + Recovering, + Running, + Paused(String), + ShuttingDown, +} + +impl std::fmt::Display for EngineState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Starting => write!(f, "starting"), + Self::Recovering => write!(f, "recovering"), + Self::Running => write!(f, "running"), + Self::Paused(reason) => { + write!(f, "paused: {}", reason) + } + Self::ShuttingDown => write!(f, "shutting_down"), + } + } +} diff --git a/src/execution.rs b/src/execution.rs index 5372665..3abb36e 100644 --- a/src/execution.rs +++ b/src/execution.rs @@ -1,7 +1,12 @@ use crate::data::HistoricalData; -use crate::types::{ExitConfig, ExitReason, ExitSignal, Fill, MarketCandidate, Side, Signal, TradingContext}; +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)] @@ -14,9 +19,6 @@ pub struct PositionSizingConfig { 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, @@ -46,13 +48,33 @@ impl PositionSizingConfig { } } +#[async_trait] +pub trait OrderExecutor: Send + Sync { + async fn execute_signal( + &self, + signal: &Signal, + context: &TradingContext, + ) -> Option; + + fn generate_signals( + &self, + candidates: &[MarketCandidate], + context: &TradingContext, + ) -> Vec; + + fn generate_exit_signals( + &self, + context: &TradingContext, + candidate_scores: &HashMap, + ) -> Vec; +} + /// 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 { +pub fn edge_to_win_probability(edge: f64) -> f64 { (1.0 + edge.tanh()) / 2.0 } -fn kelly_size( +pub fn kelly_size( edge: f64, price: f64, bankroll: f64, @@ -71,13 +93,165 @@ fn kelly_size( 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 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) + shares + .max(config.min_position_size) + .min(config.max_position_size) } -pub struct Executor { +pub fn candidate_to_signal( + candidate: &MarketCandidate, + context: &TradingContext, + sizing_config: &PositionSizingConfig, + max_position_size: u64, +) -> Option { + 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, + exit_config: &ExitConfig, + price_lookup: &dyn Fn(&str) -> Option, +) -> Vec { + 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, slippage_bps: u32, max_position_size: u64, @@ -85,8 +259,12 @@ pub struct Executor { exit_config: ExitConfig, } -impl Executor { - pub fn new(data: Arc, slippage_bps: u32, max_position_size: u64) -> Self { +impl BacktestExecutor { + pub fn new( + data: Arc, + slippage_bps: u32, + max_position_size: u64, + ) -> Self { Self { data, slippage_bps, @@ -96,7 +274,10 @@ impl Executor { } } - pub fn with_sizing_config(mut self, config: PositionSizingConfig) -> Self { + pub fn with_sizing_config( + mut self, + config: PositionSizingConfig, + ) -> Self { self.sizing_config = config; self } @@ -105,168 +286,33 @@ impl Executor { self.exit_config = config; self } +} - pub fn generate_exit_signals( - &self, - context: &TradingContext, - candidate_scores: &std::collections::HashMap, - ) -> Vec { - 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 { - candidates - .iter() - .filter_map(|c| self.candidate_to_signal(c, context)) - .collect() - } - - fn candidate_to_signal( - &self, - candidate: &MarketCandidate, - context: &TradingContext, - ) -> Option { - 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( +#[async_trait] +impl OrderExecutor for BacktestExecutor { + async fn execute_signal( &self, signal: &Signal, context: &TradingContext, ) -> Option { - let market_price = self.data.get_current_price(&signal.ticker, context.timestamp)?; + 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); + 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)) { + if fill_price + > limit * (Decimal::ONE + slippage * Decimal::from(2)) + { return None; } } @@ -297,6 +343,39 @@ impl Executor { timestamp: context.timestamp, }) } + + fn generate_signals( + &self, + candidates: &[MarketCandidate], + context: &TradingContext, + ) -> Vec { + 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, + ) -> Vec { + 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( @@ -309,7 +388,8 @@ pub fn simple_signal_generator( .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 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 { @@ -321,7 +401,10 @@ pub fn simple_signal_generator( side, quantity: position_size, limit_price: Some(price), - reason: format!("simple: score={:.3}", c.final_score), + reason: format!( + "simple: score={:.3}", + c.final_score + ), } }) .collect() diff --git a/src/main.rs b/src/main.rs index f86997a..0e59423 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,22 @@ +mod api; mod backtest; +mod config; mod data; +mod engine; mod execution; mod metrics; +mod paper_executor; mod pipeline; +mod store; mod types; +mod web; use anyhow::{Context, Result}; use backtest::{Backtester, RandomBaseline}; use chrono::{DateTime, NaiveDate, TimeZone, Utc}; use clap::{Parser, Subcommand}; use data::HistoricalData; -use execution::{Executor, PositionSizingConfig}; +use execution::PositionSizingConfig; use rust_decimal::Decimal; use std::path::PathBuf; use std::sync::Arc; @@ -57,7 +63,7 @@ enum Commands { #[arg(long)] compare_random: bool, - /// kelly fraction for position sizing (0.40 = 40% of kelly optimal) + /// kelly fraction for position sizing #[arg(long, default_value = "0.40")] kelly_fraction: f64, @@ -65,11 +71,11 @@ enum Commands { #[arg(long, default_value = "0.30")] max_position_pct: f64, - /// take profit threshold (0.50 = +50%) + /// take profit threshold #[arg(long, default_value = "0.50")] take_profit: f64, - /// stop loss threshold (0.99 = disabled for prediction markets) + /// stop loss threshold #[arg(long, default_value = "0.99")] stop_loss: f64, @@ -78,19 +84,27 @@ enum Commands { max_hold_hours: i64, }, + Paper { + /// path to config.toml + #[arg(short, long, default_value = "config.toml")] + config: PathBuf, + }, + Summary { #[arg(short, long)] results_file: PathBuf, }, } -fn parse_date(s: &str) -> Result> { +pub fn parse_date(s: &str) -> Result> { if let Ok(dt) = DateTime::parse_from_rfc3339(s) { return Ok(dt.with_timezone(&Utc)); } if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") { - return Ok(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap())); + return Ok(Utc.from_utc_datetime( + &date.and_hms_opt(0, 0, 0).unwrap(), + )); } Err(anyhow::anyhow!("could not parse date: {}", s)) @@ -101,7 +115,9 @@ async fn main() -> Result<()> { tracing_subscriber::registry() .with( tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "kalshi_backtest=info".into()), + .unwrap_or_else(|_| { + "kalshi_backtest=info".into() + }), ) .with(tracing_subscriber::fmt::layer()) .init(); @@ -125,8 +141,10 @@ async fn main() -> Result<()> { stop_loss, max_hold_hours, } => { - let start_time = parse_date(&start).context("parsing start date")?; - let end_time = parse_date(&end).context("parsing end date")?; + let start_time = + parse_date(&start).context("parsing start date")?; + let end_time = + parse_date(&end).context("parsing end date")?; info!( data_dir = %data_dir.display(), @@ -137,7 +155,8 @@ async fn main() -> Result<()> { ); let data = Arc::new( - HistoricalData::load(&data_dir).context("loading historical data")?, + HistoricalData::load(&data_dir) + .context("loading historical data")?, ); info!( @@ -150,7 +169,8 @@ async fn main() -> Result<()> { start_time, end_time, interval: chrono::Duration::hours(interval_hours), - initial_capital: Decimal::try_from(capital).unwrap(), + initial_capital: Decimal::try_from(capital) + .unwrap(), max_position_size: max_position, max_positions, }; @@ -169,16 +189,25 @@ async fn main() -> Result<()> { score_reversal_threshold: -0.3, }; - let backtester = Backtester::with_configs(config.clone(), data.clone(), sizing_config, exit_config); + let backtester = Backtester::with_configs( + config.clone(), + data.clone(), + sizing_config, + exit_config, + ); let result = backtester.run().await; println!("{}", result.summary()); std::fs::create_dir_all(&output_dir)?; - let result_path = output_dir.join("backtest_result.json"); + let result_path = + output_dir.join("backtest_result.json"); let json = serde_json::to_string_pretty(&result)?; std::fs::write(&result_path, json)?; - info!(path = %result_path.display(), "results saved"); + info!( + path = %result_path.display(), + "results saved" + ); if compare_random { println!("\n--- random baseline ---\n"); @@ -186,18 +215,23 @@ async fn main() -> Result<()> { let baseline_result = baseline.run().await; println!("{}", baseline_result.summary()); - let baseline_path = output_dir.join("baseline_result.json"); - let json = serde_json::to_string_pretty(&baseline_result)?; + let baseline_path = + output_dir.join("baseline_result.json"); + let json = serde_json::to_string_pretty( + &baseline_result, + )?; std::fs::write(&baseline_path, json)?; println!("\n--- comparison ---\n"); println!( "strategy return: {:.2}% vs baseline: {:.2}%", - result.total_return_pct, baseline_result.total_return_pct + result.total_return_pct, + baseline_result.total_return_pct ); println!( "strategy sharpe: {:.3} vs baseline: {:.3}", - result.sharpe_ratio, baseline_result.sharpe_ratio + result.sharpe_ratio, + baseline_result.sharpe_ratio ); println!( "strategy win rate: {:.1}% vs baseline: {:.1}%", @@ -208,11 +242,16 @@ async fn main() -> Result<()> { Ok(()) } + Commands::Paper { config: config_path } => { + run_paper(config_path).await + } + Commands::Summary { results_file } => { let content = std::fs::read_to_string(&results_file) .context("reading results file")?; let result: metrics::BacktestResult = - serde_json::from_str(&content).context("parsing results")?; + serde_json::from_str(&content) + .context("parsing results")?; println!("{}", result.summary()); @@ -220,3 +259,124 @@ async fn main() -> Result<()> { } } } + +async fn run_paper(config_path: PathBuf) -> Result<()> { + let app_config = config::AppConfig::load(&config_path) + .context("loading config")?; + + info!( + mode = ?app_config.mode, + poll_secs = app_config.kalshi.poll_interval_secs, + capital = app_config.trading.initial_capital, + "starting paper trading" + ); + + let store = Arc::new( + store::SqliteStore::new(&app_config.persistence.db_path) + .await + .context("initializing SQLite store")?, + ); + + let client = Arc::new(api::KalshiClient::new( + &app_config.kalshi, + )); + + let sizing_config = PositionSizingConfig { + kelly_fraction: app_config.trading.kelly_fraction, + max_position_pct: app_config.trading.max_position_pct, + min_position_size: 10, + max_position_size: 1000, + }; + + let exit_config = ExitConfig { + take_profit_pct: app_config + .trading + .take_profit_pct + .unwrap_or(0.50), + stop_loss_pct: app_config + .trading + .stop_loss_pct + .unwrap_or(0.99), + max_hold_hours: app_config + .trading + .max_hold_hours + .unwrap_or(48), + score_reversal_threshold: -0.3, + }; + + let executor = Arc::new(paper_executor::PaperExecutor::new( + 1000, + sizing_config, + exit_config, + store.clone(), + )); + + let engine = engine::PaperTradingEngine::new( + app_config.clone(), + store.clone(), + executor, + client, + ) + .await + .context("initializing engine")?; + + let shutdown_tx = engine.shutdown_handle(); + + let engine = Arc::new(engine); + + if app_config.web.enabled { + let web_state = Arc::new(web::AppState { + engine: engine.clone(), + store: store.clone(), + shutdown_tx: shutdown_tx.clone(), + backtest: Arc::new(tokio::sync::Mutex::new( + web::BacktestState { + status: web::BacktestRunStatus::Idle, + progress: None, + result: None, + error: None, + }, + )), + data_dir: PathBuf::from("data"), + }); + + let router = web::build_router(web_state); + let bind_addr = app_config.web.bind_addr.clone(); + + info!(addr = %bind_addr, "starting web dashboard"); + + match tokio::net::TcpListener::bind(&bind_addr).await { + Ok(listener) => { + tokio::spawn(async move { + if let Err(e) = + axum::serve(listener, router).await + { + tracing::error!( + error = %e, + "web server error" + ); + } + }); + } + Err(e) => { + tracing::warn!( + addr = %bind_addr, + error = %e, + "web dashboard disabled (port in use)" + ); + } + } + } + + let shutdown_tx_clone = shutdown_tx.clone(); + tokio::spawn(async move { + tokio::signal::ctrl_c().await.ok(); + info!("ctrl+c received, shutting down"); + let _ = shutdown_tx_clone.send(()); + }); + + engine.run().await?; + + info!("paper trading session ended"); + Ok(()) +} diff --git a/src/paper_executor.rs b/src/paper_executor.rs new file mode 100644 index 0000000..daebd71 --- /dev/null +++ b/src/paper_executor.rs @@ -0,0 +1,143 @@ +use crate::execution::{ + candidate_to_signal, compute_exit_signals, OrderExecutor, + PositionSizingConfig, +}; +use crate::store::SqliteStore; +use crate::types::{ + ExitConfig, ExitSignal, Fill, MarketCandidate, Signal, Side, + TradingContext, +}; +use async_trait::async_trait; +use rust_decimal::Decimal; +use rust_decimal::prelude::ToPrimitive; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +pub struct PaperExecutor { + max_position_size: u64, + sizing_config: PositionSizingConfig, + exit_config: ExitConfig, + store: Arc, + current_prices: Arc>>, +} + +impl PaperExecutor { + pub fn new( + max_position_size: u64, + sizing_config: PositionSizingConfig, + exit_config: ExitConfig, + store: Arc, + ) -> Self { + Self { + max_position_size, + sizing_config, + exit_config, + store, + current_prices: Arc::new(RwLock::new(HashMap::new())), + } + } + + pub async fn update_prices( + &self, + prices: HashMap, + ) { + let mut current = self.current_prices.write().await; + *current = prices; + } +} + +#[async_trait] +impl OrderExecutor for PaperExecutor { + async fn execute_signal( + &self, + signal: &Signal, + context: &TradingContext, + ) -> Option { + let prices = self.current_prices.read().await; + let market_price = prices.get(&signal.ticker).copied()?; + + let effective_price = match signal.side { + Side::Yes => market_price, + Side::No => Decimal::ONE - market_price, + }; + + if let Some(limit) = signal.limit_price { + let tolerance = Decimal::new(5, 2); + if effective_price > limit * (Decimal::ONE + tolerance) { + return None; + } + } + + let cost = effective_price * Decimal::from(signal.quantity); + let quantity = if cost > context.portfolio.cash { + let affordable = (context.portfolio.cash + / effective_price) + .to_u64() + .unwrap_or(0); + if affordable == 0 { + return None; + } + affordable + } else { + signal.quantity + }; + + let fill = Fill { + ticker: signal.ticker.clone(), + side: signal.side, + quantity, + price: effective_price, + timestamp: context.timestamp, + }; + + if let Err(e) = + self.store.record_fill(&fill, None, None).await + { + tracing::error!( + error = %e, + "failed to persist fill" + ); + } + + Some(fill) + } + + fn generate_signals( + &self, + candidates: &[MarketCandidate], + context: &TradingContext, + ) -> Vec { + 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, + ) -> Vec { + let prices = self.current_prices.try_read(); + match prices { + Ok(prices) => { + let prices_ref = prices.clone(); + compute_exit_signals( + context, + candidate_scores, + &self.exit_config, + &|ticker| prices_ref.get(ticker).copied(), + ) + } + Err(_) => Vec::new(), + } + } +} diff --git a/src/pipeline/sources.rs b/src/pipeline/sources.rs index ea34056..5beb771 100644 --- a/src/pipeline/sources.rs +++ b/src/pipeline/sources.rs @@ -1,7 +1,9 @@ +use crate::api::KalshiClient; use crate::data::HistoricalData; use crate::pipeline::Source; -use crate::types::{MarketCandidate, TradingContext}; +use crate::types::{MarketCandidate, PricePoint, TradingContext}; use async_trait::async_trait; +use chrono::Utc; use rust_decimal::Decimal; use std::collections::HashMap; use std::sync::Arc; @@ -67,3 +69,78 @@ impl Source for HistoricalMarketSource { Ok(candidates) } } + +pub struct LiveKalshiSource { + client: Arc, +} + +impl LiveKalshiSource { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[async_trait] +impl Source for LiveKalshiSource { + fn name(&self) -> &'static str { + "LiveKalshiSource" + } + + async fn get_candidates( + &self, + _context: &TradingContext, + ) -> Result, String> { + let markets = self + .client + .get_open_markets() + .await + .map_err(|e| format!("API error: {}", e))?; + + let now = Utc::now(); + + let mut candidates = Vec::with_capacity(markets.len()); + + for market in markets { + let yes_price = market.mid_yes_price(); + if yes_price <= 0.0 || yes_price >= 1.0 { + continue; + } + + let yes_dec = Decimal::try_from(yes_price) + .unwrap_or(Decimal::new(50, 2)); + let no_dec = Decimal::ONE - yes_dec; + + let volume_24h = market.volume_24h.max(0) as u64; + let total_volume = market.volume.max(0) as u64; + + // skip per-market trade fetching to avoid N+1 + // the market list already provides volume data + // for filtering; scorers will work with what's available + let price_history = Vec::new(); + let buy_vol = 0u64; + let sell_vol = 0u64; + + let category = market.category_from_event(); + + candidates.push(MarketCandidate { + ticker: market.ticker, + title: market.title, + category, + current_yes_price: yes_dec, + current_no_price: no_dec, + volume_24h, + total_volume, + buy_volume_24h: buy_vol, + sell_volume_24h: sell_vol, + open_time: market.open_time, + close_time: market.close_time, + result: None, + price_history, + scores: HashMap::new(), + final_score: 0.0, + }); + } + + Ok(candidates) + } +} diff --git a/src/store/mod.rs b/src/store/mod.rs new file mode 100644 index 0000000..5c6b0b9 --- /dev/null +++ b/src/store/mod.rs @@ -0,0 +1,4 @@ +mod queries; +mod schema; + +pub use queries::*; diff --git a/src/store/queries.rs b/src/store/queries.rs new file mode 100644 index 0000000..ca7d81d --- /dev/null +++ b/src/store/queries.rs @@ -0,0 +1,372 @@ +use crate::types::{Fill, Portfolio, Position, Side}; +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use sqlx::SqlitePool; +use std::collections::HashMap; +use std::str::FromStr; + +use super::schema::MIGRATIONS; + +pub struct SqliteStore { + pool: SqlitePool, +} + +impl SqliteStore { + pub async fn new(db_path: &str) -> anyhow::Result { + let url = format!("sqlite:{}?mode=rwc", db_path); + let pool = SqlitePool::connect(&url).await?; + let store = Self { pool }; + store.run_migrations().await?; + Ok(store) + } + + async fn run_migrations(&self) -> anyhow::Result<()> { + sqlx::raw_sql(MIGRATIONS).execute(&self.pool).await?; + Ok(()) + } + + pub async fn load_portfolio( + &self, + ) -> anyhow::Result> { + let row = sqlx::query_as::<_, (String, String)>( + "SELECT cash, initial_capital FROM portfolio_state \ + WHERE id = 1", + ) + .fetch_optional(&self.pool) + .await?; + + let Some((cash_str, capital_str)) = row else { + return Ok(None); + }; + + let cash = Decimal::from_str(&cash_str)?; + let initial_capital = Decimal::from_str(&capital_str)?; + + let position_rows = sqlx::query_as::< + _, + (String, String, i64, String, String), + >( + "SELECT ticker, side, quantity, avg_entry_price, \ + entry_time FROM positions", + ) + .fetch_all(&self.pool) + .await?; + + let mut positions = HashMap::new(); + for (ticker, side_str, qty, price_str, time_str) in + position_rows + { + let side = match side_str.as_str() { + "Yes" => Side::Yes, + _ => Side::No, + }; + let price = Decimal::from_str(&price_str)?; + let entry_time: DateTime = + time_str.parse::>()?; + + positions.insert( + ticker.clone(), + Position { + ticker, + side, + quantity: qty as u64, + avg_entry_price: price, + entry_time, + }, + ); + } + + Ok(Some(Portfolio { + positions, + cash, + initial_capital, + })) + } + + pub async fn save_portfolio( + &self, + portfolio: &Portfolio, + ) -> anyhow::Result<()> { + let cash = portfolio.cash.to_string(); + let capital = portfolio.initial_capital.to_string(); + + sqlx::query( + "INSERT INTO portfolio_state (id, cash, initial_capital, \ + updated_at) VALUES (1, ?1, ?2, datetime('now')) \ + ON CONFLICT(id) DO UPDATE SET \ + cash = ?1, initial_capital = ?2, \ + updated_at = datetime('now')", + ) + .bind(&cash) + .bind(&capital) + .execute(&self.pool) + .await?; + + sqlx::query("DELETE FROM positions") + .execute(&self.pool) + .await?; + + for pos in portfolio.positions.values() { + let side = match pos.side { + Side::Yes => "Yes", + Side::No => "No", + }; + sqlx::query( + "INSERT INTO positions \ + (ticker, side, quantity, avg_entry_price, \ + entry_time) VALUES (?1, ?2, ?3, ?4, ?5)", + ) + .bind(&pos.ticker) + .bind(side) + .bind(pos.quantity as i64) + .bind(pos.avg_entry_price.to_string()) + .bind(pos.entry_time.to_rfc3339()) + .execute(&self.pool) + .await?; + } + + Ok(()) + } + + pub async fn record_fill( + &self, + fill: &Fill, + pnl: Option, + exit_reason: Option<&str>, + ) -> anyhow::Result<()> { + let side = match fill.side { + Side::Yes => "Yes", + Side::No => "No", + }; + sqlx::query( + "INSERT INTO fills \ + (ticker, side, quantity, price, timestamp, pnl, \ + exit_reason) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) + .bind(&fill.ticker) + .bind(side) + .bind(fill.quantity as i64) + .bind(fill.price.to_string()) + .bind(fill.timestamp.to_rfc3339()) + .bind(pnl.map(|p| p.to_string())) + .bind(exit_reason) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn snapshot_equity( + &self, + timestamp: DateTime, + equity: Decimal, + cash: Decimal, + positions_value: Decimal, + drawdown_pct: f64, + ) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO equity_snapshots \ + (timestamp, equity, cash, positions_value, \ + drawdown_pct) VALUES (?1, ?2, ?3, ?4, ?5)", + ) + .bind(timestamp.to_rfc3339()) + .bind(equity.to_string()) + .bind(cash.to_string()) + .bind(positions_value.to_string()) + .bind(drawdown_pct) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn record_circuit_breaker_event( + &self, + rule: &str, + details: &str, + action: &str, + ) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO circuit_breaker_events \ + (timestamp, rule, details, action) \ + VALUES (datetime('now'), ?1, ?2, ?3)", + ) + .bind(rule) + .bind(details) + .bind(action) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn record_pipeline_run( + &self, + timestamp: DateTime, + duration_ms: u64, + candidates_fetched: usize, + candidates_filtered: usize, + candidates_selected: usize, + signals_generated: usize, + fills_executed: usize, + errors: Option<&str>, + ) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO pipeline_runs \ + (timestamp, duration_ms, candidates_fetched, \ + candidates_filtered, candidates_selected, \ + signals_generated, fills_executed, errors) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + ) + .bind(timestamp.to_rfc3339()) + .bind(duration_ms as i64) + .bind(candidates_fetched as i64) + .bind(candidates_filtered as i64) + .bind(candidates_selected as i64) + .bind(signals_generated as i64) + .bind(fills_executed as i64) + .bind(errors) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn get_equity_curve( + &self, + ) -> anyhow::Result> { + let rows = sqlx::query_as::<_, (String, String, String, String, f64)>( + "SELECT timestamp, equity, cash, positions_value, \ + drawdown_pct FROM equity_snapshots \ + ORDER BY timestamp ASC", + ) + .fetch_all(&self.pool) + .await?; + + let mut snapshots = Vec::with_capacity(rows.len()); + for (ts, eq, cash, pv, dd) in rows { + snapshots.push(EquitySnapshot { + timestamp: ts.parse::>()?, + equity: Decimal::from_str(&eq)?, + cash: Decimal::from_str(&cash)?, + positions_value: Decimal::from_str(&pv)?, + drawdown_pct: dd, + }); + } + Ok(snapshots) + } + + pub async fn get_recent_fills( + &self, + limit: u32, + ) -> anyhow::Result> { + let rows = sqlx::query_as::< + _, + (String, String, i64, String, String, Option, Option), + >( + "SELECT ticker, side, quantity, price, timestamp, \ + pnl, exit_reason FROM fills \ + ORDER BY id DESC LIMIT ?1", + ) + .bind(limit as i64) + .fetch_all(&self.pool) + .await?; + + let mut fills = Vec::with_capacity(rows.len()); + for (ticker, side, qty, price, ts, pnl, reason) in rows { + fills.push(FillRecord { + ticker, + side: match side.as_str() { + "Yes" => Side::Yes, + _ => Side::No, + }, + quantity: qty as u64, + price: Decimal::from_str(&price)?, + timestamp: ts.parse::>()?, + pnl: pnl + .map(|p| Decimal::from_str(&p)) + .transpose()?, + exit_reason: reason, + }); + } + Ok(fills) + } + + pub async fn get_fills_since( + &self, + since: DateTime, + ) -> anyhow::Result { + let row = sqlx::query_as::<_, (i64,)>( + "SELECT COUNT(*) FROM fills \ + WHERE timestamp >= ?1", + ) + .bind(since.to_rfc3339()) + .fetch_one(&self.pool) + .await?; + Ok(row.0 as u32) + } + + pub async fn get_circuit_breaker_events( + &self, + limit: u32, + ) -> anyhow::Result> { + let rows = sqlx::query_as::<_, (String, String, String, String)>( + "SELECT timestamp, rule, details, action \ + FROM circuit_breaker_events \ + ORDER BY id DESC LIMIT ?1", + ) + .bind(limit as i64) + .fetch_all(&self.pool) + .await?; + + let mut events = Vec::with_capacity(rows.len()); + for (ts, rule, details, action) in rows { + events.push(CbEvent { + timestamp: ts.parse::>()?, + rule, + details, + action, + }); + } + Ok(events) + } + + pub async fn get_peak_equity( + &self, + ) -> anyhow::Result> { + let row = sqlx::query_as::<_, (Option,)>( + "SELECT MAX(equity) FROM equity_snapshots", + ) + .fetch_one(&self.pool) + .await?; + + match row.0 { + Some(s) => Ok(Some(Decimal::from_str(&s)?)), + None => Ok(None), + } + } +} + +#[derive(Debug, Clone)] +pub struct EquitySnapshot { + pub timestamp: DateTime, + pub equity: Decimal, + pub cash: Decimal, + pub positions_value: Decimal, + pub drawdown_pct: f64, +} + +#[derive(Debug, Clone)] +pub struct FillRecord { + pub ticker: String, + pub side: Side, + pub quantity: u64, + pub price: Decimal, + pub timestamp: DateTime, + pub pnl: Option, + pub exit_reason: Option, +} + +#[derive(Debug, Clone)] +pub struct CbEvent { + pub timestamp: DateTime, + pub rule: String, + pub details: String, + pub action: String, +} diff --git a/src/store/schema.rs b/src/store/schema.rs new file mode 100644 index 0000000..52935ba --- /dev/null +++ b/src/store/schema.rs @@ -0,0 +1,56 @@ +pub const MIGRATIONS: &str = r#" +CREATE TABLE IF NOT EXISTS portfolio_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + cash TEXT NOT NULL, + initial_capital TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS positions ( + ticker TEXT PRIMARY KEY, + side TEXT NOT NULL, + quantity INTEGER NOT NULL, + avg_entry_price TEXT NOT NULL, + entry_time TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS fills ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ticker TEXT NOT NULL, + side TEXT NOT NULL, + quantity INTEGER NOT NULL, + price TEXT NOT NULL, + timestamp TEXT NOT NULL, + pnl TEXT, + exit_reason TEXT +); + +CREATE TABLE IF NOT EXISTS equity_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + equity TEXT NOT NULL, + cash TEXT NOT NULL, + positions_value TEXT NOT NULL, + drawdown_pct REAL NOT NULL DEFAULT 0.0 +); + +CREATE TABLE IF NOT EXISTS circuit_breaker_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + rule TEXT NOT NULL, + details TEXT NOT NULL, + action TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS pipeline_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + duration_ms INTEGER NOT NULL, + candidates_fetched INTEGER NOT NULL DEFAULT 0, + candidates_filtered INTEGER NOT NULL DEFAULT 0, + candidates_selected INTEGER NOT NULL DEFAULT 0, + signals_generated INTEGER NOT NULL DEFAULT 0, + fills_executed INTEGER NOT NULL DEFAULT 0, + errors TEXT +); +"#; diff --git a/src/web/handlers.rs b/src/web/handlers.rs new file mode 100644 index 0000000..695c35a --- /dev/null +++ b/src/web/handlers.rs @@ -0,0 +1,514 @@ +use super::{AppState, BacktestRunStatus}; +use crate::backtest::Backtester; +use crate::data::HistoricalData; +use crate::execution::PositionSizingConfig; +use crate::types::{BacktestConfig, ExitConfig}; +use axum::extract::State; +use axum::http::StatusCode; +use axum::Json; +use chrono::Utc; +use rust_decimal::Decimal; +use rust_decimal::prelude::ToPrimitive; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::Arc; +use tracing::{error, info}; + +#[derive(Serialize)] +pub struct StatusResponse { + pub state: String, + pub uptime_secs: u64, + pub last_tick: Option, + pub ticks_completed: u64, +} + +#[derive(Serialize)] +pub struct PortfolioResponse { + pub cash: f64, + pub equity: f64, + pub initial_capital: f64, + pub return_pct: f64, + pub drawdown_pct: f64, + pub positions_count: usize, +} + +#[derive(Serialize)] +pub struct PositionResponse { + pub ticker: String, + pub side: String, + pub quantity: u64, + pub entry_price: f64, + pub entry_time: String, + pub unrealized_pnl: f64, +} + +#[derive(Serialize)] +pub struct TradeResponse { + pub ticker: String, + pub side: String, + pub quantity: u64, + pub price: f64, + pub timestamp: String, + pub pnl: Option, + pub exit_reason: Option, +} + +#[derive(Serialize)] +pub struct EquityPoint { + pub timestamp: String, + pub equity: f64, + pub cash: f64, + pub positions_value: f64, + pub drawdown_pct: f64, +} + +#[derive(Serialize)] +pub struct CbResponse { + pub status: String, + pub events: Vec, +} + +#[derive(Serialize)] +pub struct CbEventResponse { + pub timestamp: String, + pub rule: String, + pub details: String, + pub action: String, +} + +pub async fn get_status( + State(state): State>, +) -> Json { + let status = state.engine.get_status().await; + Json(StatusResponse { + state: format!("{}", status.state), + uptime_secs: status.uptime_secs, + last_tick: status + .last_tick + .map(|t| t.to_rfc3339()), + ticks_completed: status.ticks_completed, + }) +} + +pub async fn get_portfolio( + State(state): State>, +) -> Json { + let ctx = state.engine.get_context().await; + let portfolio = &ctx.portfolio; + + let positions_value: f64 = portfolio + .positions + .values() + .map(|p| { + p.avg_entry_price.to_f64().unwrap_or(0.0) + * p.quantity as f64 + }) + .sum(); + + let cash = portfolio.cash.to_f64().unwrap_or(0.0); + let equity = cash + positions_value; + let initial = portfolio + .initial_capital + .to_f64() + .unwrap_or(10000.0); + let return_pct = if initial > 0.0 { + (equity - initial) / initial * 100.0 + } else { + 0.0 + }; + + let peak = state + .store + .get_peak_equity() + .await + .ok() + .flatten() + .and_then(|p| p.to_f64()) + .unwrap_or(equity); + + let drawdown_pct = if peak > 0.0 { + ((peak - equity) / peak * 100.0).max(0.0) + } else { + 0.0 + }; + + Json(PortfolioResponse { + cash, + equity, + initial_capital: initial, + return_pct, + drawdown_pct, + positions_count: portfolio.positions.len(), + }) +} + +pub async fn get_positions( + State(state): State>, +) -> Json> { + let ctx = state.engine.get_context().await; + let positions: Vec = ctx + .portfolio + .positions + .values() + .map(|p| { + let entry = p.avg_entry_price.to_f64().unwrap_or(0.0); + PositionResponse { + ticker: p.ticker.clone(), + side: format!("{:?}", p.side), + quantity: p.quantity, + entry_price: entry, + entry_time: p.entry_time.to_rfc3339(), + unrealized_pnl: 0.0, + } + }) + .collect(); + + Json(positions) +} + +pub async fn get_trades( + State(state): State>, +) -> Json> { + let fills = state + .store + .get_recent_fills(100) + .await + .unwrap_or_default(); + + let trades: Vec = fills + .into_iter() + .map(|f| TradeResponse { + ticker: f.ticker, + side: format!("{:?}", f.side), + quantity: f.quantity, + price: f.price.to_f64().unwrap_or(0.0), + timestamp: f.timestamp.to_rfc3339(), + pnl: f.pnl.and_then(|p| p.to_f64()), + exit_reason: f.exit_reason, + }) + .collect(); + + Json(trades) +} + +pub async fn get_equity( + State(state): State>, +) -> Json> { + let snapshots = state + .store + .get_equity_curve() + .await + .unwrap_or_default(); + + let points: Vec = snapshots + .into_iter() + .map(|s| EquityPoint { + timestamp: s.timestamp.to_rfc3339(), + equity: s.equity.to_f64().unwrap_or(0.0), + cash: s.cash.to_f64().unwrap_or(0.0), + positions_value: s.positions_value.to_f64().unwrap_or(0.0), + drawdown_pct: s.drawdown_pct, + }) + .collect(); + + Json(points) +} + +pub async fn get_circuit_breaker( + State(state): State>, +) -> Json { + let engine_status = state.engine.get_status().await; + let cb_status = match engine_status.state { + crate::engine::EngineState::Paused(ref reason) => { + format!("tripped: {}", reason) + } + _ => "ok".to_string(), + }; + + let events = state + .store + .get_circuit_breaker_events(20) + .await + .unwrap_or_default(); + + let event_responses: Vec = events + .into_iter() + .map(|e| CbEventResponse { + timestamp: e.timestamp.to_rfc3339(), + rule: e.rule, + details: e.details, + action: e.action, + }) + .collect(); + + Json(CbResponse { + status: cb_status, + events: event_responses, + }) +} + +pub async fn post_pause( + State(state): State>, +) -> StatusCode { + state + .engine + .pause("manual pause via API".to_string()) + .await; + StatusCode::OK +} + +pub async fn post_resume( + State(state): State>, +) -> StatusCode { + state.engine.resume().await; + StatusCode::OK +} + +#[derive(Deserialize)] +pub struct BacktestRequest { + pub start: String, + pub end: String, + pub data_dir: Option, + pub capital: Option, + pub max_positions: Option, + pub max_position: Option, + pub interval_hours: Option, + pub kelly_fraction: Option, + pub max_position_pct: Option, + pub take_profit: Option, + pub stop_loss: Option, + pub max_hold_hours: Option, +} + +#[derive(Serialize)] +pub struct BacktestStatusResponse { + pub status: String, + pub elapsed_secs: Option, + pub error: Option, + pub phase: Option, + pub current_step: Option, + pub total_steps: Option, + pub progress_pct: Option, +} + +#[derive(Serialize)] +pub struct BacktestErrorResponse { + pub error: String, +} + +pub async fn post_backtest_run( + State(state): State>, + Json(req): Json, +) -> Result)> { + { + let guard = state.backtest.lock().await; + if matches!(guard.status, BacktestRunStatus::Running { .. }) { + return Err(( + StatusCode::CONFLICT, + Json(BacktestErrorResponse { + error: "backtest already running".into(), + }), + )); + } + } + + let start_time = crate::parse_date(&req.start).map_err(|e| { + ( + StatusCode::BAD_REQUEST, + Json(BacktestErrorResponse { + error: format!("invalid start date: {}", e), + }), + ) + })?; + let end_time = crate::parse_date(&req.end).map_err(|e| { + ( + StatusCode::BAD_REQUEST, + Json(BacktestErrorResponse { + error: format!("invalid end date: {}", e), + }), + ) + })?; + + let data_dir = if let Some(ref dir) = req.data_dir { + PathBuf::from(dir) + } else { + state.data_dir.clone() + }; + + if !data_dir.exists() { + return Err(( + StatusCode::BAD_REQUEST, + Json(BacktestErrorResponse { + error: format!( + "data directory not found: {}", + data_dir.display() + ), + }), + )); + } + + info!( + start = %start_time, + end = %end_time, + data_dir = %data_dir.display(), + "starting backtest from web UI" + ); + + let progress = Arc::new(super::BacktestProgress::new(0)); + + { + let mut guard = state.backtest.lock().await; + guard.status = + BacktestRunStatus::Running { started_at: Utc::now() }; + guard.progress = Some(progress.clone()); + guard.result = None; + guard.error = None; + } + + let backtest_state = state.backtest.clone(); + let progress = progress.clone(); + + let capital = req.capital.unwrap_or(10000.0); + let max_positions = req.max_positions.unwrap_or(100); + let max_position = req.max_position.unwrap_or(100); + let interval_hours = req.interval_hours.unwrap_or(1); + let kelly_fraction = req.kelly_fraction.unwrap_or(0.40); + let max_position_pct = req.max_position_pct.unwrap_or(0.30); + let take_profit = req.take_profit.unwrap_or(0.50); + let stop_loss = req.stop_loss.unwrap_or(0.99); + let max_hold_hours = req.max_hold_hours.unwrap_or(48); + + tokio::spawn(async move { + let data = match tokio::task::spawn_blocking(move || { + HistoricalData::load(&data_dir) + }) + .await + { + Ok(Ok(d)) => Arc::new(d), + Ok(Err(e)) => { + let mut guard = backtest_state.lock().await; + guard.status = BacktestRunStatus::Failed; + guard.error = + Some(format!("failed to load data: {}", e)); + error!(error = %e, "backtest data load failed"); + return; + } + Err(e) => { + let mut guard = backtest_state.lock().await; + guard.status = BacktestRunStatus::Failed; + guard.error = + Some(format!("task join error: {}", e)); + return; + } + }; + + let config = BacktestConfig { + start_time, + end_time, + interval: chrono::Duration::hours(interval_hours), + initial_capital: Decimal::try_from(capital) + .unwrap_or(Decimal::new(10000, 0)), + max_position_size: max_position, + max_positions, + }; + + let sizing_config = PositionSizingConfig { + kelly_fraction, + max_position_pct, + min_position_size: 10, + max_position_size: max_position, + }; + + let exit_config = ExitConfig { + take_profit_pct: take_profit, + stop_loss_pct: stop_loss, + max_hold_hours, + score_reversal_threshold: -0.3, + }; + + let backtester = Backtester::with_configs( + config, + data, + sizing_config, + exit_config, + ) + .with_progress(progress); + let result = backtester.run().await; + + let mut guard = backtest_state.lock().await; + guard.status = BacktestRunStatus::Complete; + guard.result = Some(result); + }); + + Ok(StatusCode::OK) +} + +pub async fn get_backtest_status( + State(state): State>, +) -> Json { + let guard = state.backtest.lock().await; + let (status_str, elapsed, error) = match &guard.status { + BacktestRunStatus::Idle => { + ("idle".to_string(), None, None) + } + BacktestRunStatus::Running { started_at } => { + let elapsed = Utc::now() + .signed_duration_since(*started_at) + .num_seconds() + .max(0) as u64; + ("running".to_string(), Some(elapsed), None) + } + BacktestRunStatus::Complete => { + ("complete".to_string(), None, None) + } + BacktestRunStatus::Failed => { + ("failed".to_string(), None, guard.error.clone()) + } + }; + + let (phase, current_step, total_steps, progress_pct) = + if let Some(ref p) = guard.progress { + let current = p.current_step.load( + std::sync::atomic::Ordering::Relaxed, + ); + let total = p.total_steps.load( + std::sync::atomic::Ordering::Relaxed, + ); + let pct = if total > 0 { + current as f64 / total as f64 * 100.0 + } else { + 0.0 + }; + ( + Some(p.phase_name().to_string()), + Some(current), + Some(total), + Some(pct), + ) + } else { + (None, None, None, None) + }; + + Json(BacktestStatusResponse { + status: status_str, + elapsed_secs: elapsed, + error, + phase, + current_step, + total_steps, + progress_pct, + }) +} + +pub async fn get_backtest_result( + State(state): State>, +) -> Result< + Json, + StatusCode, +> { + let guard = state.backtest.lock().await; + match &guard.result { + Some(result) => Ok(Json(result.clone())), + None => Err(StatusCode::NOT_FOUND), + } +} diff --git a/src/web/mod.rs b/src/web/mod.rs new file mode 100644 index 0000000..92e55d7 --- /dev/null +++ b/src/web/mod.rs @@ -0,0 +1,103 @@ +mod handlers; + +use crate::engine::PaperTradingEngine; +use crate::metrics::BacktestResult; +use crate::store::SqliteStore; +use axum::routing::{get, post}; +use axum::Router; +use chrono::{DateTime, Utc}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::broadcast; +use tower_http::services::ServeDir; + +pub enum BacktestRunStatus { + Idle, + Running { started_at: DateTime }, + Complete, + Failed, +} + +pub struct BacktestProgress { + pub phase: std::sync::atomic::AtomicU8, + pub current_step: std::sync::atomic::AtomicU64, + pub total_steps: std::sync::atomic::AtomicU64, +} + +impl BacktestProgress { + pub const PHASE_LOADING: u8 = 0; + pub const PHASE_RUNNING: u8 = 1; + + pub fn new(total_steps: u64) -> Self { + Self { + phase: std::sync::atomic::AtomicU8::new( + Self::PHASE_LOADING, + ), + current_step: std::sync::atomic::AtomicU64::new(0), + total_steps: std::sync::atomic::AtomicU64::new( + total_steps, + ), + } + } + + pub fn phase_name(&self) -> &'static str { + match self + .phase + .load(std::sync::atomic::Ordering::Relaxed) + { + Self::PHASE_LOADING => "loading data", + Self::PHASE_RUNNING => "simulating", + _ => "unknown", + } + } +} + +pub struct BacktestState { + pub status: BacktestRunStatus, + pub progress: Option>, + pub result: Option, + pub error: Option, +} + +pub struct AppState { + pub engine: Arc, + pub store: Arc, + pub shutdown_tx: broadcast::Sender<()>, + pub backtest: Arc>, + pub data_dir: PathBuf, +} + +pub fn build_router(state: Arc) -> Router { + Router::new() + .route("/api/status", get(handlers::get_status)) + .route("/api/portfolio", get(handlers::get_portfolio)) + .route("/api/positions", get(handlers::get_positions)) + .route("/api/trades", get(handlers::get_trades)) + .route("/api/equity", get(handlers::get_equity)) + .route( + "/api/circuit-breaker", + get(handlers::get_circuit_breaker), + ) + .route( + "/api/control/pause", + post(handlers::post_pause), + ) + .route( + "/api/control/resume", + post(handlers::post_resume), + ) + .route( + "/api/backtest/run", + post(handlers::post_backtest_run), + ) + .route( + "/api/backtest/status", + get(handlers::get_backtest_status), + ) + .route( + "/api/backtest/result", + get(handlers::get_backtest_result), + ) + .fallback_service(ServeDir::new("static")) + .with_state(state) +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..ea41460 --- /dev/null +++ b/static/index.html @@ -0,0 +1,1124 @@ + + + + + + kalshi paper trading + + + + + +
+
kalshi paper
+
+ loading... +
+
+
+
+ +
+
+ + + +
+
+ +
+
+
+
+
+ +
+
+
positions
+

loading...

+
+ +
+
recent trades
+

loading...

+
+
+
+ +
+ +
+
backtest
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + + + + + + +
+
+ + + +