diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 9b1a981..ab404b3 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -110,6 +110,7 @@ pub struct BrokerSimulator { volume_limit: bool, inactive_limit: bool, liquidity_limit: bool, + strict_value_budget: bool, intraday_execution_start_time: Option, runtime_intraday_start_time: Cell>, runtime_intraday_end_time: Cell>, @@ -130,6 +131,7 @@ impl BrokerSimulator { volume_limit: true, inactive_limit: true, liquidity_limit: true, + strict_value_budget: false, intraday_execution_start_time: None, runtime_intraday_start_time: Cell::new(None), runtime_intraday_end_time: Cell::new(None), @@ -154,6 +156,7 @@ impl BrokerSimulator { volume_limit: true, inactive_limit: true, liquidity_limit: true, + strict_value_budget: false, intraday_execution_start_time: None, runtime_intraday_start_time: Cell::new(None), runtime_intraday_end_time: Cell::new(None), @@ -177,6 +180,11 @@ impl BrokerSimulator { self } + pub fn with_strict_value_budget(mut self, enabled: bool) -> Self { + self.strict_value_budget = enabled; + self + } + pub fn with_volume_percent(mut self, volume_percent: f64) -> Self { self.volume_percent = volume_percent; self @@ -3388,6 +3396,16 @@ where requested_qty } + fn value_budget_gross_limit(&self, value_budget: Option) -> Option { + value_budget.map(|budget| { + if self.strict_value_budget { + budget + } else { + budget + 400.0 + } + }) + } + fn process_buy( &self, date: NaiveDate, @@ -3559,7 +3577,7 @@ where execution_cursors, None, Some(portfolio.cash()), - value_budget.map(|budget| budget + 400.0), + self.value_budget_gross_limit(value_budget), algo_request, limit_price, ); @@ -3590,7 +3608,7 @@ where let filled_qty = self.affordable_buy_quantity( date, portfolio.cash(), - value_budget.map(|budget| budget + 400.0), + self.value_budget_gross_limit(value_budget), execution_price, constrained_qty, self.minimum_order_quantity(data, symbol), @@ -3601,7 +3619,7 @@ where partial_fill_reason, self.buy_reduction_reason( portfolio.cash(), - value_budget.map(|budget| budget + 400.0), + self.value_budget_gross_limit(value_budget), execution_price, constrained_qty, filled_qty, @@ -3660,7 +3678,7 @@ where side: OrderSide::Buy, requested_quantity: requested_qty, filled_quantity: 0, - status: OrderStatus::Rejected, + status: zero_fill_status_for_reason(detail), reason: format!("{reason}: {detail}"), }); Self::emit_order_process_event( @@ -3670,7 +3688,10 @@ where order_id, symbol, OrderSide::Buy, - format!("status=Rejected reason={detail}"), + format!( + "status={:?} reason={detail}", + zero_fill_status_for_reason(detail) + ), ); self.clear_open_order(order_id); return Ok(()); @@ -4255,57 +4276,43 @@ where } if algo_request.is_some() || self.intraday_execution_start_time.is_some() { - let execution_price = self.snapshot_execution_price(snapshot, side); - if !self.price_satisfies_limit( - side, - execution_price, - limit_price, - snapshot.effective_price_tick(), - ) { - return None; - } - let execution_price = - self.execution_price_with_limit_slippage(execution_price, limit_price); - let quantity = match side { - OrderSide::Buy => self.affordable_buy_quantity( - date, - cash_limit.unwrap_or(f64::INFINITY), - gross_limit, - execution_price, - requested_qty, - minimum_order_quantity, - order_step_size, - ), - OrderSide::Sell => requested_qty, - }; - if quantity == 0 { - return None; - } let next_cursor = algo_request .and_then(|request| request.start_time) .or(self.intraday_execution_start_time) .map(|start_time| date.and_time(start_time) + Duration::seconds(1)) .unwrap_or_else(|| date.and_hms_opt(0, 0, 1).expect("valid midnight")); return Some(ExecutionFill { - quantity, + quantity: 0, next_cursor, - legs: vec![ExecutionLeg { - price: execution_price, - quantity, - }], - unfilled_reason: self.buy_reduction_reason( - cash_limit.unwrap_or(f64::INFINITY), - gross_limit, - execution_price, - requested_qty, - quantity, - ), + legs: Vec::new(), + unfilled_reason: Some(self.empty_intraday_quote_reason( + quotes, + start_cursor, + end_cursor, + )), }); } None } + fn empty_intraday_quote_reason( + &self, + quotes: &[IntradayExecutionQuote], + start_cursor: Option, + end_cursor: Option, + ) -> &'static str { + let saw_quote_in_window = quotes.iter().any(|quote| { + !start_cursor.is_some_and(|cursor| quote.timestamp < cursor) + && !end_cursor.is_some_and(|cursor| quote.timestamp > cursor) + }); + if saw_quote_in_window { + "intraday quote liquidity exhausted" + } else { + "no execution quotes after start" + } + } + fn select_execution_fill( &self, snapshot: &crate::data::DailyMarketSnapshot, @@ -4487,7 +4494,10 @@ fn merge_partial_fill_reason(current: Option, next: Option<&str>) -> Opt fn zero_fill_status_for_reason(reason: &str) -> OrderStatus { match reason { - "tick no volume" | "tick volume limit" => OrderStatus::Canceled, + "tick no volume" + | "tick volume limit" + | "intraday quote liquidity exhausted" + | "no execution quotes after start" => OrderStatus::Canceled, _ => OrderStatus::Rejected, } } diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index d8ac334..69c5af6 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -682,6 +682,23 @@ impl BenchmarkPriceSeries { self.moving_average_for(date, lookback, PriceField::Close) } + fn decision_moving_average(&self, date: NaiveDate, lookback: usize) -> Option { + if lookback == 0 { + return None; + } + let end = match self.dates.binary_search(&date) { + Ok(idx) => idx, + Err(0) => return None, + Err(idx) => idx, + }; + if end < lookback { + return None; + } + let start = end - lookback; + let sum = self.close_prefix[end] - self.close_prefix[start]; + Some(sum / lookback as f64) + } + fn moving_average_for( &self, date: NaiveDate, @@ -2123,6 +2140,15 @@ impl DataSet { self.benchmark_series_cache.moving_average(date, lookback) } + pub fn benchmark_decision_moving_average( + &self, + date: NaiveDate, + lookback: usize, + ) -> Option { + self.benchmark_series_cache + .decision_moving_average(date, lookback) + } + pub fn benchmark_open_moving_average(&self, date: NaiveDate, lookback: usize) -> Option { self.benchmark_series_cache .moving_average_for(date, lookback, PriceField::Open) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 167f8af..cb43a76 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -199,6 +199,9 @@ pub struct PlatformExprStrategyConfig { pub skip_month_day_ranges: Vec<(u32, u32, u32)>, pub rebalance_schedule: Option, pub rotation_enabled: bool, + pub daily_top_up_enabled: bool, + pub retry_empty_rebalance: bool, + pub strict_value_budget: bool, pub explicit_action_stage: PlatformExplicitActionStage, pub explicit_action_schedule: Option, pub subscription_guard_required: bool, @@ -249,6 +252,9 @@ fn band_low(index_close) { skip_month_day_ranges: Vec::new(), rebalance_schedule: None, rotation_enabled: true, + daily_top_up_enabled: false, + retry_empty_rebalance: false, + strict_value_budget: false, explicit_action_stage: PlatformExplicitActionStage::OnDay, explicit_action_schedule: None, subscription_guard_required: false, @@ -518,10 +524,7 @@ impl PlatformExprStrategy { .engine .eval_ast_with_scope::(scope, ast) .map_err(|error| { - BacktestError::Execution(format!( - "platform expr eval failed: {}", - error - )) + BacktestError::Execution(format!("platform expr eval failed: {}", error)) }); } } @@ -875,53 +878,6 @@ impl PlatformExprStrategy { return None; } - if let Some(market) = ctx.data.market(date, symbol) { - let execution_price = self.projected_execution_price(market, side); - if execution_price.is_finite() && execution_price > 0.0 { - let quantity = match side { - OrderSide::Buy => { - let cash = cash_limit.unwrap_or(f64::INFINITY); - let mut take_qty = self.round_lot_quantity( - requested_qty, - minimum_order_quantity, - order_step_size, - ); - while take_qty > 0 { - let candidate_gross = execution_price * take_qty as f64; - if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { - take_qty = self.decrement_order_quantity( - take_qty, - minimum_order_quantity, - order_step_size, - ); - continue; - } - let candidate_cash = - candidate_gross + self.buy_commission(candidate_gross); - if candidate_cash <= cash + 1e-6 { - break; - } - take_qty = self.decrement_order_quantity( - take_qty, - minimum_order_quantity, - order_step_size, - ); - } - take_qty - } - OrderSide::Sell => requested_qty, - }; - if quantity > 0 { - return Some(ProjectedExecutionFill { - price: execution_price, - quantity, - next_cursor: date.and_time(self.intraday_execution_start_time()) - + Duration::seconds(1), - }); - } - } - } - let start_cursor = self.projected_execution_start_cursor(date, symbol, execution_state); let quotes = ctx.data.execution_quotes_on(date, symbol); let mut filled_qty = 0_u32; @@ -1036,12 +992,18 @@ impl PlatformExprStrategy { None, execution_state, ) - .unwrap_or(ProjectedExecutionFill { - price: self.projected_execution_price(market, OrderSide::Sell), - quantity, - next_cursor: date.and_time(self.intraday_execution_start_time()) - + Duration::seconds(1), - }); + .or_else(|| { + if ctx.data.execution_quotes_on(date, symbol).is_empty() { + None + } else { + Some(ProjectedExecutionFill { + price: self.projected_execution_price(market, OrderSide::Sell), + quantity, + next_cursor: date.and_time(self.intraday_execution_start_time()) + + Duration::seconds(1), + }) + } + })?; let gross_amount = fill.price * fill.quantity as f64; let net_cash = gross_amount - self.sell_cost(date, gross_amount); projected @@ -1090,9 +1052,14 @@ impl PlatformExprStrategy { ); let execution_price = self.projected_execution_price(market, OrderSide::Buy); let mut quantity = snapshot_requested_qty; + let gross_limit = if self.config.strict_value_budget { + order_value + } else { + order_value + 400.0 + }; while quantity > 0 { let gross_amount = execution_price * quantity as f64; - if gross_amount <= order_value + 400.0 + if gross_amount <= gross_limit && gross_amount + self.buy_commission(gross_amount) <= projected.cash() + 1e-6 { break; @@ -1115,15 +1082,24 @@ impl PlatformExprStrategy { order_step_size, false, Some(projected.cash()), - Some(order_value + 400.0), + Some(gross_limit), execution_state, ) - .unwrap_or(ProjectedExecutionFill { - price: execution_price, - quantity, - next_cursor: date.and_time(self.intraday_execution_start_time()) - + Duration::seconds(1), + .or_else(|| { + if ctx.data.execution_quotes_on(date, symbol).is_empty() { + None + } else { + Some(ProjectedExecutionFill { + price: execution_price, + quantity, + next_cursor: date.and_time(self.intraday_execution_start_time()) + + Duration::seconds(1), + }) + } }); + let Some(fill) = fill else { + return 0; + }; let gross_amount = fill.price * fill.quantity as f64; let cash_out = gross_amount + self.buy_commission(gross_amount); if cash_out > projected.cash() + 1e-6 { @@ -1185,46 +1161,56 @@ impl PlatformExprStrategy { let benchmark_close = benchmark.close; let benchmark_ma_short = ctx .data - .market_decision_close_moving_average( - date, - &self.config.signal_symbol, - self.config.benchmark_short_ma_days, - ) - .ok_or_else(|| { - BacktestError::Execution(format!( - "insufficient benchmark short MA history for {} on {}", - self.config.signal_symbol, date - )) - })?; + .benchmark_decision_moving_average(date, self.config.benchmark_short_ma_days) + .or_else(|| { + ctx.data + .benchmark_moving_average(date, self.config.benchmark_short_ma_days) + }) + .unwrap_or(benchmark_close); let benchmark_ma_long = ctx .data - .market_decision_close_moving_average( - date, - &self.config.signal_symbol, - self.config.benchmark_long_ma_days, - ) - .ok_or_else(|| { - BacktestError::Execution(format!( - "insufficient benchmark long MA history for {} on {}", - self.config.signal_symbol, date - )) - })?; + .benchmark_decision_moving_average(date, self.config.benchmark_long_ma_days) + .or_else(|| { + ctx.data + .benchmark_moving_average(date, self.config.benchmark_long_ma_days) + }) + .unwrap_or(benchmark_ma_short); let benchmark_ma5 = ctx .data - .market_decision_close_moving_average(date, &self.config.signal_symbol, 5) + .benchmark_decision_moving_average(date, 5) + .or_else(|| ctx.data.benchmark_moving_average(date, 5)) .unwrap_or(benchmark_ma_short); let benchmark_ma10 = ctx .data - .market_decision_close_moving_average(date, &self.config.signal_symbol, 10) + .benchmark_decision_moving_average(date, 10) + .or_else(|| ctx.data.benchmark_moving_average(date, 10)) .unwrap_or(benchmark_ma_long); let benchmark_ma20 = ctx .data - .market_decision_close_moving_average(date, &self.config.signal_symbol, 20) + .benchmark_decision_moving_average(date, 20) + .or_else(|| ctx.data.benchmark_moving_average(date, 20)) .unwrap_or(benchmark_ma10); let benchmark_ma30 = ctx .data - .market_decision_close_moving_average(date, &self.config.signal_symbol, 30) + .benchmark_decision_moving_average(date, 30) + .or_else(|| ctx.data.benchmark_moving_average(date, 30)) .unwrap_or(benchmark_ma20); + let signal_ma5 = ctx + .data + .market_decision_close_moving_average(date, &self.config.signal_symbol, 5) + .unwrap_or(benchmark_ma5); + let signal_ma10 = ctx + .data + .market_decision_close_moving_average(date, &self.config.signal_symbol, 10) + .unwrap_or(benchmark_ma10); + let signal_ma20 = ctx + .data + .market_decision_close_moving_average(date, &self.config.signal_symbol, 20) + .unwrap_or(benchmark_ma20); + let signal_ma30 = ctx + .data + .market_decision_close_moving_average(date, &self.config.signal_symbol, 30) + .unwrap_or(benchmark_ma30); let account = ctx.account(); let cash = account.cash; let market_value = account.market_value; @@ -1249,10 +1235,10 @@ impl PlatformExprStrategy { benchmark_ma10, benchmark_ma20, benchmark_ma30, - signal_ma5: benchmark_ma5, - signal_ma10: benchmark_ma10, - signal_ma20: benchmark_ma20, - signal_ma30: benchmark_ma30, + signal_ma5, + signal_ma10, + signal_ma20, + signal_ma30, cash, available_cash: account.available_cash, frozen_cash: account.frozen_cash, @@ -4626,19 +4612,26 @@ impl Strategy for PlatformExprStrategy { 0 }; let stock_list = if self.config.rotation_enabled { + let selection_scan_limit = if self.config.daily_top_up_enabled { + selection_limit.saturating_add(80).max(120) + } else { + selection_limit + }; let (stock_list, notes) = self.select_symbols( ctx, decision_date, &day, band_low, band_high, - selection_limit, + selection_scan_limit, )?; selection_notes = notes; stock_list } else { Vec::new() }; + let empty_rebalance_retry = + self.config.retry_empty_rebalance && ctx.portfolio.positions().is_empty(); let periodic_rebalance = if self.config.rotation_enabled { if let Some(schedule) = &self.config.rebalance_schedule { schedule.matches( @@ -4646,9 +4639,9 @@ impl Strategy for PlatformExprStrategy { execution_date, ScheduleStage::OnDay, default_stage_time(ScheduleStage::OnDay), - ) + ) || empty_rebalance_retry } else { - ctx.decision_index % self.config.refresh_rate == 0 + ctx.decision_index % self.config.refresh_rate == 0 || empty_rebalance_retry } } else { false @@ -4767,7 +4760,7 @@ impl Strategy for PlatformExprStrategy { } let fixed_buy_cash = projected.cash() * trading_ratio / selection_limit as f64; - for symbol in &stock_list { + for symbol in stock_list.iter().take(selection_limit) { if projected.positions().len() >= selection_limit { break; } @@ -4806,6 +4799,52 @@ impl Strategy for PlatformExprStrategy { ); } } + if self.config.daily_top_up_enabled + && self.config.rotation_enabled + && !periodic_rebalance + && !ctx.portfolio.positions().is_empty() + && projected.positions().len() < selection_limit + { + let fixed_buy_cash = projected.total_value() * trading_ratio / selection_limit as f64; + let available_buy_cash = fixed_buy_cash.min(projected.cash()); + if available_buy_cash >= fixed_buy_cash * 0.5 { + for symbol in &stock_list { + if projected.positions().contains_key(symbol) { + continue; + } + let decision_stock = self.stock_state(ctx, decision_date, symbol)?; + let execution_stock = self.stock_state(ctx, execution_date, symbol)?; + if self + .buy_rejection_reason(ctx, execution_date, symbol, &execution_stock)? + .is_some() + { + continue; + } + if !self.stock_passes_expr(ctx, &day, &decision_stock)? { + continue; + } + let buy_cash = + available_buy_cash * self.buy_scale(ctx, &day, &decision_stock)?; + if buy_cash <= 0.0 { + continue; + } + order_intents.push(OrderIntent::Value { + symbol: symbol.clone(), + value: buy_cash, + reason: "daily_top_up_buy".to_string(), + }); + self.project_order_value( + ctx, + &mut projected, + execution_date, + symbol, + buy_cash, + &mut projected_execution_state, + ); + break; + } + } + } if !explicit_action_intents.is_empty() { order_intents.extend(explicit_action_intents); diff --git a/crates/fidc-core/src/platform_runtime_schema.rs b/crates/fidc-core/src/platform_runtime_schema.rs index ae6d12c..2e0a62b 100644 --- a/crates/fidc-core/src/platform_runtime_schema.rs +++ b/crates/fidc-core/src/platform_runtime_schema.rs @@ -318,8 +318,7 @@ mod tests { #[test] fn runtime_schema_includes_known_identifiers() { - let names: std::collections::HashSet<&str> = - RESERVED_SCOPE_NAMES.iter().copied().collect(); + let names: std::collections::HashSet<&str> = RESERVED_SCOPE_NAMES.iter().copied().collect(); for required in [ "signal_close", "benchmark_close", @@ -328,7 +327,10 @@ mod tests { "current_price", "stock_ma_short", ] { - assert!(names.contains(required), "missing reserved name: {required}"); + assert!( + names.contains(required), + "missing reserved name: {required}" + ); } let helpers: std::collections::HashSet<&str> = diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 55b9607..857deb8 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -49,6 +49,8 @@ pub struct StrategyExecutionSpec { pub slippage_model: Option, #[serde(default)] pub slippage_value: Option, + #[serde(default)] + pub strict_value_budget: Option, } #[derive(Debug, Clone, Default, Deserialize, Serialize)] @@ -83,6 +85,8 @@ pub struct StrategyEngineConfig { #[serde(default)] pub slippage_value: Option, #[serde(default)] + pub strict_value_budget: Option, + #[serde(default)] pub dividend_reinvestment: Option, #[serde(default)] pub rebalance_schedule: Option, @@ -224,6 +228,10 @@ pub struct StrategyExpressionTradingConfig { #[serde(default)] pub rotation_enabled: Option, #[serde(default)] + pub daily_top_up: Option, + #[serde(default)] + pub retry_empty_rebalance: Option, + #[serde(default)] pub subscription_guard_required: Option, #[serde(default)] pub actions: Vec, @@ -551,6 +559,24 @@ pub fn platform_expr_config_from_spec( if let Some(enabled) = trading.rotation_enabled { cfg.rotation_enabled = enabled; } + if let Some(enabled) = trading.daily_top_up { + cfg.daily_top_up_enabled = enabled; + } + if let Some(enabled) = trading.retry_empty_rebalance { + cfg.retry_empty_rebalance = enabled; + } + if let Some(enabled) = spec + .engine_config + .as_ref() + .and_then(|engine| engine.strict_value_budget) + .or_else(|| { + spec.execution + .as_ref() + .and_then(|execution| execution.strict_value_budget) + }) + { + cfg.strict_value_budget = enabled; + } if let Some(required) = trading.subscription_guard_required { cfg.subscription_guard_required = required; } @@ -1008,6 +1034,8 @@ mod tests { }, "trading": { "rotationEnabled": false, + "dailyTopUp": true, + "retryEmptyRebalance": true, "stage": "open_auction", "actions": [ { @@ -1027,6 +1055,8 @@ mod tests { assert_eq!(cfg.signal_symbol, "000852.SH"); assert_eq!(cfg.selection_limit_expr, "stocknum"); assert!(!cfg.rotation_enabled); + assert!(cfg.daily_top_up_enabled); + assert!(cfg.retry_empty_rebalance); assert_eq!(cfg.explicit_actions.len(), 1); assert_eq!( cfg.explicit_action_stage, diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index eeb8950..4c27114 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -10,7 +10,7 @@ use std::collections::{BTreeMap, BTreeSet}; #[test] fn broker_executes_explicit_order_value_buy() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); - let data = DataSet::from_components( + let data = DataSet::from_components_with_actions_and_quotes( vec![Instrument { symbol: "000002.SZ".to_string(), name: "Test".to_string(), @@ -72,6 +72,20 @@ fn broker_executes_explicit_order_value_buy() { prev_close: 99.0, volume: 1_000_000, }], + Vec::new(), + vec![IntradayExecutionQuote { + date, + symbol: "000002.SZ".to_string(), + timestamp: date.and_hms_opt(10, 18, 3).unwrap(), + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + bid1_volume: 1, + ask1_volume: 1, + volume_delta: 1, + amount_delta: 0.0, + trading_phase: Some("continuous".to_string()), + }], ) .expect("dataset"); let mut portfolio = PortfolioState::new(1_000_000.0); @@ -111,7 +125,7 @@ fn broker_executes_explicit_order_value_buy() { #[test] fn broker_executes_order_shares_and_order_lots() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); - let data = DataSet::from_components( + let data = DataSet::from_components_with_actions_and_quotes( vec![Instrument { symbol: "000002.SZ".to_string(), name: "Test".to_string(), @@ -173,6 +187,20 @@ fn broker_executes_order_shares_and_order_lots() { prev_close: 99.0, volume: 1_000_000, }], + Vec::new(), + vec![IntradayExecutionQuote { + date, + symbol: "000002.SZ".to_string(), + timestamp: date.and_hms_opt(10, 18, 3).unwrap(), + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + bid1_volume: 1, + ask1_volume: 1, + volume_delta: 1, + amount_delta: 0.0, + trading_phase: Some("continuous".to_string()), + }], ) .expect("dataset"); let mut portfolio = PortfolioState::new(1_000_000.0); @@ -1192,6 +1220,120 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() { #[test] fn broker_applies_tick_size_slippage_on_intraday_last_fills() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let data = DataSet::from_components_with_actions_and_quotes( + vec![Instrument { + symbol: "000002.SZ".to_string(), + name: "Test".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: "000002.SZ".to_string(), + timestamp: Some("2024-01-10 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.1, + low: 9.9, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + prev_close: 10.0, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 80_000, + ask1_volume: 80_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: "000002.SZ".to_string(), + market_cap_bn: 50.0, + free_float_cap_bn: 45.0, + pe_ttm: 15.0, + turnover_ratio: Some(2.0), + effective_turnover_ratio: Some(1.8), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: "000002.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }], + Vec::new(), + vec![IntradayExecutionQuote { + date, + symbol: "000002.SZ".to_string(), + timestamp: date.and_hms_opt(10, 18, 3).unwrap(), + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + bid1_volume: 1, + ask1_volume: 1, + volume_delta: 1, + amount_delta: 0.0, + trading_phase: Some("continuous".to_string()), + }], + ) + .expect("dataset"); + let mut portfolio = PortfolioState::new(1_000_000.0); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Last, + ) + .with_intraday_execution_start_time(chrono::NaiveTime::from_hms_opt(10, 18, 0).unwrap()) + .with_slippage_model(SlippageModel::TickSize(2.0)); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![OrderIntent::Value { + symbol: "000002.SZ".to_string(), + value: 100_000.0, + reason: "tick_slippage".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("broker execution"); + + assert_eq!(report.fill_events.len(), 1); + assert!((report.fill_events[0].price - 10.02).abs() < 1e-9); +} + +#[test] +fn broker_rejects_intraday_last_order_without_execution_quotes() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); let data = DataSet::from_components( vec![Instrument { @@ -1263,8 +1405,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() { ChinaEquityRuleHooks::default(), PriceField::Last, ) - .with_intraday_execution_start_time(chrono::NaiveTime::from_hms_opt(10, 18, 0).unwrap()) - .with_slippage_model(SlippageModel::TickSize(2.0)); + .with_intraday_execution_start_time(chrono::NaiveTime::from_hms_opt(10, 18, 0).unwrap()); let report = broker .execute( @@ -1278,7 +1419,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() { order_intents: vec![OrderIntent::Value { symbol: "000002.SZ".to_string(), value: 100_000.0, - reason: "tick_slippage".to_string(), + reason: "missing_tick_quotes".to_string(), }], notes: Vec::new(), diagnostics: Vec::new(), @@ -1286,8 +1427,15 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() { ) .expect("broker execution"); - assert_eq!(report.fill_events.len(), 1); - assert!((report.fill_events[0].price - 10.02).abs() < 1e-9); + assert!(report.fill_events.is_empty()); + assert_eq!(report.order_events.len(), 1); + assert_eq!(report.order_events[0].status, OrderStatus::Canceled); + assert!( + report.order_events[0] + .reason + .contains("no execution quotes after start") + ); + assert!(portfolio.position("000002.SZ").is_none()); } #[test]