Files
fidc-backtest-engine/crates/fidc-core/tests/futures_account.rs
T

342 lines
12 KiB
Rust

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!(
report
.process_events
.iter()
.any(|event| event.kind == fidc_core::ProcessEventKind::Trade)
);
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
.process_events
.iter()
.any(|event| event.kind == fidc_core::ProcessEventKind::OrderCreationReject)
);
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);
}
#[test]
fn futures_expiration_settlement_closes_all_contract_directions() {
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, 0.0);
account.open("IF2501", FuturesDirection::Short, spec, 1, 4000.0, 0.0);
let report = account.expire_contract(d(2025, 1, 17), "IF2501", 4010.0, "contract expired");
assert_eq!(report.order_events.len(), 2);
assert_eq!(report.fill_events.len(), 2);
assert_eq!(
report
.process_events
.iter()
.filter(|event| event.kind == fidc_core::ProcessEventKind::Trade)
.count(),
2
);
assert!(
report
.order_events
.iter()
.all(|event| event.status == OrderStatus::Filled)
);
assert!(account.position("IF2501", FuturesDirection::Long).is_none());
assert!(
account
.position("IF2501", FuturesDirection::Short)
.is_none()
);
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);
}