211 lines
6.8 KiB
Rust
211 lines
6.8 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);
|
|
}
|