实现类型化订单有效期合同

This commit is contained in:
boris
2026-08-26 19:48:21 +08:00
parent bc0f5f6089
commit 8e238f9131
10 changed files with 1025 additions and 73 deletions
+3 -3
View File
@@ -2956,7 +2956,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
}
#[test]
fn engine_rejects_pending_limit_orders_at_market_close() {
fn engine_expires_pending_day_limit_orders_at_market_close() {
let date1 = d(2025, 1, 2);
let date2 = d(2025, 1, 3);
let data = DataSet::from_components(
@@ -3118,8 +3118,8 @@ fn engine_rejects_pending_limit_orders_at_market_close() {
);
assert!(result.order_events.iter().any(|event| {
event.date == date1
&& event.status == fidc_core::OrderStatus::Rejected
&& event.reason.contains("Market close")
&& event.status == fidc_core::OrderStatus::Expired
&& event.reason.contains("DAY order expired at market close")
}));
assert!(result.process_events.iter().any(|event| {
event.date == date1 && event.kind == ProcessEventKind::OrderUnsolicitedUpdate
+332 -6
View File
@@ -3,8 +3,8 @@ use fidc_core::{
AlgoOrderStyle, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel,
ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, DynamicSlippageConfig,
FidcRiskControlConfig, Instrument, IntradayExecutionQuote, MatchingType, OrderIntent,
OrderStatus, PortfolioState, PriceField, ProcessEventKind, SlippageModel, StrategyDecision,
TargetPortfolioOrderPricing,
OrderStatus, OrderTimeInForce, PortfolioState, PriceField, ProcessEventKind, SlippageModel,
StrategyDecision, TargetPortfolioOrderPricing,
};
use std::collections::{BTreeMap, BTreeSet};
@@ -4715,7 +4715,7 @@ fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet {
}
#[test]
fn broker_rejects_open_limit_buy_at_market_close() {
fn broker_expires_day_limit_buy_at_market_close() {
let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let day2 = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap();
let data = two_day_limit_order_data(10.0, 9.7);
@@ -4756,11 +4756,11 @@ fn broker_rejects_open_limit_buy_at_market_close() {
assert!(close_report.fill_events.is_empty());
assert_eq!(close_report.order_events.len(), 1);
assert_eq!(close_report.order_events[0].order_id, Some(order_id));
assert_eq!(close_report.order_events[0].status, OrderStatus::Rejected);
assert_eq!(close_report.order_events[0].status, OrderStatus::Expired);
assert!(
close_report.order_events[0]
.reason
.contains("Order Rejected: 000002.SZ can not match. Market close.")
.contains("DAY order expired at market close")
);
assert!(close_report.process_events.iter().any(|event| {
event.kind == ProcessEventKind::OrderUnsolicitedUpdate && event.order_id == Some(order_id)
@@ -4787,6 +4787,328 @@ fn broker_rejects_open_limit_buy_at_market_close() {
assert!(portfolio.position("000002.SZ").is_none());
}
#[test]
fn broker_ioc_limit_order_fills_available_quantity_and_cancels_remainder() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let data = two_day_limit_order_data(10.0, 10.0);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
.with_volume_limit(true)
.with_volume_percent(0.001)
.with_liquidity_limit(false);
let mut portfolio = PortfolioState::new(1_000_000.0);
let report = broker
.execute(
date,
&mut portfolio,
&data,
&StrategyDecision {
order_intents: vec![
OrderIntent::LimitShares {
symbol: "000002.SZ".to_string(),
quantity: 200,
limit_price: 10.1,
reason: "ioc_limit_buy".to_string(),
}
.with_time_in_force(OrderTimeInForce::Ioc),
],
..StrategyDecision::default()
},
)
.expect("IOC execution");
assert_eq!(report.fill_events.len(), 1);
assert_eq!(report.fill_events[0].quantity, 100);
assert_eq!(report.order_events.len(), 1);
assert_eq!(report.order_events[0].status, OrderStatus::Canceled);
assert_eq!(report.order_events[0].filled_quantity, 100);
assert!(broker.open_order_views().is_empty());
assert_eq!(portfolio.position("000002.SZ").unwrap().quantity, 100);
}
#[test]
fn broker_day_market_order_cancels_remainder_without_creating_invalid_open_order() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let data = two_day_limit_order_data(10.0, 10.0);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
.with_volume_limit(true)
.with_volume_percent(0.001)
.with_liquidity_limit(false);
let mut portfolio = PortfolioState::new(1_000_000.0);
let report = broker
.execute(
date,
&mut portfolio,
&data,
&StrategyDecision {
order_intents: vec![
OrderIntent::Shares {
symbol: "000002.SZ".to_string(),
quantity: 200,
reason: "day_market_buy".to_string(),
}
.with_time_in_force(OrderTimeInForce::Day),
],
..StrategyDecision::default()
},
)
.expect("DAY market execution");
assert_eq!(report.fill_events.len(), 1);
assert_eq!(report.fill_events[0].quantity, 100);
assert_eq!(report.order_events[0].status, OrderStatus::Canceled);
assert_eq!(report.order_events[0].filled_quantity, 100);
assert!(broker.open_order_views().is_empty());
}
#[test]
fn broker_fok_order_is_atomic_when_liquidity_is_insufficient() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let data = two_day_limit_order_data(10.0, 10.0);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
.with_volume_limit(true)
.with_volume_percent(0.001)
.with_liquidity_limit(false);
let initial_cash = 1_000_000.0;
let mut portfolio = PortfolioState::new(initial_cash);
let report = broker
.execute(
date,
&mut portfolio,
&data,
&StrategyDecision {
order_intents: vec![
OrderIntent::Shares {
symbol: "000002.SZ".to_string(),
quantity: 200,
reason: "fok_market_buy".to_string(),
}
.with_time_in_force(OrderTimeInForce::Fok),
],
..StrategyDecision::default()
},
)
.expect("FOK execution");
assert!(report.fill_events.is_empty());
assert_eq!(report.order_events.len(), 1);
assert_eq!(report.order_events[0].status, OrderStatus::Canceled);
assert_eq!(report.order_events[0].filled_quantity, 0);
assert!(
report.order_events[0]
.reason
.contains("FOK not fully fillable")
);
assert!(portfolio.position("000002.SZ").is_none());
assert!((portfolio.cash() - initial_cash).abs() < 1e-9);
assert!(broker.open_order_views().is_empty());
}
#[test]
fn broker_fok_order_fills_when_full_quantity_is_available() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let data = two_day_limit_order_data(10.0, 10.0);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
.with_volume_limit(false)
.with_liquidity_limit(false);
let mut portfolio = PortfolioState::new(1_000_000.0);
let report = broker
.execute(
date,
&mut portfolio,
&data,
&StrategyDecision {
order_intents: vec![
OrderIntent::Shares {
symbol: "000002.SZ".to_string(),
quantity: 200,
reason: "fok_market_buy".to_string(),
}
.with_time_in_force(OrderTimeInForce::Fok),
],
..StrategyDecision::default()
},
)
.expect("FOK execution");
assert_eq!(report.fill_events.len(), 1);
assert_eq!(report.fill_events[0].quantity, 200);
assert_eq!(report.order_events[0].status, OrderStatus::Filled);
assert_eq!(portfolio.position("000002.SZ").unwrap().quantity, 200);
}
#[test]
fn broker_gtc_limit_order_survives_close_and_fills_next_day() {
let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let day2 = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap();
let data = two_day_limit_order_data(10.0, 9.7);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
let mut portfolio = PortfolioState::new(1_000_000.0);
let day1_report = broker
.execute(
day1,
&mut portfolio,
&data,
&StrategyDecision {
order_intents: vec![
OrderIntent::LimitShares {
symbol: "000002.SZ".to_string(),
quantity: 200,
limit_price: 9.8,
reason: "gtc_limit_buy".to_string(),
}
.with_time_in_force(OrderTimeInForce::Gtc),
],
..StrategyDecision::default()
},
)
.expect("GTC day one execution");
assert_eq!(day1_report.order_events[0].status, OrderStatus::Pending);
assert_eq!(broker.open_order_views().len(), 1);
let close_report = broker.after_trading(day1);
assert!(close_report.order_events.is_empty());
assert_eq!(broker.open_order_views().len(), 1);
let day2_report = broker
.execute(day2, &mut portfolio, &data, &StrategyDecision::default())
.expect("GTC day two execution");
assert_eq!(day2_report.fill_events.len(), 1);
assert_eq!(day2_report.fill_events[0].quantity, 200);
assert_eq!(day2_report.order_events[0].status, OrderStatus::Filled);
assert!(broker.open_order_views().is_empty());
assert_eq!(portfolio.position("000002.SZ").unwrap().quantity, 200);
}
#[test]
fn broker_gtc_partial_fills_preserve_cumulative_order_and_commission_state() {
let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let day2 = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap();
let data = two_day_limit_order_data(10.0, 10.0);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
.with_volume_limit(true)
.with_volume_percent(0.001)
.with_liquidity_limit(false);
let mut portfolio = PortfolioState::new(1_000_000.0);
let day1_report = broker
.execute(
day1,
&mut portfolio,
&data,
&StrategyDecision {
order_intents: vec![
OrderIntent::LimitShares {
symbol: "000002.SZ".to_string(),
quantity: 200,
limit_price: 10.1,
reason: "gtc_partial_buy".to_string(),
}
.with_time_in_force(OrderTimeInForce::Gtc),
],
..StrategyDecision::default()
},
)
.expect("GTC first partial fill");
assert_eq!(day1_report.fill_events[0].quantity, 100);
assert_eq!(
day1_report.order_events[0].status,
OrderStatus::PartiallyFilled
);
let open_order = broker
.open_order_views()
.pop()
.expect("remaining GTC order");
assert_eq!(open_order.requested_quantity, 200);
assert_eq!(open_order.filled_quantity, 100);
assert_eq!(open_order.remaining_quantity, 100);
assert!(broker.after_trading(day1).order_events.is_empty());
let day2_report = broker
.execute(day2, &mut portfolio, &data, &StrategyDecision::default())
.expect("GTC final fill");
assert_eq!(day2_report.fill_events[0].quantity, 100);
assert_eq!(day2_report.order_events[0].requested_quantity, 200);
assert_eq!(day2_report.order_events[0].filled_quantity, 200);
assert_eq!(day2_report.order_events[0].status, OrderStatus::Filled);
assert!(broker.open_order_views().is_empty());
let total_commission = day1_report
.fill_events
.iter()
.chain(day2_report.fill_events.iter())
.map(|fill| fill.commission)
.sum::<f64>();
assert!((total_commission - 5.0).abs() < 1e-9, "{total_commission}");
assert_eq!(day2_report.fill_events[0].commission, 0.0);
}
#[test]
fn broker_rejects_gtc_for_market_order() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let data = two_day_limit_order_data(10.0, 10.0);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
let mut portfolio = PortfolioState::new(1_000_000.0);
let error = broker
.execute(
date,
&mut portfolio,
&data,
&StrategyDecision {
order_intents: vec![
OrderIntent::Shares {
symbol: "000002.SZ".to_string(),
quantity: 200,
reason: "invalid_gtc_market_buy".to_string(),
}
.with_time_in_force(OrderTimeInForce::Gtc),
],
..StrategyDecision::default()
},
)
.expect_err("market GTC must be rejected");
assert!(
error
.to_string()
.contains("time_in_force=gtc is not supported")
);
assert!(portfolio.position("000002.SZ").is_none());
}
#[test]
fn broker_uses_limit_price_slippage_for_limit_orders() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
@@ -5152,7 +5474,11 @@ fn broker_reserves_sellable_quantity_for_open_limit_sells() {
assert_eq!(report.order_events[0].status, OrderStatus::Pending);
assert_eq!(report.order_events[1].status, OrderStatus::Canceled);
assert_eq!(report.order_events[1].filled_quantity, 100);
assert!(report.order_events[1].reason.contains("remaining quantity canceled"));
assert!(
report.order_events[1]
.reason
.contains("remaining quantity canceled")
);
let open_orders = broker.open_order_views();
assert_eq!(open_orders.len(), 1);
assert_eq!(open_orders[0].reason, "reserve_sell");