修正退市持仓虚假现金兑付
This commit is contained in:
@@ -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
|
||||
);
|
||||
if instrument.delisted_at == Some(date) || instrument.delisted_at.is_none() {
|
||||
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,
|
||||
});
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
report
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
.all(|fill| !fill.reason.contains("delisted_cash_settlement"))
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
let unresolved = result
|
||||
.holdings_summary
|
||||
.iter()
|
||||
.all(|holding| holding.symbol != "000001.SZ")
|
||||
);
|
||||
.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(
|
||||
|
||||
Reference in New Issue
Block a user