From 32e29fdf9aa5b39d98d2b1899a3fcb4edb6765b8 Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 22 Apr 2026 22:13:32 -0700 Subject: [PATCH] Support successor conversions for delisted holdings --- crates/fidc-core/src/data.rs | 70 ++++++-- crates/fidc-core/src/engine.rs | 66 ++++++- crates/fidc-core/src/portfolio.rs | 85 +++++++++ crates/fidc-core/tests/delisting.rs | 259 +++++++++++++++++++++++++++- 4 files changed, 447 insertions(+), 33 deletions(-) diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 93d893f..056a0e4 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -231,6 +231,12 @@ pub struct CorporateAction { pub issue_price: f64, pub reform: bool, pub adjust_factor: Option, + #[serde(default)] + pub successor_symbol: Option, + #[serde(default)] + pub successor_ratio: Option, + #[serde(default)] + pub successor_cash: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -284,6 +290,26 @@ impl CorporateAction { || (self.split_ratio() - 1.0).abs() > f64::EPSILON || self.issue_quantity.abs() > f64::EPSILON || self.reform + || self.has_successor_conversion() + } + + pub fn has_successor_conversion(&self) -> bool { + self.successor_symbol + .as_ref() + .is_some_and(|symbol| !symbol.trim().is_empty()) + && self.successor_ratio_value() > 0.0 + } + + pub fn successor_ratio_value(&self) -> f64 { + self.successor_ratio + .filter(|ratio| ratio.is_finite() && *ratio > 0.0) + .unwrap_or(1.0) + } + + pub fn successor_cash_value(&self) -> f64 { + self.successor_cash + .filter(|cash| cash.is_finite()) + .unwrap_or(0.0) } } @@ -329,7 +355,10 @@ impl SymbolPriceSeries { let closes = sorted.iter().map(|row| row.close).collect::>(); let prev_closes = sorted.iter().map(|row| row.prev_close).collect::>(); let last_prices = sorted.iter().map(|row| row.last_price).collect::>(); - let volumes = sorted.iter().map(|row| row.volume as f64).collect::>(); + let volumes = sorted + .iter() + .map(|row| row.volume as f64) + .collect::>(); let open_prefix = prefix_sums(&opens); let close_prefix = prefix_sums(&closes); let prev_close_prefix = prefix_sums(&prev_closes); @@ -513,7 +542,12 @@ impl BenchmarkPriceSeries { self.moving_average_for(date, lookback, PriceField::Close) } - fn moving_average_for(&self, date: NaiveDate, lookback: usize, field: PriceField) -> Option { + fn moving_average_for( + &self, + date: NaiveDate, + lookback: usize, + field: PriceField, + ) -> Option { if lookback == 0 { return None; } @@ -875,12 +909,7 @@ impl DataSet { .and_then(|series| series.decision_volume_moving_average(date, lookback)) } - pub fn factor_numeric_value( - &self, - date: NaiveDate, - symbol: &str, - field: &str, - ) -> Option { + pub fn factor_numeric_value(&self, date: NaiveDate, symbol: &str, field: &str) -> Option { self.factor(date, symbol) .and_then(|snapshot| factor_numeric_value(snapshot, field)) } @@ -922,16 +951,14 @@ impl DataSet { lookback: usize, ) -> Option { match field { - "close" | "prev_close" | "stock_close" | "price" => { - self.market_series_by_symbol - .get(symbol) - .and_then(|series| series.decision_close_rolling_average(date, lookback)) - } - "volume" | "stock_volume" => { - self.market_series_by_symbol - .get(symbol) - .and_then(|series| series.decision_volume_rolling_average(date, lookback)) - } + "close" | "prev_close" | "stock_close" | "price" => self + .market_series_by_symbol + .get(symbol) + .and_then(|series| series.decision_close_rolling_average(date, lookback)), + "volume" | "stock_volume" => self + .market_series_by_symbol + .get(symbol) + .and_then(|series| series.decision_volume_rolling_average(date, lookback)), "open" => self.market_moving_average(date, symbol, lookback, PriceField::Open), "last" | "last_price" => { self.market_moving_average(date, symbol, lookback, PriceField::Last) @@ -1178,6 +1205,13 @@ fn read_corporate_actions(path: &Path) -> Result, DataSetEr issue_price: row.parse_optional_f64(6 + offset).unwrap_or(0.0), reform: row.parse_optional_bool(7 + offset).unwrap_or(false), adjust_factor: row.parse_optional_f64(8 + offset), + successor_symbol: row + .fields + .get(9 + offset) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + successor_ratio: row.parse_optional_f64(10 + offset), + successor_cash: row.parse_optional_f64(11 + offset), }); } Ok(snapshots) diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 080d06c..976ac82 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -6,7 +6,7 @@ use crate::broker::{BrokerExecutionReport, BrokerSimulator}; use crate::cost::CostModel; use crate::data::{BenchmarkSnapshot, DataSet, DataSetError, PriceField}; use crate::events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent}; -use crate::metrics::{BacktestMetrics, compute_backtest_metrics}; +use crate::metrics::{compute_backtest_metrics, BacktestMetrics}; use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState}; use crate::rules::EquityRuleHooks; use crate::strategy::{Strategy, StrategyContext}; @@ -177,18 +177,18 @@ where &mut corporate_action_notes, )?; self.extend_result(&mut result, receivable_report); - let delisting_report = self.settle_delisted_positions( - execution_date, - &mut portfolio, - &mut corporate_action_notes, - )?; - self.extend_result(&mut result, delisting_report); let corporate_action_report = self.apply_corporate_actions( execution_date, &mut portfolio, &mut corporate_action_notes, )?; self.extend_result(&mut result, corporate_action_report); + let delisting_report = self.settle_delisted_positions( + execution_date, + &mut portfolio, + &mut corporate_action_notes, + )?; + self.extend_result(&mut result, delisting_report); let decision = execution_idx .checked_sub(self.config.decision_lag_trading_days) @@ -396,6 +396,58 @@ where }); } } + + if action.has_successor_conversion() { + let successor_symbol = action + .successor_symbol + .as_deref() + .expect("successor symbol checked"); + let Some(outcome) = portfolio.apply_successor_conversion( + &action.symbol, + successor_symbol, + action.successor_ratio_value(), + action.successor_cash_value(), + ) else { + continue; + }; + let reason = format!( + "successor_conversion {}->{} ratio={:.6} cash_per_share={:.6}", + outcome.old_symbol, + outcome.new_symbol, + action.successor_ratio_value(), + action.successor_cash_value() + ); + notes.push(reason.clone()); + report.position_events.push(PositionEvent { + date, + symbol: outcome.old_symbol.clone(), + delta_quantity: -(outcome.old_quantity as i32), + quantity_after: 0, + average_cost: 0.0, + realized_pnl_delta: 0.0, + reason: reason.clone(), + }); + report.position_events.push(PositionEvent { + date, + symbol: outcome.new_symbol.clone(), + delta_quantity: outcome.new_quantity_delta, + quantity_after: outcome.new_quantity_after, + average_cost: outcome.new_average_cost_after, + realized_pnl_delta: 0.0, + reason: reason.clone(), + }); + if outcome.cash_delta.abs() > f64::EPSILON { + let cash_before = portfolio.cash(); + portfolio.apply_cash_delta(outcome.cash_delta); + report.account_events.push(AccountEvent { + date, + cash_before, + cash_after: portfolio.cash(), + total_equity: portfolio.total_equity(), + note: format!("{} cash={:.2}", reason, outcome.cash_delta), + }); + } + } } portfolio.prune_flat_positions(); diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index 9a48bb4..750a396 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -181,6 +181,17 @@ pub struct PortfolioState { cash_receivables: Vec, } +#[derive(Debug, Clone)] +pub(crate) struct SuccessorConversionOutcome { + pub old_symbol: String, + pub new_symbol: String, + pub old_quantity: u32, + pub new_quantity_delta: i32, + pub new_quantity_after: u32, + pub new_average_cost_after: f64, + pub cash_delta: f64, +} + impl PortfolioState { pub fn new(initial_cash: f64) -> Self { Self { @@ -290,6 +301,80 @@ impl PortfolioState { }) .collect() } + + pub(crate) fn apply_successor_conversion( + &mut self, + old_symbol: &str, + new_symbol: &str, + ratio: f64, + cash_per_old_share: f64, + ) -> Option { + if !ratio.is_finite() || ratio <= 0.0 { + return None; + } + let old_symbol_owned = old_symbol.to_string(); + let old_position = self.positions.shift_remove(old_symbol)?; + if old_position.quantity == 0 { + return None; + } + + let old_quantity = old_position.quantity; + let last_price = old_position.last_price; + let realized_pnl = old_position.realized_pnl; + let mut converted_lots = old_position + .lots + .into_iter() + .map(|lot| PositionLot { + acquired_date: lot.acquired_date, + quantity: round_half_up_u32(lot.quantity as f64 * ratio), + price: lot.price / ratio, + }) + .collect::>(); + let expected_total = round_half_up_u32(old_quantity as f64 * ratio); + let scaled_total = converted_lots.iter().map(|lot| lot.quantity).sum::(); + if let Some(last_lot) = converted_lots.last_mut() { + if scaled_total < expected_total { + last_lot.quantity += expected_total - scaled_total; + } else if scaled_total > expected_total { + last_lot.quantity = last_lot + .quantity + .saturating_sub(scaled_total - expected_total); + } + } + converted_lots.retain(|lot| lot.quantity > 0); + let converted_quantity = converted_lots.iter().map(|lot| lot.quantity).sum::(); + let converted_last_price = if last_price > 0.0 { + last_price / ratio + } else { + 0.0 + }; + + let successor = self + .positions + .entry(new_symbol.to_string()) + .or_insert_with(|| Position::new(new_symbol)); + successor.lots.extend(converted_lots); + successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum(); + successor.realized_pnl += realized_pnl; + if converted_last_price > 0.0 { + successor.last_price = converted_last_price; + } + successor.recalculate_average_cost(); + + Some(SuccessorConversionOutcome { + old_symbol: old_symbol_owned, + new_symbol: new_symbol.to_string(), + old_quantity, + new_quantity_delta: converted_quantity as i32, + new_quantity_after: successor.quantity, + new_average_cost_after: successor.average_cost, + cash_delta: if cash_per_old_share.is_finite() { + old_quantity as f64 * cash_per_old_share + } else { + 0.0 + }, + }) + } } #[cfg(test)] diff --git a/crates/fidc-core/tests/delisting.rs b/crates/fidc-core/tests/delisting.rs index ab4d8ec..7f9de43 100644 --- a/crates/fidc-core/tests/delisting.rs +++ b/crates/fidc-core/tests/delisting.rs @@ -1,8 +1,9 @@ use chrono::NaiveDate; use fidc_core::{ BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, - ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, - Instrument, OrderIntent, PriceField, Strategy, StrategyContext, StrategyDecision, + ChinaAShareCostModel, ChinaEquityRuleHooks, CorporateAction, DailyFactorSnapshot, + DailyMarketSnapshot, DataSet, Instrument, OrderIntent, PriceField, Strategy, StrategyContext, + StrategyDecision, }; use std::collections::{BTreeMap, BTreeSet}; @@ -144,6 +145,7 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run() pe_ttm: 10.0, turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), }, DailyFactorSnapshot { date: date1, @@ -153,6 +155,7 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run() pe_ttm: 10.0, turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), }, DailyFactorSnapshot { date: date2, @@ -162,6 +165,7 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run() pe_ttm: 10.0, turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), }, ], vec![ @@ -248,10 +252,249 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run() .any(|fill| fill.reason.contains("delisted_cash_settlement") && fill.symbol == "000001.SZ") ); - assert!( - result - .holdings_summary - .iter() - .all(|holding| holding.symbol != "000001.SZ") - ); + assert!(result + .holdings_summary + .iter() + .all(|holding| holding.symbol != "000001.SZ")); +} + +#[test] +fn engine_applies_successor_conversion_before_delisted_cash_settlement() { + let date1 = d(2025, 1, 2); + let date2 = d(2025, 1, 3); + let data = DataSet::from_components_with_actions( + vec![ + Instrument { + symbol: "000001.SZ".to_string(), + name: "Legacy".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: Some(date2), + status: "delisted".to_string(), + }, + Instrument { + symbol: "000002.SZ".to_string(), + name: "Successor".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }, + ], + vec![ + DailyMarketSnapshot { + date: date1, + symbol: "000001.SZ".to_string(), + timestamp: Some("2025-01-02 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.0, + low: 10.0, + close: 10.0, + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + prev_close: 10.0, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 100_000, + ask1_volume: 100_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }, + DailyMarketSnapshot { + date: date1, + symbol: "000002.SZ".to_string(), + timestamp: Some("2025-01-02 10:18:00".to_string()), + day_open: 20.0, + open: 20.0, + high: 20.0, + low: 20.0, + close: 20.0, + last_price: 20.0, + bid1: 20.0, + ask1: 20.0, + prev_close: 20.0, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 100_000, + ask1_volume: 100_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 22.0, + lower_limit: 18.0, + price_tick: 0.01, + }, + DailyMarketSnapshot { + date: date2, + symbol: "000002.SZ".to_string(), + timestamp: Some("2025-01-03 10:18:00".to_string()), + day_open: 21.0, + open: 21.0, + high: 21.0, + low: 21.0, + close: 21.0, + last_price: 21.0, + bid1: 21.0, + ask1: 21.0, + prev_close: 20.0, + volume: 120_000, + tick_volume: 120_000, + bid1_volume: 120_000, + ask1_volume: 120_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 22.0, + lower_limit: 18.0, + price_tick: 0.01, + }, + ], + vec![ + DailyFactorSnapshot { + date: date1, + symbol: "000001.SZ".to_string(), + market_cap_bn: 20.0, + free_float_cap_bn: 18.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }, + DailyFactorSnapshot { + date: date1, + symbol: "000002.SZ".to_string(), + market_cap_bn: 30.0, + free_float_cap_bn: 28.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }, + DailyFactorSnapshot { + date: date2, + symbol: "000002.SZ".to_string(), + market_cap_bn: 31.0, + free_float_cap_bn: 29.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }, + ], + vec![ + CandidateEligibility { + date: date1, + symbol: "000001.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }, + CandidateEligibility { + date: date1, + symbol: "000002.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }, + CandidateEligibility { + date: date2, + symbol: "000002.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }, + ], + vec![ + BenchmarkSnapshot { + date: date1, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }, + BenchmarkSnapshot { + date: date2, + benchmark: "000300.SH".to_string(), + open: 101.0, + close: 101.0, + prev_close: 100.0, + volume: 1_100_000, + }, + ], + vec![CorporateAction { + date: date2, + symbol: "000001.SZ".to_string(), + payable_date: None, + share_cash: 0.0, + share_bonus: 0.0, + share_gift: 0.0, + issue_quantity: 0.0, + issue_price: 0.0, + reform: false, + adjust_factor: None, + successor_symbol: Some("000002.SZ".to_string()), + successor_ratio: Some(0.5), + successor_cash: Some(1.0), + }], + ) + .expect("dataset"); + + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + let mut engine = BacktestEngine::new( + data, + BuyThenHoldStrategy, + 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 succeeds"); + assert!(result.equity_curve.iter().any(|point| point + .notes + .contains("successor_conversion 000001.SZ->000002.SZ"))); + assert!(result.fills.iter().all( + |fill| !fill.reason.contains("delisted_cash_settlement") || fill.symbol != "000001.SZ" + )); + let successor_holding = result + .holdings_summary + .iter() + .find(|holding| holding.symbol == "000002.SZ") + .expect("successor holding exists"); + assert_eq!(successor_holding.quantity, 500); + assert!(result + .holdings_summary + .iter() + .all(|holding| holding.symbol != "000001.SZ")); + assert!(result.account_events.iter().any(|event| event + .note + .contains("successor_conversion 000001.SZ->000002.SZ") + && event.note.contains("cash=1000.00"))); }