use std::collections::BTreeMap; 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() { 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); } #[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); }