diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 86a7a8b..9b1a981 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -3841,8 +3841,14 @@ where ) -> Result { let mut market_value = 0.0; for position in portfolio.positions().values() { - let price = data.price(date, &position.symbol, field).ok_or_else(|| { - BacktestError::MissingPrice { + let price = data + .price(date, &position.symbol, field) + .or_else(|| data.price_on_or_before(date, &position.symbol, field)) + .or_else(|| { + (position.last_price.is_finite() && position.last_price > 0.0) + .then_some(position.last_price) + }) + .ok_or_else(|| BacktestError::MissingPrice { date, symbol: position.symbol.clone(), field: match field { @@ -3851,8 +3857,7 @@ where PriceField::Close => "close", PriceField::Last => "last", }, - } - })?; + })?; market_value += price * position.quantity as f64; } @@ -3962,12 +3967,32 @@ where ) -> Result { let mut market_value = 0.0; for position in portfolio.positions().values() { - let price = self.rebalance_valuation_price_with_overrides( - date, - &position.symbol, - data, - valuation_prices, - )?; + let price = if valuation_prices.is_some() { + self.rebalance_valuation_price_with_overrides( + date, + &position.symbol, + data, + valuation_prices, + )? + } else if let Some(snapshot) = data.market(date, &position.symbol) { + self.rebalance_valuation_price_for_snapshot(snapshot) + .ok_or_else(|| BacktestError::MissingPrice { + date, + symbol: position.symbol.clone(), + field: self.rebalance_valuation_price_field_name(), + })? + } else { + data.price_on_or_before(date, &position.symbol, PriceField::Close) + .or_else(|| { + (position.last_price.is_finite() && position.last_price > 0.0) + .then_some(position.last_price) + }) + .ok_or_else(|| BacktestError::MissingPrice { + date, + symbol: position.symbol.clone(), + field: self.rebalance_valuation_price_field_name(), + })? + }; market_value += price * position.quantity as f64; } Ok(portfolio.cash() + market_value) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index d521477..30c7869 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -4125,7 +4125,13 @@ impl PlatformExprStrategy { if position.quantity == 0 || position.average_cost <= 0.0 { return Ok((false, false)); } - let stock = self.stock_state(ctx, date, symbol)?; + let stock = match self.stock_state(ctx, date, symbol) { + Ok(stock) => stock, + Err(BacktestError::Data(crate::data::DataSetError::MissingSnapshot { .. })) => { + return Ok((false, false)); + } + Err(error) => return Err(error), + }; let current_price = stock.last; let holding_return = if position.average_cost > 0.0 { current_price / position.average_cost - 1.0 diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index 038d52d..88df0a3 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -551,8 +551,14 @@ impl PortfolioState { field: PriceField, ) -> Result<(), DataSetError> { for position in self.positions.values_mut() { - let price = data.price(date, &position.symbol, field).ok_or_else(|| { - DataSetError::MissingSnapshot { + let price = data + .price(date, &position.symbol, field) + .or_else(|| data.price_on_or_before(date, &position.symbol, field)) + .or_else(|| { + (position.last_price.is_finite() && position.last_price > 0.0) + .then_some(position.last_price) + }) + .ok_or_else(|| DataSetError::MissingSnapshot { kind: match field { PriceField::DayOpen => "day open price", PriceField::Open => "open price", @@ -561,8 +567,7 @@ impl PortfolioState { }, date, symbol: position.symbol.clone(), - } - })?; + })?; position.last_price = price; position.refresh_day_pnl(); } @@ -992,6 +997,75 @@ mod tests { assert!((position.trading_pnl + 5.0).abs() < 1e-6); } + #[test] + fn portfolio_carries_last_price_when_position_market_row_is_missing() { + let prev_date = NaiveDate::from_ymd_opt(2025, 5, 26).unwrap(); + let missing_date = NaiveDate::from_ymd_opt(2025, 5, 27).unwrap(); + let mut portfolio = PortfolioState::new(10_000.0); + portfolio + .position_mut("601028.SH") + .buy(prev_date, 100, 10.0); + + let dataset = DataSet::from_components( + vec![Instrument { + symbol: "601028.SH".to_string(), + name: "Missing Row Test".to_string(), + board: "SH".to_string(), + round_lot: 100, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date: prev_date, + symbol: "601028.SH".to_string(), + timestamp: None, + day_open: 10.2, + open: 10.2, + high: 10.4, + low: 9.9, + close: 10.3, + last_price: 10.3, + bid1: 10.29, + ask1: 10.31, + prev_close: 10.0, + volume: 1000, + tick_volume: 1000, + bid1_volume: 1000, + ask1_volume: 1000, + trading_phase: None, + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }], + Vec::new(), + Vec::new(), + vec![BenchmarkSnapshot { + date: prev_date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1000.0, + prev_close: 999.0, + volume: 1000, + }], + ) + .expect("dataset"); + + portfolio + .update_prices(prev_date, &dataset, PriceField::Close) + .expect("previous close"); + portfolio.begin_trading_day(); + portfolio + .update_prices(missing_date, &dataset, PriceField::Close) + .expect("missing current row should carry previous close"); + + let position = portfolio.position("601028.SH").expect("position"); + assert!((position.last_price - 10.3).abs() < 1e-6); + assert!((position.market_value() - 1030.0).abs() < 1e-6); + assert!(position.position_pnl.abs() < 1e-6); + } + #[test] fn position_tracks_day_lifecycle_fields() { let prev_date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index 6ea2e33..32ea631 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -10,8 +10,9 @@ use fidc_core::{ FuturesCommissionType, FuturesContractSpec, FuturesDirection, FuturesOrderIntent, FuturesTradingParameter, FuturesValidationConfig, Instrument, IntradayExecutionQuote, IntradayOrderBookDepthLevel, MatchingType, OpenOrderView, OrderIntent, OrderSide, OrderStatus, - PortfolioState, PriceField, ProcessEvent, ProcessEventBus, ProcessEventKind, ScheduleRule, - ScheduleStage, ScheduleTimeRule, Strategy, StrategyContext, StrategyDecision, + PlatformExprStrategy, PlatformExprStrategyConfig, PortfolioState, PriceField, ProcessEvent, + ProcessEventBus, ProcessEventKind, ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy, + StrategyContext, StrategyDecision, }; fn d(year: i32, month: u32, day: u32) -> NaiveDate { @@ -3714,3 +3715,216 @@ fn engine_exposes_current_process_context_to_strategies() { ); assert!(snapshots.iter().any(|item| item == "on_day:on_day:8")); } + +struct BuyMissingRowThenHoldStrategy; + +impl Strategy for BuyMissingRowThenHoldStrategy { + fn name(&self) -> &str { + "buy-missing-row-then-hold" + } + + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + if ctx.execution_date == d(2025, 5, 26) { + return Ok(StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![OrderIntent::Value { + symbol: "601028.SH".to_string(), + value: 1_000.0, + reason: "seed_position".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }); + } + Ok(StrategyDecision::default()) + } +} + +#[test] +fn engine_carries_position_price_when_current_market_row_is_missing() { + let date1 = d(2025, 5, 26); + let date2 = d(2025, 5, 27); + let data = DataSet::from_components( + vec![ + Instrument { + symbol: "601028.SH".to_string(), + name: "Missing Row".to_string(), + board: "SH".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }, + Instrument { + symbol: "000001.SZ".to_string(), + name: "Anchor".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }, + ], + vec![ + market_row(date1, "601028.SH", 10.0, 10.3), + market_row(date2, "000001.SZ", 20.0, 20.2), + ], + vec![ + factor_row(date1, "601028.SH", BTreeMap::new()), + factor_row(date2, "000001.SZ", BTreeMap::new()), + ], + vec![ + candidate_row(date1, "601028.SH"), + candidate_row(date2, "000001.SZ"), + ], + vec![ + benchmark_row(date1), + BenchmarkSnapshot { + date: date2, + benchmark: "000300.SH".to_string(), + open: 101.0, + close: 101.0, + prev_close: 100.0, + volume: 1_100_000, + }, + ], + ) + .expect("dataset"); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + let mut engine = BacktestEngine::new( + data, + BuyMissingRowThenHoldStrategy, + broker, + BacktestConfig { + initial_cash: 100_000.0, + benchmark_code: "000300.SH".to_string(), + start_date: Some(date1), + end_date: Some(date2), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Open, + }, + ); + + let result = engine + .run() + .expect("backtest should not fail on one missing holding row"); + assert_eq!(result.equity_curve.len(), 2); + assert!( + result + .daily_holdings + .iter() + .any(|holding| holding.date == date2 && holding.symbol == "601028.SH") + ); +} + +#[test] +fn platform_strategy_skips_position_stop_take_when_current_market_row_is_missing() { + let date1 = d(2025, 5, 26); + let date2 = d(2025, 5, 27); + let data = DataSet::from_components( + vec![ + Instrument { + symbol: "601028.SH".to_string(), + name: "Missing Row".to_string(), + board: "SH".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }, + Instrument { + symbol: "000001.SZ".to_string(), + name: "Signal Anchor".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }, + ], + vec![ + market_row(date1, "601028.SH", 10.0, 10.3), + market_row(date1, "000001.SZ", 20.0, 20.0), + market_row(date2, "000001.SZ", 20.0, 20.2), + ], + vec![ + factor_row(date1, "601028.SH", BTreeMap::new()), + factor_row(date2, "000001.SZ", BTreeMap::new()), + ], + vec![ + candidate_row(date1, "601028.SH"), + candidate_row(date2, "000001.SZ"), + ], + vec![ + benchmark_row(date1), + BenchmarkSnapshot { + date: date2, + benchmark: "000300.SH".to_string(), + open: 101.0, + close: 101.0, + prev_close: 100.0, + volume: 1_100_000, + }, + ], + ) + .expect("dataset"); + let mut config = PlatformExprStrategyConfig::microcap_rotation(); + config.strategy_name = "missing-row-platform-risk".to_string(); + config.benchmark_symbol = "000300.SH".to_string(); + config.signal_symbol = "000001.SZ".to_string(); + config.refresh_rate = 1; + config.max_positions = 1; + config.prelude.clear(); + config.universe_exclude.clear(); + config.market_cap_field = "market_cap".to_string(); + config.market_cap_lower_expr = "0".to_string(); + config.market_cap_upper_expr = "200".to_string(); + config.selection_limit_expr = "1".to_string(); + config.stock_filter_expr = "true".to_string(); + config.stop_loss_expr = "0.93".to_string(); + config.take_profit_expr = "1.07".to_string(); + config.benchmark_short_ma_days = 1; + config.benchmark_long_ma_days = 1; + config.stock_short_ma_days = 1; + config.stock_mid_ma_days = 1; + config.stock_long_ma_days = 1; + + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + let mut engine = BacktestEngine::new( + data, + PlatformExprStrategy::new(config), + broker, + BacktestConfig { + initial_cash: 100_000.0, + benchmark_code: "000300.SH".to_string(), + start_date: Some(date1), + end_date: Some(date2), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Open, + }, + ); + + let result = engine + .run() + .expect("platform strategy should hold through a missing current market row"); + assert_eq!(result.equity_curve.len(), 2); + assert!( + result + .daily_holdings + .iter() + .any(|holding| holding.date == date2 && holding.symbol == "601028.SH") + ); +}