实现类型化订单有效期合同
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user