fix: reject missing daily execution prices per order
This commit is contained in:
+270
-13
@@ -1039,16 +1039,23 @@ where
|
|||||||
.or(self.intraday_execution_start_time)
|
.or(self.intraday_execution_start_time)
|
||||||
.map(|start_time| date.and_time(start_time));
|
.map(|start_time| date.and_time(start_time));
|
||||||
let matching_type = self.matching_type_for_algo_request(None);
|
let matching_type = self.matching_type_for_algo_request(None);
|
||||||
self.latest_known_quote_at_or_before(
|
let execution_price = self
|
||||||
data.execution_quotes_on(date, symbol),
|
.latest_known_quote_at_or_before(
|
||||||
start_cursor,
|
data.execution_quotes_on(date, symbol),
|
||||||
snapshot,
|
start_cursor,
|
||||||
side,
|
snapshot,
|
||||||
matching_type,
|
side,
|
||||||
false,
|
matching_type,
|
||||||
)
|
false,
|
||||||
.and_then(|quote| self.select_quote_reference_price(snapshot, quote, side, matching_type))
|
)
|
||||||
.unwrap_or_else(|| self.sizing_price(snapshot))
|
.and_then(|quote| {
|
||||||
|
self.select_quote_reference_price(snapshot, quote, side, matching_type)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| self.sizing_price(snapshot));
|
||||||
|
if execution_price.is_finite() && execution_price > 0.0 {
|
||||||
|
return execution_price;
|
||||||
|
}
|
||||||
|
self.target_value_valuation_price(date, data, symbol, snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn snapshot_execution_price(
|
fn snapshot_execution_price(
|
||||||
@@ -1080,6 +1087,31 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn missing_daily_execution_price_reason(
|
||||||
|
&self,
|
||||||
|
snapshot: &crate::data::DailyMarketSnapshot,
|
||||||
|
algo_request: Option<&AlgoExecutionRequest>,
|
||||||
|
) -> Option<&'static str> {
|
||||||
|
if algo_request.is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (price, reason) = match self.matching_type {
|
||||||
|
MatchingType::OpenAuction => {
|
||||||
|
(snapshot.day_open, "missing_execution_price field=day_open")
|
||||||
|
}
|
||||||
|
MatchingType::CurrentBarClose => {
|
||||||
|
(snapshot.close, "missing_execution_price field=close")
|
||||||
|
}
|
||||||
|
MatchingType::NextBarOpen => (snapshot.open, "missing_execution_price field=open"),
|
||||||
|
MatchingType::MinuteLast
|
||||||
|
| MatchingType::MinuteBestOwn
|
||||||
|
| MatchingType::MinuteBestCounterparty
|
||||||
|
| MatchingType::Vwap
|
||||||
|
| MatchingType::Twap => return None,
|
||||||
|
};
|
||||||
|
(!price.is_finite() || price <= 0.0).then_some(reason)
|
||||||
|
}
|
||||||
|
|
||||||
fn snapshot_mark_price(
|
fn snapshot_mark_price(
|
||||||
&self,
|
&self,
|
||||||
snapshot: &crate::data::DailyMarketSnapshot,
|
snapshot: &crate::data::DailyMarketSnapshot,
|
||||||
@@ -3153,6 +3185,47 @@ where
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reject_missing_execution_price_order(
|
||||||
|
report: &mut BrokerExecutionReport,
|
||||||
|
date: NaiveDate,
|
||||||
|
order_id: u64,
|
||||||
|
symbol: &str,
|
||||||
|
side: OrderSide,
|
||||||
|
requested_quantity: u32,
|
||||||
|
reason: &str,
|
||||||
|
missing_reason: &'static str,
|
||||||
|
emit_creation_events: bool,
|
||||||
|
) {
|
||||||
|
report.order_events.push(OrderEvent {
|
||||||
|
date,
|
||||||
|
decision_date: None,
|
||||||
|
order_created_date: None,
|
||||||
|
execution_date: None,
|
||||||
|
order_id: Some(order_id),
|
||||||
|
symbol: symbol.to_string(),
|
||||||
|
side,
|
||||||
|
requested_quantity,
|
||||||
|
filled_quantity: 0,
|
||||||
|
status: OrderStatus::Rejected,
|
||||||
|
reason: format!("{reason}: {missing_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={missing_reason} historical_price_fallback=false"
|
||||||
|
),
|
||||||
|
);
|
||||||
|
report.diagnostics.push(format!(
|
||||||
|
"order_execution_price_unavailable symbol={symbol} side={} requested={requested_quantity} reason={missing_reason} historical_price_fallback=false",
|
||||||
|
side.as_str()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -4263,6 +4336,24 @@ where
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(missing_reason) =
|
||||||
|
self.missing_daily_execution_price_reason(snapshot, algo_request)
|
||||||
|
{
|
||||||
|
Self::reject_missing_execution_price_order(
|
||||||
|
report,
|
||||||
|
date,
|
||||||
|
order_id,
|
||||||
|
symbol,
|
||||||
|
OrderSide::Sell,
|
||||||
|
requested_qty,
|
||||||
|
reason,
|
||||||
|
missing_reason,
|
||||||
|
emit_creation_events,
|
||||||
|
);
|
||||||
|
self.clear_open_order(order_id);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let size_check_price = limit_price.unwrap_or_else(|| {
|
let size_check_price = limit_price.unwrap_or_else(|| {
|
||||||
self.execution_order_limit_check_price(
|
self.execution_order_limit_check_price(
|
||||||
date,
|
date,
|
||||||
@@ -6042,6 +6133,24 @@ where
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(missing_reason) =
|
||||||
|
self.missing_daily_execution_price_reason(snapshot, algo_request)
|
||||||
|
{
|
||||||
|
Self::reject_missing_execution_price_order(
|
||||||
|
report,
|
||||||
|
date,
|
||||||
|
order_id,
|
||||||
|
symbol,
|
||||||
|
OrderSide::Buy,
|
||||||
|
requested_qty,
|
||||||
|
reason,
|
||||||
|
missing_reason,
|
||||||
|
emit_creation_events,
|
||||||
|
);
|
||||||
|
self.clear_open_order(order_id);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let current_position_quantity = portfolio
|
let current_position_quantity = portfolio
|
||||||
.position(symbol)
|
.position(symbol)
|
||||||
.map(|position| position.quantity)
|
.map(|position| position.quantity)
|
||||||
@@ -6683,10 +6792,15 @@ where
|
|||||||
snapshot.price(self.execution_price_field)
|
snapshot.price(self.execution_price_field)
|
||||||
};
|
};
|
||||||
if price.is_finite() && price > 0.0 {
|
if price.is_finite() && price > 0.0 {
|
||||||
Some(price)
|
return Some(price);
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
if self.matching_type == MatchingType::NextBarOpen
|
||||||
|
&& snapshot.close.is_finite()
|
||||||
|
&& snapshot.close > 0.0
|
||||||
|
{
|
||||||
|
return Some(snapshot.close);
|
||||||
|
}
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rebalance_valuation_price_with_overrides(
|
fn rebalance_valuation_price_with_overrides(
|
||||||
@@ -7943,6 +8057,34 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn unpriced_next_open_test_data(paused: bool) -> DataSet {
|
||||||
|
let mut snapshot = limit_test_snapshot();
|
||||||
|
snapshot.day_open = 0.0;
|
||||||
|
snapshot.open = 0.0;
|
||||||
|
snapshot.close = 10.0;
|
||||||
|
snapshot.last_price = 10.0;
|
||||||
|
snapshot.paused = paused;
|
||||||
|
if paused {
|
||||||
|
snapshot.high = 0.0;
|
||||||
|
snapshot.low = 0.0;
|
||||||
|
snapshot.volume = 0;
|
||||||
|
snapshot.upper_limit = 0.0;
|
||||||
|
snapshot.lower_limit = 0.0;
|
||||||
|
}
|
||||||
|
let mut candidate = limit_test_candidate(!paused, !paused);
|
||||||
|
candidate.is_paused = paused;
|
||||||
|
DataSet::from_components_with_actions_and_quotes(
|
||||||
|
vec![limit_test_instrument()],
|
||||||
|
vec![snapshot],
|
||||||
|
Vec::new(),
|
||||||
|
vec![candidate],
|
||||||
|
vec![limit_test_benchmark()],
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.expect("unpriced next-open dataset")
|
||||||
|
}
|
||||||
|
|
||||||
fn target_position_slot_test_data(block_exit: bool) -> DataSet {
|
fn target_position_slot_test_data(block_exit: bool) -> DataSet {
|
||||||
let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"];
|
let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"];
|
||||||
let instruments = symbols
|
let instruments = symbols
|
||||||
@@ -10357,6 +10499,121 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_open_unpriced_active_snapshot_rejects_without_historical_fill() {
|
||||||
|
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
||||||
|
let data = unpriced_next_open_test_data(false);
|
||||||
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
|
ChinaAShareCostModel::default(),
|
||||||
|
ChinaEquityRuleHooks,
|
||||||
|
PriceField::Open,
|
||||||
|
)
|
||||||
|
.with_volume_limit(false)
|
||||||
|
.with_liquidity_limit(false)
|
||||||
|
.with_inactive_limit(false);
|
||||||
|
let mut portfolio = PortfolioState::new(20_000.0);
|
||||||
|
let decision = StrategyDecision {
|
||||||
|
order_intents: vec![OrderIntent::TargetValue {
|
||||||
|
symbol: "000001.SZ".to_string(),
|
||||||
|
target_value: 10_000.0,
|
||||||
|
reason: "unpriced_next_open".to_string(),
|
||||||
|
}],
|
||||||
|
..StrategyDecision::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let report = broker
|
||||||
|
.execute(date, &mut portfolio, &data, &decision)
|
||||||
|
.expect("missing execution price must reject only the affected order");
|
||||||
|
|
||||||
|
assert!(report.fill_events.is_empty());
|
||||||
|
assert!(portfolio.position("000001.SZ").is_none());
|
||||||
|
assert!(report.order_events.iter().any(|event| {
|
||||||
|
event.symbol == "000001.SZ"
|
||||||
|
&& event.requested_quantity > 0
|
||||||
|
&& event.filled_quantity == 0
|
||||||
|
&& event.status == OrderStatus::Rejected
|
||||||
|
&& event.reason.contains("missing_execution_price field=open")
|
||||||
|
}));
|
||||||
|
assert!(report.process_events.iter().any(|event| {
|
||||||
|
event.detail.contains("missing_execution_price field=open")
|
||||||
|
&& event.detail.contains("historical_price_fallback=false")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_open_unpriced_paused_snapshot_prefers_execution_day_pause_risk() {
|
||||||
|
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
||||||
|
let data = unpriced_next_open_test_data(true);
|
||||||
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
|
ChinaAShareCostModel::default(),
|
||||||
|
ChinaEquityRuleHooks,
|
||||||
|
PriceField::Open,
|
||||||
|
)
|
||||||
|
.with_volume_limit(false)
|
||||||
|
.with_liquidity_limit(false)
|
||||||
|
.with_inactive_limit(false);
|
||||||
|
let mut portfolio = PortfolioState::new(20_000.0);
|
||||||
|
let decision = StrategyDecision {
|
||||||
|
order_intents: vec![OrderIntent::TargetValue {
|
||||||
|
symbol: "000001.SZ".to_string(),
|
||||||
|
target_value: 10_000.0,
|
||||||
|
reason: "unpriced_paused_next_open".to_string(),
|
||||||
|
}],
|
||||||
|
..StrategyDecision::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let report = broker
|
||||||
|
.execute(date, &mut portfolio, &data, &decision)
|
||||||
|
.expect("execution-day pause must reject without aborting the run");
|
||||||
|
|
||||||
|
assert!(report.fill_events.is_empty());
|
||||||
|
assert!(portfolio.position("000001.SZ").is_none());
|
||||||
|
assert!(report.order_events.iter().any(|event| {
|
||||||
|
event.symbol == "000001.SZ"
|
||||||
|
&& event.requested_quantity > 0
|
||||||
|
&& event.filled_quantity == 0
|
||||||
|
&& event.status == OrderStatus::Canceled
|
||||||
|
&& event.reason.ends_with(": paused")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_open_target_portfolio_unpriced_symbol_does_not_abort_batch() {
|
||||||
|
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
||||||
|
let data = unpriced_next_open_test_data(false);
|
||||||
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
|
ChinaAShareCostModel::default(),
|
||||||
|
ChinaEquityRuleHooks,
|
||||||
|
PriceField::Open,
|
||||||
|
)
|
||||||
|
.with_volume_limit(false)
|
||||||
|
.with_liquidity_limit(false)
|
||||||
|
.with_inactive_limit(false);
|
||||||
|
let mut portfolio = PortfolioState::new(20_000.0);
|
||||||
|
let decision = StrategyDecision {
|
||||||
|
order_intents: vec![OrderIntent::TargetPortfolioSmart {
|
||||||
|
target_weights: BTreeMap::from([("000001.SZ".to_string(), 0.5)]),
|
||||||
|
order_prices: None,
|
||||||
|
valuation_prices: None,
|
||||||
|
reason: "unpriced_target_portfolio".to_string(),
|
||||||
|
}],
|
||||||
|
..StrategyDecision::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let report = broker
|
||||||
|
.execute(date, &mut portfolio, &data, &decision)
|
||||||
|
.expect("one unpriced target must not abort the target portfolio batch");
|
||||||
|
|
||||||
|
assert!(report.fill_events.is_empty());
|
||||||
|
assert!(portfolio.position("000001.SZ").is_none());
|
||||||
|
assert!(report.order_events.iter().any(|event| {
|
||||||
|
event.symbol == "000001.SZ"
|
||||||
|
&& event.requested_quantity > 0
|
||||||
|
&& event.status == OrderStatus::Rejected
|
||||||
|
&& event.reason.contains("missing_execution_price field=open")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn target_portfolio_smart_uses_prior_close_only_for_missing_day_valuation() {
|
fn target_portfolio_smart_uses_prior_close_only_for_missing_day_valuation() {
|
||||||
let trade_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
let trade_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
||||||
|
|||||||
@@ -413,7 +413,10 @@ impl ChinaAShareRiskControl {
|
|||||||
RiskCheckScope::Buy => config.static_rules.reject_one_yuan_buy,
|
RiskCheckScope::Buy => config.static_rules.reject_one_yuan_buy,
|
||||||
RiskCheckScope::Sell => false,
|
RiskCheckScope::Sell => false,
|
||||||
};
|
};
|
||||||
if reject_one_yuan && (candidate.is_one_yuan || market.day_open <= 1.0) {
|
if reject_one_yuan
|
||||||
|
&& (candidate.is_one_yuan
|
||||||
|
|| (market.day_open.is_finite() && market.day_open > 0.0 && market.day_open <= 1.0))
|
||||||
|
{
|
||||||
return Some("one_yuan");
|
return Some("one_yuan");
|
||||||
}
|
}
|
||||||
if Self::missing_risk_state_rejected(candidate, config, scope) {
|
if Self::missing_risk_state_rejected(candidate, config, scope) {
|
||||||
|
|||||||
Reference in New Issue
Block a user