fix(stock-pool): execute verified ETF daily fallbacks with frozen next-open targets

This commit is contained in:
boris
2026-09-12 10:47:47 +08:00
parent d646ca455d
commit 099759ae67
9 changed files with 650 additions and 15 deletions
@@ -638,3 +638,198 @@ fn next_day_outside_policy_executes_after_the_first_exclusion_signal() {
);
assert_eq!(account.position(&code(2)).unwrap().quantity, 3000);
}
fn etf_fallback_fixture(time: chrono::NaiveTime) -> DataSet {
let mut parts = data_with_fund_rules(1_000_000, None, true).snapshot_components();
let previous = NaiveDate::from_ymd_opt(2025,12,31).unwrap();
for instrument in &mut parts.instruments { instrument.listed_at = Some(NaiveDate::from_ymd_opt(2025,12,1).unwrap()); }
let mut past_market = parts.market.iter().filter(|row| row.date == day(2)).cloned().collect::<Vec<_>>();
for row in &mut past_market { row.date=previous; if row.symbol == code(2) { row.close=5.; row.open=5.; row.high=5.; row.low=5.; row.last_price=5.; } }
parts.market.extend(past_market);
let mut past_factors=parts.factors.iter().filter(|row|row.date==day(2)).cloned().collect::<Vec<_>>();
for row in &mut past_factors {row.date=previous;}
parts.factors.extend(past_factors);
let mut past_candidates=parts.candidates.iter().filter(|row|row.date==day(2)).cloned().collect::<Vec<_>>();
for row in &mut past_candidates {row.date=previous;}
parts.candidates.extend(past_candidates);
for factor in &mut parts.factors { if factor.symbol==code(2) {factor.market_cap_bn=f64::NAN;factor.free_float_cap_bn=f64::NAN;} }
let mut past_benchmark = parts.benchmarks[0].clone(); past_benchmark.date=previous; parts.benchmarks.push(past_benchmark);
for row in &mut parts.market {
if row.symbol==code(2) && row.date>=day(2) {
row.open=if row.date==day(2) {10.} else {4.}; row.day_open=row.open;
row.close=40.; row.last_price=40.; row.high=40.; row.low=row.open; row.prev_close=5.;
}
}
parts.execution_quotes.retain(|row| row.symbol==code(1));
for quote in &mut parts.execution_quotes { quote.timestamp=quote.date.and_time(time); }
DataSet::from_components_with_actions_and_quotes(parts.instruments,parts.market,parts.factors,parts.candidates,parts.benchmarks,parts.corporate_actions,parts.execution_quotes).unwrap()
}
struct EtfPoolSignal { at:chrono::NaiveTime, condition:String }
impl fidc_core::strategy::Strategy for EtfPoolSignal {
fn name(&self)->&str {"ETF fallback fixture"}
fn requires_minute_callbacks(&self)->bool {false}
fn decision_quote_times(&self)->Vec<chrono::NaiveTime> {vec![self.at]}
fn decision_quote_symbols(&mut self,_:&fidc_core::strategy::StrategyContext<'_>)->Result<BTreeSet<String>,fidc_core::BacktestError> {Ok(BTreeSet::from([code(1),code(2)]))}
fn on_day(&mut self,ctx:&fidc_core::strategy::StrategyContext<'_>)->Result<StrategyDecision,fidc_core::BacktestError> {
if ctx.execution_date!=day(2) {return Ok(StrategyDecision::default());}
let mut intent=contract(day(2),1,true);
intent.selection.final_symbols=vec![code(1),code(2)];
intent.constraints.target_holding_count=Some(2);
intent.rule.buy_condition=self.condition.clone();
Ok(decision(intent))
}
}
fn run_etf_fallback(time:chrono::NaiveTime,end:NaiveDate,enabled:bool,condition:&str,loader_fails:bool,volume_limit:bool)->Result<fidc_core::BacktestResult,fidc_core::BacktestError> {
let broker=broker(volume_limit).with_matching_type(MatchingType::MinuteLast)
.with_execution_price_field(PriceField::Last).with_intraday_execution_start_time(time)
.with_historical_etf_open_fallback(enabled);
BacktestEngine::new(etf_fallback_fixture(time),EtfPoolSignal{at:time,condition:condition.into()},broker,BacktestConfig{
initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(end),decision_lag_trading_days:0,execution_price_field:PriceField::Last,
}).with_execution_quote_loader(Box::new(move |_| {
if loader_fails {Err(fidc_core::BacktestError::Execution("fixture_source_unavailable".into()))} else {Ok(vec![])}
})).run()
}
#[test]
fn historical_etf_open_uses_real_open_without_creating_minute_bars() {
let result=run_etf_fallback(chrono::NaiveTime::from_hms_opt(9,30,0).unwrap(),day(6),true,"",false,false).unwrap();
let etf=result.fills.iter().filter(|fill| fill.symbol==code(2)).collect::<Vec<_>>();
assert_eq!(etf.len(),1,"{:?}",result.fills);
assert_eq!((etf[0].date,etf[0].price,etf[0].quantity),(day(2),10.,1500));
assert_eq!(etf[0].execution_timestamp,Some(day(2).and_hms_opt(9,30,0).unwrap()));
assert!(etf[0].reason.contains("etf_daily_open_fallback"));
assert!(result.fills.iter().any(|fill|fill.symbol==code(1)&&fill.date==day(2)));
}
#[test]
fn historical_etf_late_signal_freezes_money_and_requantifies_at_next_official_open() {
let result=run_etf_fallback(chrono::NaiveTime::from_hms_opt(13,0,0).unwrap(),day(6),true,"",false,false).unwrap();
let etf=result.fills.iter().filter(|fill| fill.symbol==code(2)).collect::<Vec<_>>();
assert_eq!(etf.len(),1,"{:?}",result.fills);
assert_eq!((etf[0].date,etf[0].price,etf[0].quantity),(day(5),4.,3700));
assert_eq!(etf[0].execution_timestamp,Some(day(5).and_hms_opt(9,30,0).unwrap()));
assert_eq!(etf[0].order_created_date,Some(day(2)));
assert!(etf[0].reason.contains("2026-01-02 13:00:00"));
assert!(result.fills.iter().any(|fill|fill.symbol==code(1)&&fill.date==day(2)));
assert!(result.terminal_audit.is_clean());
}
#[test]
fn historical_etf_pending_target_at_end_is_not_a_fake_order_or_fill() {
let result=run_etf_fallback(chrono::NaiveTime::from_hms_opt(13,0,0).unwrap(),day(2),true,"",false,false).unwrap();
assert_eq!(result.terminal_audit.deferred_etf_target_count,1);
assert_eq!(result.terminal_audit.status,fidc_core::BacktestTerminalStatus::CompletedWithPendingState);
assert!(result.order_events.iter().all(|order|order.symbol!=code(2)));
assert!(result.fills.iter().all(|fill|fill.symbol!=code(2)));
}
#[test]
fn historical_etf_fallback_does_not_waive_source_conditions_or_capacity() {
let at=chrono::NaiveTime::from_hms_opt(9,30,0).unwrap();
assert!(run_etf_fallback(at,day(6),false,"",false,false).is_err());
assert!(run_etf_fallback(at,day(6),true,"last > 1",false,false).unwrap_err().to_string().contains("condition evidence unavailable"));
assert!(run_etf_fallback(at,day(6),true,"",true,false).unwrap_err().to_string().contains("fixture_source_unavailable"));
assert!(run_etf_fallback(at,day(6),true,"",false,true).unwrap_err().to_string().contains("capacity is missing"));
}
#[test]
fn compiled_pool_price_screen_does_not_require_unconfigured_etf_market_cap() {
let time=chrono::NaiveTime::from_hms_opt(9,30,0).unwrap();
let intent=contract(day(2),1,true);
let program=StockPoolProgram {
schema_version:1,pool_id:"typed-mixed-pool".into(),version_id:"v1".into(),members:intent.members,
allocation_policy:serde_json::json!({"target_holding_count":2,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":false}}),
timing_policy:serde_json::json!({"pricing_mode":"first_tick","window_start":"09:30"}),
stop_take_policy:serde_json::json!({"stop_loss":null,"take_profit":null}),out_of_pool_policy:"hold".into(),
};
let mut config=platform_expr_config_from_value("etf-no-cap-filter","000300.SH",&serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]}})).unwrap();
config.market_cap_field="close".into();config.market_cap_lower_expr="0".into();config.market_cap_upper_expr="1000000".into();
config.stock_filter_expr="close > 0".into();config.selection_limit_expr="2".into();config.selection_candidate_limit_expr="2".into();
config.rank_expr=format!("symbol == {:?} ? 0 : 1",code(1));
config.intraday_execution_time=Some(time);config.matching_type=MatchingType::CurrentBarClose;
config.risk_config.trading_constraints.volume_limit_enabled=false;
let result=BacktestEngine::new(etf_fallback_fixture(time),PlatformExprStrategy::new(config.clone()),
broker(false).with_matching_type(MatchingType::CurrentBarClose).with_historical_etf_open_fallback(true),
BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(5)),end_date:Some(day(6)),decision_lag_trading_days:0,execution_price_field:PriceField::Last})
.with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap();
assert!(result.fills.iter().any(|fill|fill.symbol==code(2)),"{:?}",result.equity_curve.iter().map(|row|&row.diagnostics).collect::<Vec<_>>());
assert!(result.fills.iter().any(|fill|fill.symbol==code(1)));
config.stock_filter_expr="last != 0".into();
let rejected=BacktestEngine::new(etf_fallback_fixture(time),PlatformExprStrategy::new(config),
broker(false).with_matching_type(MatchingType::CurrentBarClose).with_historical_etf_open_fallback(true),
BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(5)),end_date:Some(day(6)),decision_lag_trading_days:0,execution_price_field:PriceField::Last})
.with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap_err();
assert!(rejected.to_string().contains("etf_intraday_condition_evidence_missing"),"{rejected}");
}
#[test]
fn etf_signal_budget_does_not_read_the_current_sessions_future_close() {
let run=|future_close:f64| {
let time=chrono::NaiveTime::from_hms_opt(13,0,0).unwrap();
let mut parts=etf_fallback_fixture(time).snapshot_components();
for row in &mut parts.market {
if row.symbol==code(2)&&row.date==day(5) {row.close=future_close;row.last_price=future_close;row.high=future_close.max(row.open);}
}
let data=DataSet::from_components_with_actions_and_quotes(parts.instruments,parts.market,parts.factors,parts.candidates,parts.benchmarks,parts.corporate_actions,parts.execution_quotes).unwrap();
let program=StockPoolProgram{schema_version:1,pool_id:"budget-no-future".into(),version_id:"v1".into(),members:contract(day(2),1,true).members,
allocation_policy:serde_json::json!({"target_holding_count":2,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":true}}),
timing_policy:serde_json::json!({"pricing_mode":"first_tick","window_start":"13:00","window_end":"14:55"}),stop_take_policy:serde_json::json!({}),out_of_pool_policy:"hold".into()};
let mut config=platform_expr_config_from_value("etf-budget","000300.SH",&serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]},"runtimeExpressions":{"schedule":{"frequency":"daily","time":"13:00"}}})).unwrap();
config.market_cap_field="close".into();config.market_cap_lower_expr="0".into();config.market_cap_upper_expr="1000000".into();
config.stock_filter_expr="true".into();config.selection_limit_expr="2".into();config.selection_candidate_limit_expr="2".into();
config.rank_expr=format!("symbol == {:?} ? 0 : 1",code(1));config.intraday_execution_time=Some(time);config.matching_type=MatchingType::CurrentBarClose;
config.risk_config.trading_constraints.volume_limit_enabled=false;
BacktestEngine::new(data,PlatformExprStrategy::new(config),broker(false).with_matching_type(MatchingType::CurrentBarClose).with_intraday_execution_start_time(time).with_historical_etf_open_fallback(true),
BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(5)),decision_lag_trading_days:0,execution_price_field:PriceField::Last})
.with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap()
};
let a=run(40.);let b=run(400.);
let budget=|result:&fidc_core::BacktestResult|result.equity_curve.iter().find(|row|row.date==day(5)).unwrap().diagnostics.split(" | ").find(|line|line.starts_with("stock_pool_signal_frozen")).unwrap().to_string();
assert_eq!(budget(&a),budget(&b));
assert_eq!(serde_json::to_value(&a.fills).unwrap(),serde_json::to_value(&b.fills).unwrap());
}
struct EtfReallocationSignal { protection_days: u32 }
impl fidc_core::strategy::Strategy for EtfReallocationSignal {
fn name(&self)->&str {"deferred ETF sell funding"}
fn requires_minute_callbacks(&self)->bool {false}
fn decision_quote_times(&self)->Vec<chrono::NaiveTime> {vec![chrono::NaiveTime::from_hms_opt(13,0,0).unwrap()]}
fn decision_quote_symbols(&mut self,_:&fidc_core::strategy::StrategyContext<'_>)->Result<BTreeSet<String>,fidc_core::BacktestError>{Ok(BTreeSet::from([code(1),code(2)]))}
fn on_day(&mut self,ctx:&fidc_core::strategy::StrategyContext<'_>)->Result<StrategyDecision,fidc_core::BacktestError> {
if ![day(2),day(6)].contains(&ctx.execution_date) {return Ok(Default::default());}
let mut intent=contract(ctx.execution_date,1,false);
intent.rule.automatic_trade_protection.buy_protection_days=self.protection_days;
if ctx.execution_date==day(2) {intent.selection.final_symbols=vec![code(1),code(2)];intent.constraints.target_holding_count=Some(2);}
else {intent.frozen_equity=300000.into();intent.out_of_pool_policy="reduce_to_zero_when_sellable".into();}
Ok(decision(intent))
}
}
#[test]
fn deferred_etf_sell_does_not_finance_same_day_stock_topup() {
let time=chrono::NaiveTime::from_hms_opt(13,0,0).unwrap();
let result=BacktestEngine::new(etf_fallback_fixture(time),EtfReallocationSignal{protection_days:0},
broker(false).with_matching_type(MatchingType::MinuteLast).with_execution_price_field(PriceField::Last).with_intraday_execution_start_time(time).with_historical_etf_open_fallback(true),
BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(6)),decision_lag_trading_days:0,execution_price_field:PriceField::Last})
.with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap();
assert!(result.fills.iter().any(|fill|fill.symbol==code(2)&&fill.date==day(5)));
assert!(result.fills.iter().all(|fill|fill.date!=day(6)),"{:?}",result.fills);
assert!(!result.order_events.iter().any(|order|order.date==day(6)&&order.symbol==code(1)&&order.side==fidc_core::OrderSide::Buy),"{:?}",result.order_events);
assert_eq!(result.terminal_audit.deferred_etf_target_count,1);
}
#[test]
fn etf_post_buy_protection_starts_on_deferred_fill_day_not_signal_day() {
let time=chrono::NaiveTime::from_hms_opt(13,0,0).unwrap();
let result=BacktestEngine::new(etf_fallback_fixture(time),EtfReallocationSignal{protection_days:1},
broker(false).with_matching_type(MatchingType::MinuteLast).with_execution_price_field(PriceField::Last).with_intraday_execution_start_time(time).with_historical_etf_open_fallback(true),
BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(6)),decision_lag_trading_days:0,execution_price_field:PriceField::Last})
.with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap();
assert!(result.fills.iter().any(|fill|fill.symbol==code(2)&&fill.date==day(5)));
assert!(result.fills.iter().filter(|fill|fill.symbol==code(2)).all(|fill|fill.side!=fidc_core::OrderSide::Sell));
// Jan 2 is the signal; actual Jan 5 fill protects Jan 5 and Jan 6.
// Starting the timer on Jan 2 would incorrectly queue an exit on Jan 6.
assert_eq!(result.terminal_audit.deferred_etf_target_count,0);
}