修复Smart调仓缺行情处理
This commit is contained in:
+178
-12
@@ -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
|
||||||
.ok_or_else(|| BacktestError::MissingPrice {
|
.rebalance_valuation_price_for_snapshot(snapshot)
|
||||||
date,
|
.ok_or_else(|| BacktestError::MissingPrice {
|
||||||
symbol: symbol.to_string(),
|
date,
|
||||||
field: self.rebalance_valuation_price_field_name(),
|
symbol: symbol.to_string(),
|
||||||
})?;
|
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");
|
||||||
|
|||||||
Reference in New Issue
Block a user