412 lines
10 KiB
Rust
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()
|
|
}
|