fix: keep decision estimates independent and use timed capacity in fill fixtures

This commit is contained in:
boris
2026-09-12 06:46:49 +08:00
parent 053f880e34
commit 24cb4805a7
7 changed files with 89 additions and 53 deletions
@@ -107,6 +107,7 @@ fn action(quantity: &str, when: &str) -> PlatformTradeAction {
}
fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult {
let mut config = PlatformExprStrategyConfig::generic();
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
config.signal_symbol = "000001.SZ".into();
config.benchmark_symbol = "000300.SH".into();
config.rotation_enabled = false;
@@ -276,6 +277,7 @@ fn locked_holding_keeps_its_slot_even_when_cash_can_buy_the_next_candidate() {
)
.unwrap();
let mut config = PlatformExprStrategyConfig::generic();
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
config.signal_symbol = "000001.SZ".into();
config.benchmark_symbol = "000300.SH".into();
config.strategy_name = "protection_test".into();
+1
View File
@@ -4290,6 +4290,7 @@ fn platform_strategy_cannot_hide_missing_valuation_by_skipping_stop_take() {
)
.expect("dataset");
let mut config = PlatformExprStrategyConfig::microcap_rotation();
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
config.strategy_name = "missing-row-platform-risk".to_string();
config.benchmark_symbol = "000300.SH".to_string();
config.signal_symbol = "000001.SZ".to_string();
+53 -49
View File
@@ -202,6 +202,7 @@ fn execute_single_value_order(
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
.with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_strict_value_budget(true);
let report = broker
.execute(
@@ -392,7 +393,7 @@ fn broker_executes_explicit_order_value_buy() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let report = broker
.execute(
@@ -682,7 +683,7 @@ fn broker_executes_order_shares_and_order_lots() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let report = broker
.execute(
@@ -802,7 +803,7 @@ fn broker_executes_target_shares_like_order_to() {
let broker = BrokerSimulator::new(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
);
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let report = broker
.execute(
@@ -978,7 +979,7 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() {
let broker = BrokerSimulator::new(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
);
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let report = broker
.execute(
@@ -1252,7 +1253,7 @@ fn broker_executes_order_percent_and_target_percent() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut percent_portfolio = PortfolioState::new(1_000_000.0);
let percent_report = broker
@@ -1380,7 +1381,7 @@ fn broker_uses_day_open_price_for_open_auction_matching() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::DayOpen,
)
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_slippage_model(SlippageModel::PriceRatio(0.05));
let report = broker
@@ -1414,7 +1415,7 @@ fn broker_uses_day_open_price_for_open_auction_matching() {
}
#[test]
fn broker_open_auction_uses_auction_volume_without_quote_liquidity() {
fn broker_rejects_unproven_auction_capacity_in_a_daily_snapshot() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let data = DataSet::from_components(
vec![Instrument {
@@ -1511,11 +1512,10 @@ fn broker_open_auction_uses_auction_volume_without_quote_liquidity() {
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
.expect_err("a timestamped daily total is not proof of auction volume");
assert_eq!(report.fill_events.len(), 1);
assert_eq!(report.fill_events[0].quantity, 200);
assert_eq!(report.fill_events[0].price, 9.8);
assert!(report.to_string().contains("execution-time capacity is missing"));
assert_eq!(portfolio.cash(), 1_000_000.0);
}
#[test]
@@ -1710,7 +1710,7 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_slippage_model(SlippageModel::PriceRatio(0.01));
let report = broker
@@ -2337,7 +2337,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted
}
#[test]
fn broker_cancels_market_buy_when_minute_has_no_volume() {
fn broker_rejects_missing_execution_capacity_instead_of_declaring_suspension() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let data = DataSet::from_components(
vec![Instrument {
@@ -2433,15 +2433,10 @@ fn broker_cancels_market_buy_when_minute_has_no_volume() {
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
.expect_err("missing capacity is a contract error, not a normal no-volume cancellation");
assert_eq!(report.fill_events.len(), 0);
assert_eq!(report.order_events.len(), 1);
assert_eq!(
report.order_events[0].status,
fidc_core::OrderStatus::Canceled
);
assert!(report.order_events[0].reason.contains("daily no volume"));
assert!(report.to_string().contains("execution-time capacity is missing"));
assert_eq!(portfolio.cash(), 1_000_000.0);
}
#[test]
@@ -3679,7 +3674,7 @@ fn rebalance_uses_day_open_for_open_auction_valuation() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::DayOpen,
);
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let report = broker
.execute(
@@ -3864,7 +3859,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let report = broker
.execute(
@@ -4049,7 +4044,7 @@ fn rebalance_optimizer_does_not_scale_targets_above_requested_weight() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
broker
.execute(
@@ -4163,7 +4158,7 @@ fn broker_uses_board_specific_min_quantity_and_step_size_for_buy_sizing() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_risk_config(risk_config);
let report = broker
@@ -4269,7 +4264,7 @@ fn broker_allows_bjse_quantities_above_minimum_without_round_lot_step() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_risk_config(risk_config);
let report = broker
@@ -4377,7 +4372,7 @@ fn broker_allows_full_odd_lot_sell_when_liquidating_position() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let report = broker
.execute(
@@ -4511,7 +4506,7 @@ fn same_day_sell_then_rebuy_is_rejected_by_default() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
broker
.execute(
@@ -4655,7 +4650,7 @@ fn same_day_sell_then_rebuy_can_be_allowed_by_policy() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_risk_config(risk_config);
broker
@@ -4708,7 +4703,7 @@ fn broker_configured_policy_can_allow_upper_limit_buy() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_risk_config(risk_config);
let mut portfolio = PortfolioState::new(1_000_000.0);
@@ -4752,7 +4747,7 @@ fn broker_configured_policy_can_allow_lower_limit_sell() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap())
.with_risk_config(risk_config);
let mut portfolio = PortfolioState::new(1_000_000.0);
portfolio
@@ -4791,7 +4786,7 @@ fn broker_configured_policy_can_allow_lower_limit_sell() {
fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet {
let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let day2 = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap();
DataSet::from_components(
DataSet::from_components_with_actions_and_quotes(
vec![Instrument {
symbol: "000002.SZ".to_string(),
name: "Test".to_string(),
@@ -4919,6 +4914,13 @@ fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet {
volume: 1_000_000,
},
],
Vec::new(),
[(day1, day1_open), (day2, day2_open)].into_iter().map(|(date, price)| IntradayExecutionQuote {
date, symbol: "000002.SZ".into(), timestamp: date.and_hms_opt(9, 30, 0).unwrap(),
last_price: price, bid1: price, ask1: price, bid1_volume: 0, ask1_volume: 0,
volume_delta: 100_000, amount_delta: 100_000.0 * price,
trading_phase: Some("synthetic_observation_fixture".into()),
}).collect(),
)
.expect("dataset")
}
@@ -4932,7 +4934,7 @@ fn broker_expires_day_limit_buy_at_market_close() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap());
let mut portfolio = PortfolioState::new(1_000_000.0);
let day1_report = broker
@@ -5006,7 +5008,7 @@ fn broker_ioc_limit_order_fills_available_quantity_and_cancels_remainder() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap())
.with_volume_limit(true)
.with_volume_percent(0.001)
.with_liquidity_limit(false);
@@ -5042,7 +5044,7 @@ fn broker_ioc_limit_order_fills_available_quantity_and_cancels_remainder() {
}
#[test]
fn broker_persists_daily_volume_consumption_across_execute_calls() {
fn broker_persists_observed_volume_consumption_across_execute_calls() {
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);
@@ -5051,6 +5053,8 @@ fn broker_persists_daily_volume_consumption_across_execute_calls() {
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
.with_matching_type(MatchingType::MinuteLast)
.with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap())
.with_volume_limit(true)
.with_volume_percent(0.001)
.with_liquidity_limit(false);
@@ -5077,7 +5081,7 @@ fn broker_persists_daily_volume_consumption_across_execute_calls() {
assert_eq!(second.order_events.len(), 1);
assert_eq!(second.order_events[0].status, OrderStatus::Canceled);
assert_eq!(second.order_events[0].filled_quantity, 0);
assert!(second.order_events[0].reason.contains("daily volume limit"));
assert!(second.order_events[0].reason.contains("intraday quote liquidity exhausted"));
assert_eq!(portfolio.position("000002.SZ").unwrap().quantity, 100);
let next_day = broker
@@ -5294,7 +5298,7 @@ fn broker_day_market_order_cancels_remainder_without_creating_invalid_open_order
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap())
.with_volume_limit(true)
.with_volume_percent(0.001)
.with_liquidity_limit(false);
@@ -5334,7 +5338,7 @@ fn broker_fok_order_is_atomic_when_liquidity_is_insufficient() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap())
.with_volume_limit(true)
.with_volume_percent(0.001)
.with_liquidity_limit(false);
@@ -5421,7 +5425,7 @@ fn broker_gtc_limit_order_survives_close_and_fills_next_day() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap());
let mut portfolio = PortfolioState::new(1_000_000.0);
let day1_report = broker
@@ -5469,7 +5473,7 @@ fn broker_gtc_partial_fills_preserve_cumulative_order_and_commission_state() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap())
.with_volume_limit(true)
.with_volume_percent(0.001)
.with_liquidity_limit(false);
@@ -5536,7 +5540,7 @@ fn broker_modifies_gtc_limit_order_without_changing_order_identity() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap());
let mut portfolio = PortfolioState::new(1_000_000.0);
let created = broker
@@ -5621,7 +5625,7 @@ fn broker_modifies_partially_filled_gtc_total_and_preserves_commission_state() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap())
.with_volume_limit(true)
.with_volume_percent(0.001)
.with_liquidity_limit(false);
@@ -5699,7 +5703,7 @@ fn broker_rejected_modify_has_zero_side_effects() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap())
.with_volume_limit(true)
.with_volume_percent(0.001)
.with_liquidity_limit(false);
@@ -5767,7 +5771,7 @@ fn broker_accepted_modify_resets_queue_priority_but_reduction_preserves_it() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap());
let mut portfolio = PortfolioState::new(1_000_000.0);
let create = |reason: &str| StrategyDecision {
order_intents: vec![
@@ -5899,7 +5903,7 @@ fn broker_uses_limit_price_slippage_for_limit_orders() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap())
.with_slippage_model(SlippageModel::LimitPrice);
let mut portfolio = PortfolioState::new(1_000_000.0);
@@ -5938,7 +5942,7 @@ fn broker_rejects_limit_buy_when_final_execution_price_reaches_upper_limit() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap())
.with_slippage_model(SlippageModel::LimitPrice);
let mut portfolio = PortfolioState::new(1_000_000.0);
@@ -5984,7 +5988,7 @@ fn broker_executes_limit_value_and_limit_percent_intents() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap());
let mut value_portfolio = PortfolioState::new(1_000_000.0);
let value_report = broker
@@ -6047,7 +6051,7 @@ fn broker_cancels_open_order_by_order_id() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap());
let mut portfolio = PortfolioState::new(1_000_000.0);
let day1_report = broker
@@ -6225,7 +6229,7 @@ fn broker_reserves_sellable_quantity_for_open_limit_sells() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut portfolio = PortfolioState::new(1_000_000.0);
portfolio
.position_mut("000002.SZ")
@@ -142,7 +142,13 @@ fn data_with_fund_rules(
})
})
.collect();
DataSet::from_components(instruments, market, factors, candidates, benchmarks).unwrap()
let quotes = market.iter().filter(|row| row.symbol != "000300.SH").map(|row| fidc_core::IntradayExecutionQuote {
date: row.date, symbol: row.symbol.clone(), timestamp: row.date.and_hms_opt(9, 30, 0).unwrap(),
last_price: row.open, bid1: row.open, ask1: row.open, bid1_volume: 0, ask1_volume: 0,
volume_delta: row.volume, amount_delta: row.open * row.volume as f64,
trading_phase: Some("synthetic_observation_fixture".into()),
}).collect();
DataSet::from_components_with_actions_and_quotes(instruments, market, factors, candidates, benchmarks, vec![], quotes).unwrap()
}
fn broker(volume: bool) -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks> {
let mut risk = FidcRiskControlConfig::default();
@@ -159,6 +165,7 @@ fn broker(volume: bool) -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRule
ChinaEquityRuleHooks,
)
.with_matching_type(MatchingType::NextBarOpen)
.with_intraday_execution_start_time(chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap())
.with_risk_config(risk)
}
fn contract(signal: NaiveDate, target: usize, preserve: bool) -> FrozenStockPoolIntent {
@@ -268,6 +275,7 @@ fn mixed_fund_and_stock_round_trip_uses_declared_ticks_and_asset_specific_fees()
let mut costs = ChinaAShareCostModel::default();
costs.set_transfer_fee_rate(0.00001);
let broker = BrokerSimulator::new(costs, ChinaEquityRuleHooks)
.with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::NextBarOpen);
let mut account = PortfolioState::new(30_000.);
let mut entry = contract(day(2), 1, false);