fix(stock-pool): execute verified ETF daily fallbacks with frozen next-open targets
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user