From 630a7a16c9561f8046626d3f30dfb250aea15322 Mon Sep 17 00:00:00 2001 From: boris Date: Sun, 6 Sep 2026 14:36:35 +0800 Subject: [PATCH] fix: value unavailable execution days without fills --- crates/fidc-core/src/broker.rs | 96 ++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index e3e7ccb..d2d828b 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -6700,6 +6700,15 @@ where if let Some(price) = prices.get(symbol).copied().filter(|price| *price > 0.0) { return Ok(price); } + if data.market(date, symbol).is_none() + && let Some(previous_date) = date.pred_opt() + && let Some(price) = + data.price_on_or_before(previous_date, symbol, PriceField::Close) + && price.is_finite() + && price > 0.0 + { + return Ok(price); + } return Err(BacktestError::MissingPrice { date, symbol: symbol.to_string(), @@ -10348,6 +10357,93 @@ mod tests { ); } + #[test] + fn target_portfolio_smart_uses_prior_close_only_for_missing_day_valuation() { + let trade_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); + let missing_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date"); + let symbol = "000001.SZ"; + let data = DataSet::from_components_with_actions_and_quotes( + vec![limit_test_instrument()], + vec![limit_test_snapshot()], + Vec::new(), + vec![limit_test_candidate(true, true)], + vec![limit_test_benchmark()], + Vec::new(), + Vec::new(), + ) + .expect("valid dataset"); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Open, + ) + .with_volume_limit(false) + .with_liquidity_limit(false); + let mut portfolio = PortfolioState::new(100_000.0); + portfolio.position_mut(symbol).buy(trade_date, 1_000, 10.0); + let mut report = BrokerExecutionReport::default(); + + broker + .process_target_portfolio_smart( + missing_date, + &mut portfolio, + &data, + &BTreeMap::new(), + None, + Some(&BTreeMap::new()), + "target_portfolio_missing_execution_day", + &mut BTreeMap::new(), + &mut IntradayExecutionLedger::default(), + &mut None, + &mut BTreeMap::new(), + &mut report, + ) + .expect("prior close may value the holding but must not execute the order"); + + assert_eq!( + portfolio.position(symbol).map(|position| position.quantity), + Some(1_000) + ); + assert!(report.fill_events.is_empty()); + assert!(report.order_events.iter().any(|event| { + event.symbol == symbol + && event.side == OrderSide::Sell + && event.status == OrderStatus::Rejected + && event.reason.contains("market snapshot is missing") + })); + } + + #[test] + fn custom_valuation_missing_with_current_market_still_fails_closed() { + let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); + let data = DataSet::from_components_with_actions_and_quotes( + vec![limit_test_instrument()], + vec![limit_test_snapshot()], + Vec::new(), + vec![limit_test_candidate(true, true)], + vec![limit_test_benchmark()], + Vec::new(), + Vec::new(), + ) + .expect("valid dataset"); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Open, + ); + + let error = broker + .rebalance_valuation_price_with_overrides( + date, + "000001.SZ", + &data, + Some(&BTreeMap::new()), + ) + .expect_err("a present execution-day market row cannot use stale valuation fallback"); + + assert!(error.to_string().contains("custom valuation")); + } + #[test] fn market_share_buy_skips_quantity_that_rounds_below_one_lot() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");