From e7d1c875fdc9ad9b2405d63c60508dd2530be32b Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 15 Jul 2026 20:48:17 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E9=80=80=E5=B8=82=E6=8C=81?= =?UTF-8?q?=E4=BB=93=E8=99=9A=E5=81=87=E7=8E=B0=E9=87=91=E5=85=91=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/engine.rs | 105 +++++----------------------- crates/fidc-core/src/portfolio.rs | 12 ++++ crates/fidc-core/tests/delisting.rs | 31 +++++--- 3 files changed, 50 insertions(+), 98 deletions(-) diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 3bdc06f..2f85f60 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -1769,11 +1769,11 @@ where execution_date, execution_date, ); - let delisting_report = self.settle_delisted_positions( + let delisting_report = self.audit_unresolved_delisted_positions( execution_date, - &mut portfolio, + &portfolio, &mut corporate_action_notes, - )?; + ); self.extend_result( &mut result, delisting_report, @@ -3386,13 +3386,13 @@ where Ok(report) } - fn settle_delisted_positions( + fn audit_unresolved_delisted_positions( &self, date: NaiveDate, - portfolio: &mut PortfolioState, + portfolio: &PortfolioState, notes: &mut Vec, - ) -> Result { - let mut report = BrokerExecutionReport::default(); + ) -> BrokerExecutionReport { + let report = BrokerExecutionReport::default(); let symbols = portfolio.positions().keys().cloned().collect::>(); for symbol in symbols { let Some(position) = portfolio.position(&symbol) else { @@ -3404,98 +3404,29 @@ where let Some(instrument) = self.data.instrument(&symbol) else { continue; }; - let should_settle = instrument.is_delisted_on_or_before(date) + let is_unresolved = instrument.is_delisted_on_or_before(date) || (instrument.status.eq_ignore_ascii_case("delisted") && instrument.delisted_at.is_none() && self.data.market(date, &symbol).is_none()); - if !should_settle { + if !is_unresolved { continue; } - - let quantity = position.quantity; - let settlement_price = if position.last_price.is_finite() && position.last_price > 0.0 { - position.last_price - } else if position.average_cost.is_finite() && position.average_cost > 0.0 { - position.average_cost - } else { - 0.0 - }; let effective_delisted_at = instrument .delisted_at .or_else(|| self.data.calendar().previous_day(date)) .unwrap_or(date); - if !settlement_price.is_finite() || settlement_price <= 0.0 { - return Err(BacktestError::Execution(format!( - "missing delisting settlement price for {} on {}", - symbol, date - ))); - } - - let cash_before = portfolio.cash(); - let gross_amount = settlement_price * quantity as f64; - let realized_pnl_delta = { - let position = portfolio - .position_mut_if_exists(&symbol) - .expect("position exists for delisting settlement"); - position - .sell(quantity, settlement_price) - .map_err(BacktestError::Execution)? - }; - portfolio.apply_cash_delta(gross_amount); - portfolio.prune_flat_positions(); - let reason = format!( - "delisted_cash_settlement effective_date={} status={}", - effective_delisted_at, instrument.status + concat!( + "unresolved_delisted_position symbol={} quantity={} effective_date={} status={} ", + "settlement_action=missing valuation_policy=zero no_order=true" + ), + symbol, position.quantity, effective_delisted_at, instrument.status ); - notes.push(reason.clone()); - report.order_events.push(OrderEvent { - date, - decision_date: None, - order_created_date: None, - execution_date: None, - order_id: None, - symbol: symbol.clone(), - side: OrderSide::Sell, - requested_quantity: quantity, - filled_quantity: quantity, - status: OrderStatus::Filled, - reason: reason.clone(), - }); - report.fill_events.push(FillEvent { - date, - decision_date: None, - order_created_date: None, - execution_date: None, - order_id: None, - symbol: symbol.clone(), - side: OrderSide::Sell, - quantity, - price: settlement_price, - gross_amount, - commission: 0.0, - stamp_tax: 0.0, - net_cash_flow: gross_amount, - reason: reason.clone(), - }); - report.position_events.push(PositionEvent { - date, - symbol: symbol.clone(), - delta_quantity: -(quantity as i32), - quantity_after: 0, - average_cost: 0.0, - realized_pnl_delta, - reason: reason.clone(), - }); - report.account_events.push(AccountEvent { - date, - cash_before, - cash_after: portfolio.cash(), - total_equity: portfolio.total_equity(), - note: reason, - }); + if instrument.delisted_at == Some(date) || instrument.delisted_at.is_none() { + notes.push(reason.clone()); + } } - Ok(report) + report } } diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index 811df51..a450164 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -698,6 +698,18 @@ impl PortfolioState { ) -> Result<(), DataSetError> { let day_sold_symbols = self.day_sold_symbols.clone(); for position in self.positions.values_mut() { + let current_market_missing = data.market(date, &position.symbol).is_none(); + let unresolved_delisting = current_market_missing + && data.instrument(&position.symbol).is_some_and(|instrument| { + instrument.is_delisted_on_or_before(date) + || (instrument.status.eq_ignore_ascii_case("delisted") + && instrument.delisted_at.is_none()) + }); + if unresolved_delisting { + position.last_price = 0.0; + position.refresh_day_pnl(); + continue; + } let sold_today = position.sold_quantity() > 0 || day_sold_symbols.contains(&position.symbol); if same_day_buy_close_mark_at_fill diff --git a/crates/fidc-core/tests/delisting.rs b/crates/fidc-core/tests/delisting.rs index ad221d0..07b513b 100644 --- a/crates/fidc-core/tests/delisting.rs +++ b/crates/fidc-core/tests/delisting.rs @@ -42,7 +42,7 @@ impl Strategy for BuyThenHoldStrategy { } #[test] -fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run() { +fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() { let date1 = d(2025, 1, 2); let delist_date = d(2025, 1, 3); let date2 = d(2025, 1, 6); @@ -306,24 +306,33 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run() ); let result = engine.run().expect("backtest succeeds"); - assert_eq!(result.fills.len(), 2); + assert_eq!(result.fills.len(), 1); assert!( result .fills .iter() - .any(|fill| fill.reason.contains("delisted_cash_settlement") - && fill.symbol == "000001.SZ") - ); - assert!( - result - .holdings_summary - .iter() - .all(|holding| holding.symbol != "000001.SZ") + .all(|fill| !fill.reason.contains("delisted_cash_settlement")) ); + let unresolved = result + .holdings_summary + .iter() + .find(|holding| holding.symbol == "000001.SZ") + .expect("unresolved delisted holding remains auditable"); + assert_eq!(unresolved.quantity, 900); + assert_eq!(unresolved.last_price, 0.0); + assert_eq!(unresolved.market_value, 0.0); + assert!(result.equity_curve.iter().any(|point| { + point + .notes + .contains("unresolved_delisted_position symbol=000001.SZ") + && point.notes.contains("settlement_action=missing") + && point.notes.contains("valuation_policy=zero") + && point.notes.contains("no_order=true") + })); } #[test] -fn engine_applies_successor_conversion_before_delisted_cash_settlement() { +fn engine_applies_successor_conversion_before_unresolved_delisting_audit() { let date1 = d(2025, 1, 2); let date2 = d(2025, 1, 3); let data = DataSet::from_components_with_actions(