diff --git a/crates/fidc-core/src/futures.rs b/crates/fidc-core/src/futures.rs index 5a85f68..de5c858 100644 --- a/crates/fidc-core/src/futures.rs +++ b/crates/fidc-core/src/futures.rs @@ -7,6 +7,24 @@ use crate::events::{ AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, ProcessEventKind, }; +use crate::fixed_point::FixedMoney; + +fn futures_money(value: f64, label: &str) -> Result { + FixedMoney::from_f64(value) + .ok_or_else(|| format!("{label} is not representable as fixed-point money: {value}")) +} + +fn futures_money_or_panic(value: f64, label: &str) -> FixedMoney { + futures_money(value, label).unwrap_or_else(|error| panic!("{error}")) +} + +fn sum_futures_money(values: impl IntoIterator, label: &str) -> FixedMoney { + values.into_iter().fold(FixedMoney::ZERO, |total, value| { + total + .checked_add(value) + .unwrap_or_else(|| panic!("fixed-point {label} overflow")) + }) +} #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum FuturesDirection { @@ -366,15 +384,16 @@ pub struct FuturesPosition { pub symbol: String, pub direction: FuturesDirection, pub old_quantity: u32, + day_start_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, + transaction_cost: FixedMoney, trade_quantity_delta: i32, - trade_cost: f64, + trade_value: FixedMoney, } impl FuturesPosition { @@ -390,15 +409,16 @@ impl FuturesPosition { symbol: symbol.into(), direction, old_quantity: init_quantity, + day_start_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, + transaction_cost: FixedMoney::ZERO, trade_quantity_delta: 0, - trade_cost: 0.0, + trade_value: FixedMoney::ZERO, } } @@ -407,18 +427,39 @@ impl FuturesPosition { } pub fn market_value(&self) -> f64 { - self.quantity as f64 * self.last_price * self.contract_multiplier + self.market_value_money().to_f64() + } + + fn market_value_money(&self) -> FixedMoney { + futures_money_or_panic( + self.quantity as f64 * self.last_price * self.contract_multiplier, + "futures position market value", + ) } pub fn margin(&self) -> f64 { - self.market_value() * self.margin_rate + self.margin_money().to_f64() + } + + fn margin_money(&self) -> FixedMoney { + futures_money_or_panic( + self.market_value_money().to_f64() * self.margin_rate, + "futures position margin", + ) } pub fn equity(&self) -> f64 { - (self.last_price - self.avg_price) - * self.quantity as f64 - * self.contract_multiplier - * self.direction.factor() + self.equity_money().to_f64() + } + + fn equity_money(&self) -> FixedMoney { + futures_money_or_panic( + (self.last_price - self.avg_price) + * self.quantity as f64 + * self.contract_multiplier + * self.direction.factor(), + "futures position equity", + ) } pub fn pnl(&self) -> f64 { @@ -426,22 +467,47 @@ impl FuturesPosition { } pub fn trading_pnl(&self) -> f64 { - (self.trade_quantity_delta as f64 * self.last_price - self.trade_cost) - * self.contract_multiplier - * self.direction.factor() + self.trading_pnl_money().to_f64() + } + + fn trading_pnl_money(&self) -> FixedMoney { + let marked_trade_value = futures_money_or_panic( + self.trade_quantity_delta as f64 * self.last_price * self.contract_multiplier, + "futures marked trade value", + ); + let pnl = marked_trade_value + .checked_sub(self.trade_value) + .expect("fixed-point futures trading PnL overflow"); + if self.direction == FuturesDirection::Short { + pnl.checked_neg() + .expect("fixed-point futures short trading PnL overflow") + } else { + pnl + } } pub fn position_pnl(&self) -> f64 { - if self.old_quantity == 0 { - 0.0 + self.position_pnl_money().to_f64() + } + + fn position_pnl_money(&self) -> FixedMoney { + if self.day_start_quantity == 0 { + FixedMoney::ZERO } else { - self.old_quantity as f64 - * (self.last_price - self.prev_close) - * self.contract_multiplier - * self.direction.factor() + futures_money_or_panic( + self.day_start_quantity as f64 + * (self.last_price - self.prev_close) + * self.contract_multiplier + * self.direction.factor(), + "futures position daily PnL", + ) } } + pub fn transaction_cost(&self) -> f64 { + self.transaction_cost.to_f64() + } + pub fn open(&mut self, quantity: u32, price: f64, transaction_cost: f64) { if quantity == 0 { return; @@ -450,9 +516,20 @@ impl FuturesPosition { 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); + let transaction_cost = + futures_money_or_panic(transaction_cost.max(0.0), "futures open transaction cost"); + self.transaction_cost = self + .transaction_cost + .checked_add(transaction_cost) + .expect("fixed-point futures transaction cost overflow"); self.trade_quantity_delta += quantity as i32; - self.trade_cost += price * quantity as f64; + self.trade_value = self + .trade_value + .checked_add(futures_money_or_panic( + price * quantity as f64 * self.contract_multiplier, + "futures open trade value", + )) + .expect("fixed-point futures trade value overflow"); } pub fn close( @@ -476,6 +553,17 @@ impl FuturesPosition { transaction_cost: f64, effect: FuturesPositionEffect, ) -> Result { + self.close_with_effect_money(quantity, price, transaction_cost, effect) + .map(FixedMoney::to_f64) + } + + fn close_with_effect_money( + &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()); } @@ -489,7 +577,7 @@ impl FuturesPosition { )); } if quantity == 0 { - return Ok(0.0); + return Ok(FixedMoney::ZERO); } match effect { FuturesPositionEffect::Open => unreachable!(), @@ -523,19 +611,34 @@ impl FuturesPosition { } } - let realized = (price - self.avg_price) - * quantity as f64 - * self.contract_multiplier - * self.direction.factor() - - transaction_cost.max(0.0); + let transaction_cost = + futures_money(transaction_cost.max(0.0), "futures close transaction cost")?; + let realized = futures_money( + (price - self.avg_price) + * quantity as f64 + * self.contract_multiplier + * self.direction.factor(), + "futures realized PnL", + )? + .checked_sub(transaction_cost) + .ok_or_else(|| "fixed-point futures realized PnL overflow".to_string())?; 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.transaction_cost = self + .transaction_cost + .checked_add(transaction_cost) + .ok_or_else(|| "fixed-point futures transaction cost overflow".to_string())?; self.trade_quantity_delta -= quantity as i32; - self.trade_cost -= price * quantity as f64; + self.trade_value = self + .trade_value + .checked_sub(futures_money( + price * quantity as f64 * self.contract_multiplier, + "futures close trade value", + )?) + .ok_or_else(|| "fixed-point futures trade value overflow".to_string())?; Ok(realized) } @@ -547,98 +650,163 @@ impl FuturesPosition { pub fn begin_trading_day(&mut self) { self.old_quantity = self.quantity; + self.day_start_quantity = self.quantity; self.prev_close = self.last_price; - self.transaction_cost = 0.0; + self.transaction_cost = FixedMoney::ZERO; self.trade_quantity_delta = 0; - self.trade_cost = 0.0; + self.trade_value = FixedMoney::ZERO; } pub fn settlement(&mut self, settlement_price: f64) -> f64 { + self.settlement_money(settlement_price).to_f64() + } + + fn settlement_money(&mut self, settlement_price: f64) -> FixedMoney { self.mark_price(settlement_price); - let cash_delta = self.equity(); + let cash_delta = self.equity_money(); self.avg_price = self.last_price; - self.prev_close = self.last_price; - self.old_quantity = self.quantity; cash_delta } } #[derive(Debug, Clone)] pub struct FuturesAccountState { - starting_cash: f64, - total_cash: f64, - frozen_cash: f64, + starting_cash: FixedMoney, + total_cash: FixedMoney, + frozen_cash: FixedMoney, + closed_day_trading_pnl: FixedMoney, + closed_day_position_pnl: FixedMoney, + closed_day_transaction_cost: FixedMoney, positions: BTreeMap<(String, FuturesDirection), FuturesPosition>, } impl FuturesAccountState { pub fn new(total_cash: f64) -> Self { + let total_cash = futures_money_or_panic(total_cash, "futures starting cash"); Self { starting_cash: total_cash, total_cash, - frozen_cash: 0.0, + frozen_cash: FixedMoney::ZERO, + closed_day_trading_pnl: FixedMoney::ZERO, + closed_day_position_pnl: FixedMoney::ZERO, + closed_day_transaction_cost: FixedMoney::ZERO, positions: BTreeMap::new(), } } pub fn starting_cash(&self) -> f64 { - self.starting_cash + self.starting_cash.to_f64() } pub fn total_cash(&self) -> f64 { - self.total_cash + self.total_cash.to_f64() } pub fn frozen_cash(&self) -> f64 { - self.frozen_cash + self.frozen_cash.to_f64() } pub fn cash(&self) -> f64 { - self.total_cash - self.margin() - self.frozen_cash + self.cash_money().to_f64() + } + + fn cash_money(&self) -> FixedMoney { + self.total_cash + .checked_sub(self.margin_money()) + .and_then(|cash| cash.checked_sub(self.frozen_cash)) + .expect("fixed-point futures available cash overflow") } pub fn margin(&self) -> f64 { - self.positions.values().map(FuturesPosition::margin).sum() + self.margin_money().to_f64() + } + + fn margin_money(&self) -> FixedMoney { + sum_futures_money( + self.positions.values().map(FuturesPosition::margin_money), + "futures account margin", + ) } pub fn market_value(&self) -> f64 { - self.positions - .values() - .map(FuturesPosition::market_value) - .sum() + sum_futures_money( + self.positions + .values() + .map(FuturesPosition::market_value_money), + "futures account market value", + ) + .to_f64() } pub fn position_equity(&self) -> f64 { - self.positions.values().map(FuturesPosition::equity).sum() + self.position_equity_money().to_f64() + } + + fn position_equity_money(&self) -> FixedMoney { + sum_futures_money( + self.positions.values().map(FuturesPosition::equity_money), + "futures account position equity", + ) } pub fn total_value(&self) -> f64 { - self.total_cash + self.position_equity() + self.total_cash + .checked_add(self.position_equity_money()) + .expect("fixed-point futures total value overflow") + .to_f64() } pub fn daily_pnl(&self) -> f64 { - self.trading_pnl() + self.position_pnl() - self.transaction_cost() + self.trading_pnl_money() + .checked_add(self.position_pnl_money()) + .and_then(|pnl| pnl.checked_sub(self.transaction_cost_money())) + .expect("fixed-point futures daily PnL overflow") + .to_f64() } pub fn trading_pnl(&self) -> f64 { - self.positions - .values() - .map(FuturesPosition::trading_pnl) - .sum() + self.trading_pnl_money().to_f64() + } + + fn trading_pnl_money(&self) -> FixedMoney { + sum_futures_money( + std::iter::once(self.closed_day_trading_pnl).chain( + self.positions + .values() + .map(FuturesPosition::trading_pnl_money), + ), + "futures account trading PnL", + ) } pub fn position_pnl(&self) -> f64 { - self.positions - .values() - .map(FuturesPosition::position_pnl) - .sum() + self.position_pnl_money().to_f64() + } + + fn position_pnl_money(&self) -> FixedMoney { + sum_futures_money( + std::iter::once(self.closed_day_position_pnl).chain( + self.positions + .values() + .map(FuturesPosition::position_pnl_money), + ), + "futures account position PnL", + ) } pub fn transaction_cost(&self) -> f64 { - self.positions - .values() - .map(|position| position.transaction_cost) - .sum() + self.transaction_cost_money().to_f64() + } + + fn transaction_cost_money(&self) -> FixedMoney { + sum_futures_money( + std::iter::once(self.closed_day_transaction_cost).chain( + self.positions + .values() + .map(|position| position.transaction_cost), + ), + "futures account transaction cost", + ) } pub fn positions(&self) -> &BTreeMap<(String, FuturesDirection), FuturesPosition> { @@ -667,7 +835,13 @@ impl FuturesAccountState { .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); + self.total_cash = self + .total_cash + .checked_sub(futures_money_or_panic( + transaction_cost.max(0.0), + "futures open transaction cost", + )) + .expect("fixed-point futures cash overflow"); } pub fn close( @@ -702,12 +876,30 @@ impl FuturesAccountState { .positions .get_mut(&key) .ok_or_else(|| format!("missing futures position {symbol} {}", direction.as_str()))?; - let cash_delta = position.close_with_effect(quantity, price, transaction_cost, effect)?; - self.total_cash += cash_delta; + let cash_delta = + position.close_with_effect_money(quantity, price, transaction_cost, effect)?; + self.total_cash = self + .total_cash + .checked_add(cash_delta) + .ok_or_else(|| "fixed-point futures cash overflow".to_string())?; if position.quantity == 0 { + self.closed_day_trading_pnl = self + .closed_day_trading_pnl + .checked_add(position.trading_pnl_money()) + .ok_or_else(|| "fixed-point closed futures trading PnL overflow".to_string())?; + self.closed_day_position_pnl = self + .closed_day_position_pnl + .checked_add(position.position_pnl_money()) + .ok_or_else(|| "fixed-point closed futures position PnL overflow".to_string())?; + self.closed_day_transaction_cost = self + .closed_day_transaction_cost + .checked_add(position.transaction_cost) + .ok_or_else(|| { + "fixed-point closed futures transaction cost overflow".to_string() + })?; self.positions.remove(&key); } - Ok(cash_delta) + Ok(cash_delta.to_f64()) } pub fn execute_order( @@ -782,7 +974,7 @@ impl FuturesAccountState { intent.price, intent.transaction_cost, ); - if projected.cash() < -1e-8 { + if projected.cash_money().raw() < 0 { Err(format!( "insufficient futures margin available_cash={:.2} required_margin_after={:.2}", self.cash(), @@ -797,7 +989,13 @@ impl FuturesAccountState { intent.price, intent.transaction_cost, ); - Ok(-intent.transaction_cost.max(0.0)) + Ok(futures_money_or_panic( + intent.transaction_cost.max(0.0), + "futures open transaction cost", + ) + .checked_neg() + .expect("fixed-point futures open cash delta overflow") + .to_f64()) } } FuturesPositionEffect::Close @@ -822,8 +1020,16 @@ impl FuturesAccountState { .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; + let notional = futures_money_or_panic( + intent.price * intent.quantity as f64 * intent.spec.contract_multiplier, + "futures fill notional", + ) + .to_f64(); + let transaction_cost = futures_money_or_panic( + intent.transaction_cost.max(0.0), + "futures fill transaction cost", + ) + .to_f64(); report.fill_events.push(FillEvent { date, decision_date: None, @@ -835,7 +1041,7 @@ impl FuturesAccountState { quantity: intent.quantity, price: intent.price, gross_amount: notional, - commission: intent.transaction_cost.max(0.0), + commission: transaction_cost, stamp_tax: 0.0, transfer_fee: 0.0, net_cash_flow: cash_delta, @@ -1010,22 +1216,30 @@ impl FuturesAccountState { } pub fn begin_trading_day(&mut self) { + self.closed_day_trading_pnl = FixedMoney::ZERO; + self.closed_day_position_pnl = FixedMoney::ZERO; + self.closed_day_transaction_cost = FixedMoney::ZERO; 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; + let mut cash_delta = FixedMoney::ZERO; 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); + cash_delta = cash_delta + .checked_add(position.settlement_money(price)) + .expect("fixed-point futures settlement overflow"); } - self.total_cash += cash_delta; - cash_delta + self.total_cash = self + .total_cash + .checked_add(cash_delta) + .expect("fixed-point futures cash settlement overflow"); + cash_delta.to_f64() } } diff --git a/crates/fidc-core/tests/futures_account.rs b/crates/fidc-core/tests/futures_account.rs index a2a28c6..03ef8c7 100644 --- a/crates/fidc-core/tests/futures_account.rs +++ b/crates/fidc-core/tests/futures_account.rs @@ -208,3 +208,134 @@ fn futures_expiration_settlement_closes_all_contract_directions() { ); assert!((account.total_cash() - 1_003_000.0).abs() < 1e-6); } + +#[test] +fn futures_full_close_preserves_closed_position_daily_metrics() { + let spec = FuturesContractSpec::new(10.0, 0.1, 0.1); + let mut account = FuturesAccountState::new(100_000.0); + account.open("IF2506.CCFX", FuturesDirection::Long, spec, 1, 100.0, 1.0); + account.begin_trading_day(); + + let realized = account + .close("IF2506.CCFX", FuturesDirection::Long, 1, 110.0, 2.0) + .expect("close overnight position"); + + assert!(account.positions().is_empty()); + assert!((realized - 98.0).abs() < 1e-12); + assert!((account.position_pnl() - 100.0).abs() < 1e-12); + assert!(account.trading_pnl().abs() < 1e-12); + assert!((account.transaction_cost() - 2.0).abs() < 1e-12); + assert!((account.daily_pnl() - 98.0).abs() < 1e-12); + assert!((account.total_cash() - 100_097.0).abs() < 1e-12); + + account.begin_trading_day(); + assert!(account.daily_pnl().abs() < 1e-12); + assert!(account.transaction_cost().abs() < 1e-12); +} + +#[test] +fn futures_intraday_roundtrip_preserves_closed_trading_pnl() { + let spec = FuturesContractSpec::new(10.0, 0.1, 0.1); + let mut account = FuturesAccountState::new(100_000.0); + account.begin_trading_day(); + account.open("IF2506.CCFX", FuturesDirection::Long, spec, 1, 100.0, 1.0); + account + .close("IF2506.CCFX", FuturesDirection::Long, 1, 110.0, 2.0) + .expect("close intraday position"); + + assert!(account.positions().is_empty()); + assert!((account.trading_pnl() - 100.0).abs() < 1e-12); + assert!(account.position_pnl().abs() < 1e-12); + assert!((account.transaction_cost() - 3.0).abs() < 1e-12); + assert!((account.daily_pnl() - 97.0).abs() < 1e-12); + assert!((account.total_cash() - 100_097.0).abs() < 1e-12); +} + +#[test] +fn futures_partial_close_offsets_later_mark_with_trading_pnl() { + let spec = FuturesContractSpec::new(10.0, 0.1, 0.1); + let mut account = FuturesAccountState::new(100_000.0); + account.open("IF2506.CCFX", FuturesDirection::Long, spec, 2, 100.0, 0.0); + account.begin_trading_day(); + account + .close("IF2506.CCFX", FuturesDirection::Long, 1, 110.0, 0.0) + .expect("partially close overnight position"); + account.mark_price("IF2506.CCFX", FuturesDirection::Long, 120.0); + + assert!((account.position_pnl() - 400.0).abs() < 1e-12); + assert!((account.trading_pnl() + 100.0).abs() < 1e-12); + assert!((account.daily_pnl() - 300.0).abs() < 1e-12); + assert!((account.total_value() - 100_300.0).abs() < 1e-12); +} + +#[test] +fn futures_settlement_keeps_same_day_pnl_visible_until_next_day() { + let spec = FuturesContractSpec::new(10.0, 0.1, 0.1); + let mut account = FuturesAccountState::new(100_000.0); + account.open("IF2506.CCFX", FuturesDirection::Long, spec, 1, 100.0, 0.0); + account.begin_trading_day(); + account.mark_price("IF2506.CCFX", FuturesDirection::Long, 110.0); + + let settled = account.settle(&BTreeMap::from([("IF2506.CCFX".to_string(), 110.0)])); + + assert!((settled - 100.0).abs() < 1e-12); + assert!((account.daily_pnl() - 100.0).abs() < 1e-12); + assert!((account.total_cash() - 100_100.0).abs() < 1e-12); + assert!((account.total_value() - 100_100.0).abs() < 1e-12); + + account.begin_trading_day(); + assert!(account.daily_pnl().abs() < 1e-12); +} + +#[test] +fn futures_cash_and_closed_cost_accumulate_micro_yuan_exactly() { + let spec = FuturesContractSpec::new(1.0, 0.0, 0.0); + let mut account = FuturesAccountState::new(1_000_000.0); + account.begin_trading_day(); + for _ in 0..10_000 { + account.open( + "IF2506.CCFX", + FuturesDirection::Long, + spec, + 1, + 100.0, + 0.000001, + ); + account + .close("IF2506.CCFX", FuturesDirection::Long, 1, 100.0, 0.000001) + .expect("close micro-cost position"); + } + + assert!((account.total_cash() - 999_999.98).abs() < 1e-12); + assert!((account.transaction_cost() - 0.02).abs() < 1e-12); + assert!((account.daily_pnl() + 0.02).abs() < 1e-12); +} + +#[test] +fn futures_margin_gate_and_fill_cash_use_exact_micro_yuan() { + let date = d(2025, 1, 2); + let spec = FuturesContractSpec::new(1.0, 1.0, 1.0); + let intent = FuturesOrderIntent::open( + "IF2506.CCFX", + FuturesDirection::Long, + spec, + 1, + 100.0, + 0.000001, + "micro margin boundary", + ); + + let mut insufficient = FuturesAccountState::new(100.0); + let rejected = insufficient.execute_order(date, Some(1), intent.clone()); + assert_eq!(rejected.order_events[0].status, OrderStatus::Rejected); + assert!((insufficient.total_cash() - 100.0).abs() < 1e-12); + + let mut exact = FuturesAccountState::new(100.000001); + let filled = exact.execute_order(date, Some(2), intent); + assert_eq!(filled.order_events[0].status, OrderStatus::Filled); + assert_eq!(filled.fill_events.len(), 1); + assert!((filled.fill_events[0].gross_amount - 100.0).abs() < 1e-12); + assert!((filled.fill_events[0].commission - 0.000001).abs() < 1e-12); + assert!((filled.fill_events[0].net_cash_flow + 0.000001).abs() < 1e-12); + assert!(exact.cash().abs() < 1e-12); +}