Support successor conversions for delisted holdings
This commit is contained in:
@@ -231,6 +231,12 @@ pub struct CorporateAction {
|
|||||||
pub issue_price: f64,
|
pub issue_price: f64,
|
||||||
pub reform: bool,
|
pub reform: bool,
|
||||||
pub adjust_factor: Option<f64>,
|
pub adjust_factor: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub successor_symbol: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub successor_ratio: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub successor_cash: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -284,6 +290,26 @@ impl CorporateAction {
|
|||||||
|| (self.split_ratio() - 1.0).abs() > f64::EPSILON
|
|| (self.split_ratio() - 1.0).abs() > f64::EPSILON
|
||||||
|| self.issue_quantity.abs() > f64::EPSILON
|
|| self.issue_quantity.abs() > f64::EPSILON
|
||||||
|| self.reform
|
|| 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::<Vec<_>>();
|
let closes = sorted.iter().map(|row| row.close).collect::<Vec<_>>();
|
||||||
let prev_closes = sorted.iter().map(|row| row.prev_close).collect::<Vec<_>>();
|
let prev_closes = sorted.iter().map(|row| row.prev_close).collect::<Vec<_>>();
|
||||||
let last_prices = sorted.iter().map(|row| row.last_price).collect::<Vec<_>>();
|
let last_prices = sorted.iter().map(|row| row.last_price).collect::<Vec<_>>();
|
||||||
let volumes = sorted.iter().map(|row| row.volume as f64).collect::<Vec<_>>();
|
let volumes = sorted
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.volume as f64)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
let open_prefix = prefix_sums(&opens);
|
let open_prefix = prefix_sums(&opens);
|
||||||
let close_prefix = prefix_sums(&closes);
|
let close_prefix = prefix_sums(&closes);
|
||||||
let prev_close_prefix = prefix_sums(&prev_closes);
|
let prev_close_prefix = prefix_sums(&prev_closes);
|
||||||
@@ -513,7 +542,12 @@ impl BenchmarkPriceSeries {
|
|||||||
self.moving_average_for(date, lookback, PriceField::Close)
|
self.moving_average_for(date, lookback, PriceField::Close)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn moving_average_for(&self, date: NaiveDate, lookback: usize, field: PriceField) -> Option<f64> {
|
fn moving_average_for(
|
||||||
|
&self,
|
||||||
|
date: NaiveDate,
|
||||||
|
lookback: usize,
|
||||||
|
field: PriceField,
|
||||||
|
) -> Option<f64> {
|
||||||
if lookback == 0 {
|
if lookback == 0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -875,12 +909,7 @@ impl DataSet {
|
|||||||
.and_then(|series| series.decision_volume_moving_average(date, lookback))
|
.and_then(|series| series.decision_volume_moving_average(date, lookback))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn factor_numeric_value(
|
pub fn factor_numeric_value(&self, date: NaiveDate, symbol: &str, field: &str) -> Option<f64> {
|
||||||
&self,
|
|
||||||
date: NaiveDate,
|
|
||||||
symbol: &str,
|
|
||||||
field: &str,
|
|
||||||
) -> Option<f64> {
|
|
||||||
self.factor(date, symbol)
|
self.factor(date, symbol)
|
||||||
.and_then(|snapshot| factor_numeric_value(snapshot, field))
|
.and_then(|snapshot| factor_numeric_value(snapshot, field))
|
||||||
}
|
}
|
||||||
@@ -922,16 +951,14 @@ impl DataSet {
|
|||||||
lookback: usize,
|
lookback: usize,
|
||||||
) -> Option<f64> {
|
) -> Option<f64> {
|
||||||
match field {
|
match field {
|
||||||
"close" | "prev_close" | "stock_close" | "price" => {
|
"close" | "prev_close" | "stock_close" | "price" => self
|
||||||
self.market_series_by_symbol
|
.market_series_by_symbol
|
||||||
.get(symbol)
|
.get(symbol)
|
||||||
.and_then(|series| series.decision_close_rolling_average(date, lookback))
|
.and_then(|series| series.decision_close_rolling_average(date, lookback)),
|
||||||
}
|
"volume" | "stock_volume" => self
|
||||||
"volume" | "stock_volume" => {
|
.market_series_by_symbol
|
||||||
self.market_series_by_symbol
|
|
||||||
.get(symbol)
|
.get(symbol)
|
||||||
.and_then(|series| series.decision_volume_rolling_average(date, lookback))
|
.and_then(|series| series.decision_volume_rolling_average(date, lookback)),
|
||||||
}
|
|
||||||
"open" => self.market_moving_average(date, symbol, lookback, PriceField::Open),
|
"open" => self.market_moving_average(date, symbol, lookback, PriceField::Open),
|
||||||
"last" | "last_price" => {
|
"last" | "last_price" => {
|
||||||
self.market_moving_average(date, symbol, lookback, PriceField::Last)
|
self.market_moving_average(date, symbol, lookback, PriceField::Last)
|
||||||
@@ -1178,6 +1205,13 @@ fn read_corporate_actions(path: &Path) -> Result<Vec<CorporateAction>, DataSetEr
|
|||||||
issue_price: row.parse_optional_f64(6 + offset).unwrap_or(0.0),
|
issue_price: row.parse_optional_f64(6 + offset).unwrap_or(0.0),
|
||||||
reform: row.parse_optional_bool(7 + offset).unwrap_or(false),
|
reform: row.parse_optional_bool(7 + offset).unwrap_or(false),
|
||||||
adjust_factor: row.parse_optional_f64(8 + offset),
|
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)
|
Ok(snapshots)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::broker::{BrokerExecutionReport, BrokerSimulator};
|
|||||||
use crate::cost::CostModel;
|
use crate::cost::CostModel;
|
||||||
use crate::data::{BenchmarkSnapshot, DataSet, DataSetError, PriceField};
|
use crate::data::{BenchmarkSnapshot, DataSet, DataSetError, PriceField};
|
||||||
use crate::events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent};
|
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::portfolio::{CashReceivable, HoldingSummary, PortfolioState};
|
||||||
use crate::rules::EquityRuleHooks;
|
use crate::rules::EquityRuleHooks;
|
||||||
use crate::strategy::{Strategy, StrategyContext};
|
use crate::strategy::{Strategy, StrategyContext};
|
||||||
@@ -177,18 +177,18 @@ where
|
|||||||
&mut corporate_action_notes,
|
&mut corporate_action_notes,
|
||||||
)?;
|
)?;
|
||||||
self.extend_result(&mut result, receivable_report);
|
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(
|
let corporate_action_report = self.apply_corporate_actions(
|
||||||
execution_date,
|
execution_date,
|
||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&mut corporate_action_notes,
|
&mut corporate_action_notes,
|
||||||
)?;
|
)?;
|
||||||
self.extend_result(&mut result, corporate_action_report);
|
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
|
let decision = execution_idx
|
||||||
.checked_sub(self.config.decision_lag_trading_days)
|
.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();
|
portfolio.prune_flat_positions();
|
||||||
|
|||||||
@@ -181,6 +181,17 @@ pub struct PortfolioState {
|
|||||||
cash_receivables: Vec<CashReceivable>,
|
cash_receivables: Vec<CashReceivable>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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 {
|
impl PortfolioState {
|
||||||
pub fn new(initial_cash: f64) -> Self {
|
pub fn new(initial_cash: f64) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -290,6 +301,80 @@ impl PortfolioState {
|
|||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn apply_successor_conversion(
|
||||||
|
&mut self,
|
||||||
|
old_symbol: &str,
|
||||||
|
new_symbol: &str,
|
||||||
|
ratio: f64,
|
||||||
|
cash_per_old_share: f64,
|
||||||
|
) -> Option<SuccessorConversionOutcome> {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
let expected_total = round_half_up_u32(old_quantity as f64 * ratio);
|
||||||
|
let scaled_total = converted_lots.iter().map(|lot| lot.quantity).sum::<u32>();
|
||||||
|
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::<u32>();
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use fidc_core::{
|
use fidc_core::{
|
||||||
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
|
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
|
||||||
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
ChinaAShareCostModel, ChinaEquityRuleHooks, CorporateAction, DailyFactorSnapshot,
|
||||||
Instrument, OrderIntent, PriceField, Strategy, StrategyContext, StrategyDecision,
|
DailyMarketSnapshot, DataSet, Instrument, OrderIntent, PriceField, Strategy, StrategyContext,
|
||||||
|
StrategyDecision,
|
||||||
};
|
};
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
@@ -144,6 +145,7 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
date: date1,
|
date: date1,
|
||||||
@@ -153,6 +155,7 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
date: date2,
|
date: date2,
|
||||||
@@ -162,6 +165,7 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
vec![
|
vec![
|
||||||
@@ -248,10 +252,249 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
|
|||||||
.any(|fill| fill.reason.contains("delisted_cash_settlement")
|
.any(|fill| fill.reason.contains("delisted_cash_settlement")
|
||||||
&& fill.symbol == "000001.SZ")
|
&& fill.symbol == "000001.SZ")
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(result
|
||||||
result
|
|
||||||
.holdings_summary
|
.holdings_summary
|
||||||
.iter()
|
.iter()
|
||||||
.all(|holding| holding.symbol != "000001.SZ")
|
.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")));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user