From 2669350154f9c44cce2e7112409124ce599f56b2 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 23 Apr 2026 20:29:14 -0700 Subject: [PATCH] Add futures order execution model --- crates/fidc-core/src/futures.rs | 351 +++++++++++++++++++++- crates/fidc-core/src/lib.rs | 5 +- crates/fidc-core/tests/futures_account.rs | 113 ++++++- docs/rqalpha-gap-roadmap.md | 10 +- 4 files changed, 472 insertions(+), 7 deletions(-) diff --git a/crates/fidc-core/src/futures.rs b/crates/fidc-core/src/futures.rs index db159b2..9deab2a 100644 --- a/crates/fidc-core/src/futures.rs +++ b/crates/fidc-core/src/futures.rs @@ -1,5 +1,9 @@ use std::collections::BTreeMap; +use chrono::NaiveDate; + +use crate::events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent}; + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum FuturesDirection { Long, @@ -20,6 +24,39 @@ impl FuturesDirection { Self::Short => -1.0, } } + + fn open_side(&self) -> OrderSide { + match self { + Self::Long => OrderSide::Buy, + Self::Short => OrderSide::Sell, + } + } + + fn close_side(&self) -> OrderSide { + match self { + Self::Long => OrderSide::Sell, + Self::Short => OrderSide::Buy, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FuturesPositionEffect { + Open, + Close, + CloseToday, + CloseYesterday, +} + +impl FuturesPositionEffect { + pub fn as_str(&self) -> &'static str { + match self { + Self::Open => "open", + Self::Close => "close", + Self::CloseToday => "close_today", + Self::CloseYesterday => "close_yesterday", + } + } } #[derive(Debug, Clone, Copy)] @@ -29,6 +66,72 @@ pub struct FuturesContractSpec { pub short_margin_rate: f64, } +#[derive(Debug, Clone)] +pub struct FuturesOrderIntent { + pub symbol: String, + pub direction: FuturesDirection, + pub effect: FuturesPositionEffect, + pub spec: FuturesContractSpec, + pub quantity: u32, + pub price: f64, + pub transaction_cost: f64, + pub reason: String, +} + +impl FuturesOrderIntent { + pub fn open( + symbol: impl Into, + direction: FuturesDirection, + spec: FuturesContractSpec, + quantity: u32, + price: f64, + transaction_cost: f64, + reason: impl Into, + ) -> Self { + Self { + symbol: symbol.into(), + direction, + effect: FuturesPositionEffect::Open, + spec, + quantity, + price, + transaction_cost, + reason: reason.into(), + } + } + + pub fn close( + symbol: impl Into, + direction: FuturesDirection, + effect: FuturesPositionEffect, + spec: FuturesContractSpec, + quantity: u32, + price: f64, + transaction_cost: f64, + reason: impl Into, + ) -> Self { + Self { + symbol: symbol.into(), + direction, + effect, + spec, + quantity, + price, + transaction_cost, + reason: reason.into(), + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct FuturesExecutionReport { + pub order_events: Vec, + pub fill_events: Vec, + pub position_events: Vec, + pub account_events: Vec, + pub diagnostics: Vec, +} + impl FuturesContractSpec { pub fn new(contract_multiplier: f64, long_margin_rate: f64, short_margin_rate: f64) -> Self { Self { @@ -146,6 +249,24 @@ impl FuturesPosition { price: f64, transaction_cost: f64, ) -> Result { + self.close_with_effect( + quantity, + price, + transaction_cost, + FuturesPositionEffect::Close, + ) + } + + pub fn close_with_effect( + &mut self, + quantity: u32, + price: f64, + transaction_cost: f64, + effect: FuturesPositionEffect, + ) -> Result { + if effect == FuturesPositionEffect::Open { + return Err("close_with_effect does not accept open effect".to_string()); + } if quantity > self.quantity { return Err(format!( "close quantity {} exceeds current quantity {} for {} {}", @@ -158,6 +279,37 @@ impl FuturesPosition { if quantity == 0 { return Ok(0.0); } + match effect { + FuturesPositionEffect::Open => unreachable!(), + FuturesPositionEffect::Close => { + let old_closed = quantity.min(self.old_quantity); + self.old_quantity -= old_closed; + } + FuturesPositionEffect::CloseToday => { + let today_quantity = self.today_quantity(); + if quantity > today_quantity { + return Err(format!( + "close today quantity {} exceeds today quantity {} for {} {}", + quantity, + today_quantity, + self.symbol, + self.direction.as_str() + )); + } + } + FuturesPositionEffect::CloseYesterday => { + if quantity > self.old_quantity { + return Err(format!( + "close yesterday quantity {} exceeds old quantity {} for {} {}", + quantity, + self.old_quantity, + self.symbol, + self.direction.as_str() + )); + } + self.old_quantity -= quantity; + } + } let realized = (price - self.avg_price) * quantity as f64 @@ -194,6 +346,7 @@ impl FuturesPosition { let cash_delta = self.equity(); self.avg_price = self.last_price; self.prev_close = self.last_price; + self.old_quantity = self.quantity; cash_delta } } @@ -306,13 +459,32 @@ impl FuturesAccountState { quantity: u32, price: f64, transaction_cost: f64, + ) -> Result { + self.close_with_effect( + symbol, + direction, + quantity, + price, + transaction_cost, + FuturesPositionEffect::Close, + ) + } + + pub fn close_with_effect( + &mut self, + symbol: &str, + direction: FuturesDirection, + quantity: u32, + price: f64, + transaction_cost: f64, + effect: FuturesPositionEffect, ) -> 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)?; + let cash_delta = position.close_with_effect(quantity, price, transaction_cost, effect)?; self.total_cash += cash_delta; if position.quantity == 0 { self.positions.remove(&key); @@ -320,6 +492,183 @@ impl FuturesAccountState { Ok(cash_delta) } + pub fn execute_order( + &mut self, + date: NaiveDate, + order_id: Option, + intent: FuturesOrderIntent, + ) -> FuturesExecutionReport { + let mut report = FuturesExecutionReport::default(); + let side = if intent.effect == FuturesPositionEffect::Open { + intent.direction.open_side() + } else { + intent.direction.close_side() + }; + + if intent.quantity == 0 || !intent.price.is_finite() || intent.price <= 0.0 { + report.order_events.push(OrderEvent { + date, + order_id, + symbol: intent.symbol, + side, + requested_quantity: intent.quantity, + filled_quantity: 0, + status: OrderStatus::Rejected, + reason: format!( + "{}: invalid futures order effect={} price={} quantity={}", + intent.reason, + intent.effect.as_str(), + intent.price, + intent.quantity + ), + }); + return report; + } + + let cash_before = self.total_cash(); + let position_before = self + .position(&intent.symbol, intent.direction) + .map(|position| position.quantity) + .unwrap_or(0); + let result = match intent.effect { + FuturesPositionEffect::Open => { + let mut projected = self.clone(); + projected.open( + intent.symbol.clone(), + intent.direction, + intent.spec, + intent.quantity, + intent.price, + intent.transaction_cost, + ); + if projected.cash() < -1e-8 { + Err(format!( + "insufficient futures margin available_cash={:.2} required_margin_after={:.2}", + self.cash(), + projected.margin() + )) + } else { + self.open( + intent.symbol.clone(), + intent.direction, + intent.spec, + intent.quantity, + intent.price, + intent.transaction_cost, + ); + Ok(-intent.transaction_cost.max(0.0)) + } + } + FuturesPositionEffect::Close + | FuturesPositionEffect::CloseToday + | FuturesPositionEffect::CloseYesterday => self.close_with_effect( + &intent.symbol, + intent.direction, + intent.quantity, + intent.price, + intent.transaction_cost, + intent.effect, + ), + }; + + match result { + Ok(cash_delta) => { + let position_after = self + .position(&intent.symbol, intent.direction) + .map(|position| position.quantity) + .unwrap_or(0); + let avg_price_after = self + .position(&intent.symbol, intent.direction) + .map(|position| position.avg_price) + .unwrap_or(0.0); + let notional = + intent.price * intent.quantity as f64 * intent.spec.contract_multiplier; + report.fill_events.push(FillEvent { + date, + order_id, + symbol: intent.symbol.clone(), + side, + quantity: intent.quantity, + price: intent.price, + gross_amount: notional, + commission: intent.transaction_cost.max(0.0), + stamp_tax: 0.0, + net_cash_flow: cash_delta, + reason: format!( + "{} direction={} effect={}", + intent.reason, + intent.direction.as_str(), + intent.effect.as_str() + ), + }); + report.position_events.push(PositionEvent { + date, + symbol: intent.symbol.clone(), + delta_quantity: position_after as i32 - position_before as i32, + quantity_after: position_after, + average_cost: avg_price_after, + realized_pnl_delta: if intent.effect == FuturesPositionEffect::Open { + 0.0 + } else { + cash_delta + }, + reason: format!( + "{} direction={} effect={}", + intent.reason, + intent.direction.as_str(), + intent.effect.as_str() + ), + }); + report.account_events.push(AccountEvent { + date, + cash_before, + cash_after: self.total_cash(), + total_equity: self.total_value(), + note: format!( + "futures {} {} {}", + intent.symbol, + intent.direction.as_str(), + intent.effect.as_str() + ), + }); + report.order_events.push(OrderEvent { + date, + order_id, + symbol: intent.symbol, + side, + requested_quantity: intent.quantity, + filled_quantity: intent.quantity, + status: OrderStatus::Filled, + reason: format!( + "{} direction={} effect={}", + intent.reason, + intent.direction.as_str(), + intent.effect.as_str() + ), + }); + } + Err(reason) => { + report.order_events.push(OrderEvent { + date, + order_id, + symbol: intent.symbol, + side, + requested_quantity: intent.quantity, + filled_quantity: 0, + status: OrderStatus::Rejected, + reason: format!( + "{}: {} direction={} effect={}", + intent.reason, + reason, + intent.direction.as_str(), + intent.effect.as_str() + ), + }); + } + } + report + } + 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); diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 9fe2373..73022fd 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -33,7 +33,10 @@ pub use events::{ AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, ProcessEventKind, }; -pub use futures::{FuturesAccountState, FuturesContractSpec, FuturesDirection, FuturesPosition}; +pub use futures::{ + FuturesAccountState, FuturesContractSpec, FuturesDirection, FuturesExecutionReport, + FuturesOrderIntent, FuturesPosition, FuturesPositionEffect, +}; 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 index cd92681..821ab85 100644 --- a/crates/fidc-core/tests/futures_account.rs +++ b/crates/fidc-core/tests/futures_account.rs @@ -1,6 +1,14 @@ use std::collections::BTreeMap; -use fidc_core::{FuturesAccountState, FuturesContractSpec, FuturesDirection}; +use chrono::NaiveDate; +use fidc_core::{ + FuturesAccountState, FuturesContractSpec, FuturesDirection, FuturesOrderIntent, + FuturesPositionEffect, OrderStatus, +}; + +fn d(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).expect("valid date") +} #[test] fn futures_account_tracks_long_margin_pnl_and_settlement() { @@ -50,3 +58,106 @@ fn futures_account_tracks_short_close_cash_delta() { assert_eq!(position.quantity, 3); assert!((position.equity() - 900.0).abs() < 1e-6); } + +#[test] +fn futures_order_execution_splits_close_between_old_and_today_quantity() { + let spec = FuturesContractSpec::new(300.0, 0.12, 0.12); + let mut account = FuturesAccountState::new(1_000_000.0); + + account.open("IF2501", FuturesDirection::Long, spec, 3, 4000.0, 0.0); + account.begin_trading_day(); + account.open("IF2501", FuturesDirection::Long, spec, 2, 4010.0, 0.0); + + let report = account.execute_order( + d(2025, 1, 2), + Some(10), + FuturesOrderIntent::close( + "IF2501", + FuturesDirection::Long, + FuturesPositionEffect::Close, + spec, + 4, + 4020.0, + 4.0, + "auto close", + ), + ); + + assert_eq!(report.order_events.len(), 1); + assert_eq!(report.order_events[0].status, OrderStatus::Filled); + assert_eq!(report.fill_events[0].quantity, 4); + assert!((report.fill_events[0].net_cash_flow - 19_196.0).abs() < 1e-6); + let position = account + .position("IF2501", FuturesDirection::Long) + .expect("remaining long position"); + assert_eq!(position.quantity, 1); + assert_eq!(position.old_quantity, 0); + assert_eq!(position.today_quantity(), 1); +} + +#[test] +fn futures_close_today_rejects_when_today_quantity_is_insufficient() { + let spec = FuturesContractSpec::new(10.0, 0.1, 0.1); + let mut account = FuturesAccountState::new(100_000.0); + + account.open("RB2501", FuturesDirection::Short, spec, 2, 3500.0, 0.0); + account.begin_trading_day(); + + let report = account.execute_order( + d(2025, 1, 3), + Some(11), + FuturesOrderIntent::close( + "RB2501", + FuturesDirection::Short, + FuturesPositionEffect::CloseToday, + spec, + 1, + 3490.0, + 1.0, + "close today without today position", + ), + ); + + assert_eq!(report.order_events.len(), 1); + assert_eq!(report.order_events[0].status, OrderStatus::Rejected); + assert!( + report.order_events[0] + .reason + .contains("close today quantity") + ); + let position = account + .position("RB2501", FuturesDirection::Short) + .expect("short position unchanged"); + assert_eq!(position.quantity, 2); + assert_eq!(position.old_quantity, 2); +} + +#[test] +fn futures_open_order_rejects_when_margin_is_insufficient() { + let spec = FuturesContractSpec::new(300.0, 0.2, 0.2); + let mut account = FuturesAccountState::new(10_000.0); + + let report = account.execute_order( + d(2025, 1, 6), + Some(12), + FuturesOrderIntent::open( + "IF2501", + FuturesDirection::Long, + spec, + 1, + 4000.0, + 2.0, + "oversized open", + ), + ); + + assert_eq!(report.order_events.len(), 1); + assert_eq!(report.order_events[0].status, OrderStatus::Rejected); + assert!( + report.order_events[0] + .reason + .contains("insufficient futures margin") + ); + assert!(account.position("IF2501", FuturesDirection::Long).is_none()); + assert!((account.total_cash() - 10_000.0).abs() < 1e-6); +} diff --git a/docs/rqalpha-gap-roadmap.md b/docs/rqalpha-gap-roadmap.md index 915f59f..cf0dbbb 100644 --- a/docs/rqalpha-gap-roadmap.md +++ b/docs/rqalpha-gap-roadmap.md @@ -87,9 +87,10 @@ current alignment pass. `account_by_type("STOCK")`) - [x] standalone futures account model with contract multiplier, long/short margin, daily mark-to-market settlement, and short close cashflow +- [x] standalone futures order execution model with open, close, close-today, + close-yesterday, margin rejection, order/fill/position/account events - [ ] wire futures account into the generic backtest engine runtime -- [ ] futures order intents, matching, close-today semantics, and expiration - settlement +- [ ] futures intraday matching integration and expiration settlement ## Execution Order @@ -108,5 +109,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, 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. +standalone futures account/order execution model; next gap is wiring futures +into the generic engine runtime and adding futures intraday/expiration +semantics.