diff --git a/crates/fidc-core/src/futures.rs b/crates/fidc-core/src/futures.rs new file mode 100644 index 0000000..db159b2 --- /dev/null +++ b/crates/fidc-core/src/futures.rs @@ -0,0 +1,347 @@ +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum FuturesDirection { + Long, + Short, +} + +impl FuturesDirection { + pub fn as_str(&self) -> &'static str { + match self { + Self::Long => "long", + Self::Short => "short", + } + } + + fn factor(&self) -> f64 { + match self { + Self::Long => 1.0, + Self::Short => -1.0, + } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct FuturesContractSpec { + pub contract_multiplier: f64, + pub long_margin_rate: f64, + pub short_margin_rate: f64, +} + +impl FuturesContractSpec { + pub fn new(contract_multiplier: f64, long_margin_rate: f64, short_margin_rate: f64) -> Self { + Self { + contract_multiplier: contract_multiplier.max(1.0), + long_margin_rate: long_margin_rate.max(0.0), + short_margin_rate: short_margin_rate.max(0.0), + } + } + + pub fn margin_rate(&self, direction: FuturesDirection) -> f64 { + match direction { + FuturesDirection::Long => self.long_margin_rate, + FuturesDirection::Short => self.short_margin_rate, + } + } +} + +#[derive(Debug, Clone)] +pub struct FuturesPosition { + pub symbol: String, + pub direction: FuturesDirection, + pub old_quantity: u32, + pub quantity: u32, + pub avg_price: f64, + pub last_price: f64, + pub prev_close: f64, + pub contract_multiplier: f64, + pub margin_rate: f64, + pub transaction_cost: f64, + trade_quantity_delta: i32, + trade_cost: f64, +} + +impl FuturesPosition { + pub fn new( + symbol: impl Into, + direction: FuturesDirection, + spec: FuturesContractSpec, + init_quantity: u32, + init_price: f64, + ) -> Self { + let margin_rate = spec.margin_rate(direction); + Self { + symbol: symbol.into(), + direction, + old_quantity: init_quantity, + quantity: init_quantity, + avg_price: init_price.max(0.0), + last_price: init_price.max(0.0), + prev_close: init_price.max(0.0), + contract_multiplier: spec.contract_multiplier, + margin_rate, + transaction_cost: 0.0, + trade_quantity_delta: 0, + trade_cost: 0.0, + } + } + + pub fn today_quantity(&self) -> u32 { + self.quantity.saturating_sub(self.old_quantity) + } + + pub fn market_value(&self) -> f64 { + self.quantity as f64 * self.last_price * self.contract_multiplier + } + + pub fn margin(&self) -> f64 { + self.market_value() * self.margin_rate + } + + pub fn equity(&self) -> f64 { + (self.last_price - self.avg_price) + * self.quantity as f64 + * self.contract_multiplier + * self.direction.factor() + } + + pub fn pnl(&self) -> f64 { + self.equity() + } + + pub fn trading_pnl(&self) -> f64 { + (self.trade_quantity_delta as f64 * self.last_price - self.trade_cost) + * self.contract_multiplier + * self.direction.factor() + } + + pub fn position_pnl(&self) -> f64 { + if self.old_quantity == 0 { + 0.0 + } else { + self.old_quantity as f64 + * (self.last_price - self.prev_close) + * self.contract_multiplier + * self.direction.factor() + } + } + + pub fn open(&mut self, quantity: u32, price: f64, transaction_cost: f64) { + if quantity == 0 { + return; + } + let old_value = self.avg_price * self.quantity as f64; + self.quantity += quantity; + self.avg_price = (old_value + price * quantity as f64) / self.quantity as f64; + self.last_price = price; + self.transaction_cost += transaction_cost.max(0.0); + self.trade_quantity_delta += quantity as i32; + self.trade_cost += price * quantity as f64; + } + + pub fn close( + &mut self, + quantity: u32, + price: f64, + transaction_cost: f64, + ) -> Result { + if quantity > self.quantity { + return Err(format!( + "close quantity {} exceeds current quantity {} for {} {}", + quantity, + self.quantity, + self.symbol, + self.direction.as_str() + )); + } + if quantity == 0 { + return Ok(0.0); + } + + let realized = (price - self.avg_price) + * quantity as f64 + * self.contract_multiplier + * self.direction.factor() + - transaction_cost.max(0.0); + self.quantity -= quantity; + if self.quantity == 0 { + self.avg_price = 0.0; + } + self.last_price = price; + self.transaction_cost += transaction_cost.max(0.0); + self.trade_quantity_delta -= quantity as i32; + self.trade_cost -= price * quantity as f64; + Ok(realized) + } + + pub fn mark_price(&mut self, price: f64) { + if price.is_finite() && price > 0.0 { + self.last_price = price; + } + } + + pub fn begin_trading_day(&mut self) { + self.old_quantity = self.quantity; + self.prev_close = self.last_price; + self.transaction_cost = 0.0; + self.trade_quantity_delta = 0; + self.trade_cost = 0.0; + } + + pub fn settlement(&mut self, settlement_price: f64) -> f64 { + self.mark_price(settlement_price); + let cash_delta = self.equity(); + self.avg_price = self.last_price; + self.prev_close = self.last_price; + cash_delta + } +} + +#[derive(Debug, Clone)] +pub struct FuturesAccountState { + total_cash: f64, + frozen_cash: f64, + positions: BTreeMap<(String, FuturesDirection), FuturesPosition>, +} + +impl FuturesAccountState { + pub fn new(total_cash: f64) -> Self { + Self { + total_cash, + frozen_cash: 0.0, + positions: BTreeMap::new(), + } + } + + pub fn total_cash(&self) -> f64 { + self.total_cash + } + + pub fn frozen_cash(&self) -> f64 { + self.frozen_cash + } + + pub fn cash(&self) -> f64 { + self.total_cash - self.margin() - self.frozen_cash + } + + pub fn margin(&self) -> f64 { + self.positions.values().map(FuturesPosition::margin).sum() + } + + pub fn market_value(&self) -> f64 { + self.positions + .values() + .map(FuturesPosition::market_value) + .sum() + } + + pub fn position_equity(&self) -> f64 { + self.positions.values().map(FuturesPosition::equity).sum() + } + + pub fn total_value(&self) -> f64 { + self.total_cash + self.position_equity() + } + + pub fn daily_pnl(&self) -> f64 { + self.trading_pnl() + self.position_pnl() - self.transaction_cost() + } + + pub fn trading_pnl(&self) -> f64 { + self.positions + .values() + .map(FuturesPosition::trading_pnl) + .sum() + } + + pub fn position_pnl(&self) -> f64 { + self.positions + .values() + .map(FuturesPosition::position_pnl) + .sum() + } + + pub fn transaction_cost(&self) -> f64 { + self.positions + .values() + .map(|position| position.transaction_cost) + .sum() + } + + pub fn positions(&self) -> &BTreeMap<(String, FuturesDirection), FuturesPosition> { + &self.positions + } + + pub fn position(&self, symbol: &str, direction: FuturesDirection) -> Option<&FuturesPosition> { + self.positions.get(&(symbol.to_string(), direction)) + } + + pub fn open( + &mut self, + symbol: impl Into, + direction: FuturesDirection, + spec: FuturesContractSpec, + quantity: u32, + price: f64, + transaction_cost: f64, + ) { + if quantity == 0 { + return; + } + let symbol = symbol.into(); + let position = self + .positions + .entry((symbol.clone(), direction)) + .or_insert_with(|| FuturesPosition::new(symbol, direction, spec, 0, price)); + position.open(quantity, price, transaction_cost); + self.total_cash -= transaction_cost.max(0.0); + } + + pub fn close( + &mut self, + symbol: &str, + direction: FuturesDirection, + quantity: u32, + price: f64, + transaction_cost: f64, + ) -> Result { + let key = (symbol.to_string(), direction); + let position = self + .positions + .get_mut(&key) + .ok_or_else(|| format!("missing futures position {symbol} {}", direction.as_str()))?; + let cash_delta = position.close(quantity, price, transaction_cost)?; + self.total_cash += cash_delta; + if position.quantity == 0 { + self.positions.remove(&key); + } + Ok(cash_delta) + } + + pub fn mark_price(&mut self, symbol: &str, direction: FuturesDirection, price: f64) { + if let Some(position) = self.positions.get_mut(&(symbol.to_string(), direction)) { + position.mark_price(price); + } + } + + pub fn begin_trading_day(&mut self) { + for position in self.positions.values_mut() { + position.begin_trading_day(); + } + } + + pub fn settle(&mut self, settlement_prices: &BTreeMap) -> f64 { + let mut cash_delta = 0.0; + for position in self.positions.values_mut() { + let price = settlement_prices + .get(&position.symbol) + .copied() + .unwrap_or(position.last_price); + cash_delta += position.settlement(price); + } + self.total_cash += cash_delta; + cash_delta + } +} diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 56d72a7..9fe2373 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod data; pub mod engine; pub mod event_bus; pub mod events; +pub mod futures; pub mod instrument; pub mod metrics; pub mod platform_expr_strategy; @@ -32,6 +33,7 @@ pub use events::{ AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, ProcessEventKind, }; +pub use futures::{FuturesAccountState, FuturesContractSpec, FuturesDirection, FuturesPosition}; pub use instrument::Instrument; pub use metrics::{BacktestMetrics, compute_backtest_metrics}; pub use platform_expr_strategy::{ diff --git a/crates/fidc-core/tests/futures_account.rs b/crates/fidc-core/tests/futures_account.rs new file mode 100644 index 0000000..cd92681 --- /dev/null +++ b/crates/fidc-core/tests/futures_account.rs @@ -0,0 +1,52 @@ +use std::collections::BTreeMap; + +use fidc_core::{FuturesAccountState, FuturesContractSpec, FuturesDirection}; + +#[test] +fn futures_account_tracks_long_margin_pnl_and_settlement() { + let spec = FuturesContractSpec::new(300.0, 0.12, 0.14); + let mut account = FuturesAccountState::new(1_000_000.0); + + account.open("IF2501", FuturesDirection::Long, spec, 2, 4000.0, 12.0); + account.mark_price("IF2501", FuturesDirection::Long, 4010.0); + + assert!((account.total_cash() - 999_988.0).abs() < 1e-6); + assert!((account.margin() - 288_720.0).abs() < 1e-6); + assert!((account.cash() - 711_268.0).abs() < 1e-6); + assert!((account.position_equity() - 6_000.0).abs() < 1e-6); + assert!((account.total_value() - 1_005_988.0).abs() < 1e-6); + + let settlement = BTreeMap::from([("IF2501".to_string(), 4020.0)]); + let cash_delta = account.settle(&settlement); + + assert!((cash_delta - 12_000.0).abs() < 1e-6); + assert!((account.total_cash() - 1_011_988.0).abs() < 1e-6); + let position = account + .position("IF2501", FuturesDirection::Long) + .expect("long position"); + assert!((position.avg_price - 4020.0).abs() < 1e-6); + assert!((position.equity()).abs() < 1e-6); +} + +#[test] +fn futures_account_tracks_short_close_cash_delta() { + let spec = FuturesContractSpec::new(10.0, 0.1, 0.2); + let mut account = FuturesAccountState::new(100_000.0); + + account.open("RB2501", FuturesDirection::Short, spec, 5, 3500.0, 3.0); + account.mark_price("RB2501", FuturesDirection::Short, 3480.0); + assert!((account.margin() - 34_800.0).abs() < 1e-6); + assert!((account.position_equity() - 1_000.0).abs() < 1e-6); + + let cash_delta = account + .close("RB2501", FuturesDirection::Short, 2, 3470.0, 2.0) + .expect("close short"); + + assert!((cash_delta - 598.0).abs() < 1e-6); + assert!((account.total_cash() - 100_595.0).abs() < 1e-6); + let position = account + .position("RB2501", FuturesDirection::Short) + .expect("remaining short position"); + assert_eq!(position.quantity, 3); + assert!((position.equity() - 900.0).abs() < 1e-6); +} diff --git a/docs/rqalpha-gap-roadmap.md b/docs/rqalpha-gap-roadmap.md index 023dc53..915f59f 100644 --- a/docs/rqalpha-gap-roadmap.md +++ b/docs/rqalpha-gap-roadmap.md @@ -85,7 +85,11 @@ current alignment pass. - [x] management-fee rate and callback parity - [x] stock account map/accessor surface (`accounts`, `stock_account`, `account_by_type("STOCK")`) -- [ ] full futures account, margin, and short-position execution model +- [x] standalone futures account model with contract multiplier, long/short + margin, daily mark-to-market settlement, and short close cashflow +- [ ] wire futures account into the generic backtest engine runtime +- [ ] futures order intents, matching, close-today semantics, and expiration + settlement ## Execution Order @@ -103,5 +107,6 @@ current alignment pass. Active implementation target: continue account parity after exposing the stock account runtime view, core Portfolio fields, deposit/withdraw, financing -liability APIs, management-fee callbacks, and stock account accessors; next gap -is the full futures account, margin, and short-position execution model. +liability APIs, management-fee callbacks, stock account accessors, and the +standalone futures account model; next gap is wiring futures into the generic +engine runtime and adding futures-specific order matching semantics.