修正退市持仓虚假现金兑付

This commit is contained in:
boris
2026-07-15 20:48:17 +08:00
parent d63ac73903
commit e7d1c875fd
3 changed files with 50 additions and 98 deletions
+18 -87
View File
@@ -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<String>,
) -> Result<BrokerExecutionReport, BacktestError> {
let mut report = BrokerExecutionReport::default();
) -> BrokerExecutionReport {
let report = BrokerExecutionReport::default();
let symbols = portfolio.positions().keys().cloned().collect::<Vec<_>>();
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
}
}
+12
View File
@@ -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
+20 -11
View File
@@ -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(