修复Smart调仓缺行情处理

This commit is contained in:
boris
2026-06-29 16:35:21 +08:00
parent 1623994287
commit 9e6eac557f
+174 -8
View File
@@ -1671,6 +1671,52 @@ where
); );
} }
fn reject_unavailable_order(
report: &mut BrokerExecutionReport,
date: NaiveDate,
order_id: u64,
symbol: &str,
side: OrderSide,
requested_quantity: u32,
reason: &str,
unavailable_reason: impl Into<String>,
emit_creation_events: bool,
) {
let unavailable_reason = unavailable_reason.into();
if emit_creation_events {
Self::emit_order_process_event(
report,
date,
ProcessEventKind::OrderPendingNew,
order_id,
symbol,
side,
format!("requested_quantity={requested_quantity} reason={reason}"),
);
}
report.order_events.push(OrderEvent {
date,
order_id: Some(order_id),
symbol: symbol.to_string(),
side,
requested_quantity,
filled_quantity: 0,
status: OrderStatus::Rejected,
reason: format!("{reason}: {unavailable_reason}"),
});
Self::emit_order_process_event(
report,
date,
Self::creation_reject_kind(emit_creation_events),
order_id,
symbol,
side,
format!(
"status=Rejected requested_quantity={requested_quantity} filled_quantity=0 reason={unavailable_reason}"
),
);
}
fn creation_reject_kind(emit_creation_events: bool) -> ProcessEventKind { fn creation_reject_kind(emit_creation_events: bool) -> ProcessEventKind {
if emit_creation_events { if emit_creation_events {
ProcessEventKind::OrderCreationReject ProcessEventKind::OrderCreationReject
@@ -2469,11 +2515,40 @@ where
algo_request: Option<&AlgoExecutionRequest>, algo_request: Option<&AlgoExecutionRequest>,
report: &mut BrokerExecutionReport, report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> { ) -> Result<(), BacktestError> {
let snapshot = data.require_market(date, symbol)?;
let candidate = data.require_candidate(date, symbol)?;
let Some(position) = portfolio.position(symbol) else { let Some(position) = portfolio.position(symbol) else {
return Ok(()); return Ok(());
}; };
let Some(snapshot) = data.market(date, symbol) else {
Self::reject_unavailable_order(
report,
date,
order_id,
symbol,
OrderSide::Sell,
requested_qty,
reason,
format!(
"market snapshot is missing for price field {}",
price_field_name(self.execution_price_field)
),
emit_creation_events,
);
return Ok(());
};
let Some(candidate) = data.candidate(date, symbol) else {
Self::reject_unavailable_order(
report,
date,
order_id,
symbol,
OrderSide::Sell,
requested_qty,
reason,
"candidate eligibility is missing",
emit_creation_events,
);
return Ok(());
};
if emit_creation_events { if emit_creation_events {
Self::emit_order_process_event( Self::emit_order_process_event(
@@ -4083,8 +4158,37 @@ where
algo_request: Option<&AlgoExecutionRequest>, algo_request: Option<&AlgoExecutionRequest>,
report: &mut BrokerExecutionReport, report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> { ) -> Result<(), BacktestError> {
let snapshot = data.require_market(date, symbol)?; let Some(snapshot) = data.market(date, symbol) else {
let candidate = data.require_candidate(date, symbol)?; Self::reject_unavailable_order(
report,
date,
order_id,
symbol,
OrderSide::Buy,
requested_qty,
reason,
format!(
"market snapshot is missing for price field {}",
price_field_name(self.execution_price_field)
),
emit_creation_events,
);
return Ok(());
};
let Some(candidate) = data.candidate(date, symbol) else {
Self::reject_unavailable_order(
report,
date,
order_id,
symbol,
OrderSide::Buy,
requested_qty,
reason,
"candidate eligibility is missing",
emit_creation_events,
);
return Ok(());
};
if emit_creation_events { if emit_creation_events {
Self::emit_order_process_event( Self::emit_order_process_event(
@@ -4679,14 +4783,16 @@ where
field: "custom valuation", field: "custom valuation",
}); });
} }
let snapshot = data if let Some(snapshot) = data.market(date, symbol) {
.market(date, symbol) return self
.rebalance_valuation_price_for_snapshot(snapshot)
.ok_or_else(|| BacktestError::MissingPrice { .ok_or_else(|| BacktestError::MissingPrice {
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
field: self.rebalance_valuation_price_field_name(), field: self.rebalance_valuation_price_field_name(),
})?; });
self.rebalance_valuation_price_for_snapshot(snapshot) }
data.price_on_or_before(date, symbol, PriceField::Close)
.ok_or_else(|| BacktestError::MissingPrice { .ok_or_else(|| BacktestError::MissingPrice {
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
@@ -5734,6 +5840,66 @@ mod tests {
assert!(order.reason.contains("market snapshot is missing")); assert!(order.reason.contains("market snapshot is missing"));
} }
#[test]
fn target_portfolio_smart_keeps_position_when_sell_snapshot_missing() {
let trade_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
let missing_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date");
let symbol = "000001.SZ";
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Open,
)
.with_volume_limit(false)
.with_liquidity_limit(false);
let data = DataSet::from_components_with_actions_and_quotes(
vec![limit_test_instrument()],
vec![limit_test_snapshot()],
Vec::new(),
vec![limit_test_candidate(true, true)],
vec![limit_test_benchmark()],
Vec::new(),
Vec::new(),
)
.expect("valid dataset");
let mut portfolio = PortfolioState::new(100_000.0);
portfolio.position_mut(symbol).buy(trade_date, 1_000, 10.0);
let mut report = BrokerExecutionReport::default();
broker
.process_target_portfolio_smart(
missing_date,
&mut portfolio,
&data,
&BTreeMap::new(),
None,
None,
"target_portfolio_rebalance",
&mut BTreeMap::new(),
&mut BTreeMap::new(),
&mut None,
&mut BTreeMap::new(),
&mut report,
)
.expect("missing market snapshot should keep position, not abort rebalance");
assert_eq!(
portfolio.position(symbol).map(|pos| pos.quantity),
Some(1_000)
);
assert!(report.fill_events.is_empty());
assert!(report.order_events.is_empty());
assert!(
report
.diagnostics
.iter()
.any(|item| item.contains("rebalance_target_clipped")
&& item.contains("000001.SZ")),
"{:?}",
report.diagnostics
);
}
#[test] #[test]
fn timed_target_value_zero_sells_full_position_at_requested_time_quote() { fn timed_target_value_zero_sells_full_position_at_requested_time_quote() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");