diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 4568c77..35b1f4c 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -97,7 +97,7 @@ impl DynamicSlippageConfig { } } - fn ratio( + pub(crate) fn ratio( &self, snapshot: &crate::data::DailyMarketSnapshot, raw_price: f64, @@ -4706,8 +4706,12 @@ where let quote_quantity_limited = self.quote_quantity_limited_for_window(matching_type, start_cursor, end_cursor); let lot = round_lot.max(1); - let use_decision_time_quote = - matching_type == MatchingType::NextTickLast && start_cursor.is_some(); + let exact_time_order_quote = matching_type != MatchingType::NextTickLast + && start_cursor.is_some() + && end_cursor.is_some() + && start_cursor == end_cursor; + let use_decision_time_quote = start_cursor.is_some() + && (matching_type == MatchingType::NextTickLast || exact_time_order_quote); let eligible_quotes: Vec<&IntradayExecutionQuote> = if use_decision_time_quote { self.latest_known_quote_at_or_before( quotes, @@ -5428,7 +5432,7 @@ mod tests { ); let default_rule = default_broker.buy_rule_check(date, &snapshot, &candidate, None); assert!(!default_rule.allowed); - assert_eq!(default_rule.reason.as_deref(), Some("trade_disabled")); + assert_eq!(default_rule.reason.as_deref(), Some("buy_disabled")); let aiquant_broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), @@ -5438,12 +5442,17 @@ mod tests { .with_aiquant_rqalpha_execution_rules(true); let aiquant_rule = aiquant_broker.buy_rule_check(date, &snapshot, &candidate, None); assert!(!aiquant_rule.allowed); - assert_eq!(aiquant_rule.reason.as_deref(), Some("trade_disabled")); + assert_eq!(aiquant_rule.reason.as_deref(), Some("buy_disabled")); let tradable_candidate = limit_test_candidate(true, true); let aiquant_rule = aiquant_broker.buy_rule_check(date, &snapshot, &tradable_candidate, None); assert!(aiquant_rule.allowed); + + let lower_limit_buyable_candidate = limit_test_candidate(true, false); + let aiquant_rule = + aiquant_broker.buy_rule_check(date, &snapshot, &lower_limit_buyable_candidate, None); + assert!(aiquant_rule.allowed); } #[test] diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index ecadbf1..d38c813 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -1237,6 +1237,13 @@ impl DataSet { .unwrap_or(&[]) } + pub fn has_execution_quotes_on_date(&self, date: NaiveDate) -> bool { + self.execution_quotes_by_date + .get(&date) + .map(|rows_by_symbol| !rows_by_symbol.is_empty()) + .unwrap_or(false) + } + pub fn execution_quote_key_set(&self) -> HashSet<(NaiveDate, String)> { self.execution_quotes_by_date .iter() diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 937c7be..6c12830 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -1,6 +1,6 @@ use std::collections::{BTreeMap, BTreeSet}; -use chrono::{Datelike, NaiveDate}; +use chrono::{Datelike, NaiveDate, NaiveTime}; use serde::Serialize; use thiserror::Error; @@ -523,8 +523,35 @@ where return Ok(()); } - let start_time = start_time.or_else(|| self.broker.intraday_execution_start_time()); + let caller_start_time = start_time; + let caller_end_time = end_time; + let start_time = caller_start_time.or_else(|| self.broker.intraday_execution_start_time()); let mut symbols = execution_quote_symbols_for_decision(decision, portfolio, open_orders); + self.load_missing_execution_quotes(execution_date, start_time, end_time, &mut symbols)?; + + if caller_start_time.is_none() && caller_end_time.is_none() { + for ((intent_start_time, intent_end_time), mut intent_symbols) in + algo_execution_quote_windows_for_decision(decision, portfolio) + { + self.load_missing_execution_quotes( + execution_date, + intent_start_time, + intent_end_time, + &mut intent_symbols, + )?; + } + } + + Ok(()) + } + + fn load_missing_execution_quotes( + &mut self, + execution_date: NaiveDate, + start_time: Option, + end_time: Option, + symbols: &mut BTreeSet, + ) -> Result<(), BacktestError> { symbols.retain(|symbol| { !has_execution_quote_in_window(&self.data, execution_date, symbol, start_time, end_time) }); @@ -536,7 +563,7 @@ where date: execution_date, start_time, end_time, - symbols, + symbols: std::mem::take(symbols), }; let quotes = self .execution_quote_loader @@ -547,6 +574,35 @@ where Ok(()) } + fn ensure_execution_quotes_for_portfolio_times( + &mut self, + execution_date: NaiveDate, + portfolio: &PortfolioState, + quote_times: &[NaiveTime], + ) -> Result<(), BacktestError> { + if self.execution_quote_loader.is_none() || quote_times.is_empty() { + return Ok(()); + } + let base_symbols = portfolio + .positions() + .keys() + .cloned() + .collect::>(); + if base_symbols.is_empty() { + return Ok(()); + } + for quote_time in quote_times { + let mut symbols = base_symbols.clone(); + self.load_missing_execution_quotes( + execution_date, + Some(*quote_time), + Some(*quote_time), + &mut symbols, + )?; + } + Ok(()) + } + fn apply_strategy_directives( &mut self, execution_date: NaiveDate, @@ -1875,6 +1931,12 @@ where "on_day:pre", )?; let on_day_open_orders = self.open_order_views(); + let decision_quote_times = self.strategy.decision_quote_times(); + self.ensure_execution_quotes_for_portfolio_times( + execution_date, + &portfolio, + &decision_quote_times, + )?; let mut decision = decision_slot .map(|(decision_idx, decision_date)| { self.strategy.on_day(&StrategyContext { @@ -3283,6 +3345,54 @@ fn execution_quote_symbols_for_decision( symbols } +fn algo_execution_quote_windows_for_decision( + decision: &StrategyDecision, + portfolio: &PortfolioState, +) -> BTreeMap<(Option, Option), BTreeSet> { + let mut groups = BTreeMap::<(Option, Option), BTreeSet>::new(); + for intent in &decision.order_intents { + match intent { + OrderIntent::AlgoValue { + symbol, + start_time, + end_time, + .. + } + | OrderIntent::AlgoPercent { + symbol, + start_time, + end_time, + .. + } => { + if start_time.is_some() || end_time.is_some() { + groups + .entry((*start_time, *end_time)) + .or_default() + .insert(symbol.clone()); + } + } + OrderIntent::TargetPortfolioSmart { + target_weights, + order_prices: + Some(TargetPortfolioOrderPricing::AlgoOrder { + start_time, + end_time, + .. + }), + .. + } => { + if start_time.is_some() || end_time.is_some() { + let symbols = groups.entry((*start_time, *end_time)).or_default(); + symbols.extend(portfolio.positions().keys().cloned()); + symbols.extend(target_weights.keys().cloned()); + } + } + _ => {} + } + } + groups +} + fn collect_scheduled_decisions( strategy: &mut S, scheduler: &Scheduler<'_>, diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 909527a..9501963 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -4,6 +4,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime}; use rhai::{AST, Dynamic, Engine, Map, Scope}; +use crate::broker::SlippageModel; use crate::cost::ChinaAShareCostModel; use crate::data::{ DailyMarketSnapshot, EligibleUniverseSnapshot, PriceField, decision_free_float_cap_bn, @@ -213,11 +214,13 @@ pub struct PlatformExprStrategyConfig { pub stamp_tax_rate_before_change: Option, pub stamp_tax_rate_after_change: Option, pub strict_value_budget: bool, + pub slippage_model: SlippageModel, pub quote_quantity_limit: bool, pub current_day_precomputed_factors: bool, pub intraday_execution_time: Option, pub delayed_limit_open_exit_enabled: bool, pub delayed_limit_open_exit_time: Option, + pub release_slot_on_exit_signal: bool, pub explicit_action_stage: PlatformExplicitActionStage, pub explicit_action_schedule: Option, pub subscription_guard_required: bool, @@ -278,11 +281,13 @@ fn band_low(index_close) { stamp_tax_rate_before_change: None, stamp_tax_rate_after_change: None, strict_value_budget: false, + slippage_model: SlippageModel::None, quote_quantity_limit: true, current_day_precomputed_factors: false, intraday_execution_time: None, delayed_limit_open_exit_enabled: false, delayed_limit_open_exit_time: None, + release_slot_on_exit_signal: false, explicit_action_stage: PlatformExplicitActionStage::OnDay, explicit_action_schedule: None, subscription_guard_required: false, @@ -375,7 +380,9 @@ struct DayExpressionState { struct StockExpressionState { symbol: String, market_cap: f64, + market_cap_bn: f64, free_float_cap: f64, + free_float_cap_bn: f64, pe_ttm: f64, volume: f64, tick_volume: i64, @@ -516,6 +523,10 @@ pub struct PlatformSelectionQuotePlan { } impl PlatformExprStrategy { + fn market_cap_storage_to_strategy_unit(value: f64) -> f64 { + value + } + pub fn new(config: PlatformExprStrategyConfig) -> Self { let mut engine = Engine::new(); engine.register_fn("round", |value: f64| value.round()); @@ -649,159 +660,167 @@ impl PlatformExprStrategy { chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) } - fn reserved_scope_names() -> BTreeSet<&'static str> { - BTreeSet::from([ - "signal_close", - "benchmark_close", - "signal_ma5", - "signal_ma10", - "signal_ma20", - "signal_ma30", - "benchmark_ma5", - "benchmark_ma10", - "benchmark_ma20", - "benchmark_ma30", - "benchmark_ma_short", - "benchmark_ma_long", - "cash", - "available_cash", - "frozen_cash", - "market_value", - "total_equity", - "total_value", - "portfolio_value", - "starting_cash", - "unit_net_value", - "static_unit_net_value", - "daily_pnl", - "daily_returns", - "total_returns", - "cash_liabilities", - "management_fee_rate", - "management_fees", - "current_exposure", - "position_count", - "max_positions", - "refresh_rate", - "year", - "month", - "quarter", - "day_of_month", - "day_of_year", - "week_of_year", - "weekday", - "is_month_start", - "is_month_end", - "has_open_orders", - "open_order_count", - "open_buy_order_count", - "open_sell_order_count", - "open_buy_qty", - "open_sell_qty", - "latest_open_order_id", - "latest_open_order_status", - "latest_open_order_unfilled_qty", - "has_process_events", - "process_event_count", - "current_process_kind", - "current_process_order_id", - "current_process_symbol", - "current_process_side", - "current_process_detail", - "latest_process_kind", - "latest_process_order_id", - "latest_process_symbol", - "latest_process_side", - "latest_process_detail", - "process_event_counts", - "day_factors", - "symbol", - "market_cap", - "free_float_cap", - "pe_ttm", - "volume", - "tick_volume", - "bid1_volume", - "ask1_volume", - "turnover_ratio", - "effective_turnover_ratio", - "open", - "high", - "low", - "close", - "last", - "last_price", - "prev_close", - "amount", - "upper_limit", - "lower_limit", - "price_tick", - "round_lot", - "paused", - "is_st", - "is_kcb", - "is_one_yuan", - "is_new_listing", - "allow_buy", - "allow_sell", - "touched_upper_limit", - "touched_lower_limit", - "hit_upper_limit", - "hit_lower_limit", - "listed_days", - "symbol_open_order_count", - "symbol_open_buy_qty", - "symbol_open_sell_qty", - "latest_symbol_open_order_id", - "latest_symbol_open_order_status", - "latest_symbol_open_order_unfilled_qty", - "stock_ma_short", - "stock_ma_mid", - "stock_ma_long", - "stock_ma5", - "stock_ma10", - "stock_ma20", - "stock_ma30", - "ma5", - "ma10", - "ma20", - "ma30", - "factors", - "order_book_id", - "avg_cost", - "avg_price", - "current_price", - "position_prev_close", - "prev_position_close", - "holding_return", - "quantity", - "sellable_qty", - "sellable", - "closable", - "old_quantity", - "buy_quantity", - "sell_quantity", - "bought_quantity", - "sold_quantity", - "buy_avg_price", - "sell_avg_price", - "bought_value", - "sold_value", - "transaction_cost", - "position_market_value", - "equity", - "value_percent", - "unrealized_pnl", - "realized_pnl", - "pnl", - "day_trade_quantity_delta", - "profit_pct", - "trading_pnl", - "position_pnl", - "dividend_receivable", - "at_upper_limit", - "at_lower_limit", - ]) + fn is_reserved_scope_name(name: &str) -> bool { + matches!( + name, + "signal_close" + | "benchmark_close" + | "signal_ma5" + | "signal_ma10" + | "signal_ma20" + | "signal_ma30" + | "benchmark_ma5" + | "benchmark_ma10" + | "benchmark_ma20" + | "benchmark_ma30" + | "benchmark_ma_short" + | "benchmark_ma_long" + | "cash" + | "available_cash" + | "frozen_cash" + | "market_value" + | "total_equity" + | "total_value" + | "portfolio_value" + | "starting_cash" + | "unit_net_value" + | "static_unit_net_value" + | "daily_pnl" + | "daily_returns" + | "total_returns" + | "cash_liabilities" + | "management_fee_rate" + | "management_fees" + | "current_exposure" + | "position_count" + | "max_positions" + | "refresh_rate" + | "year" + | "month" + | "quarter" + | "day_of_month" + | "day_of_year" + | "week_of_year" + | "weekday" + | "is_month_start" + | "is_month_end" + | "has_open_orders" + | "open_order_count" + | "open_buy_order_count" + | "open_sell_order_count" + | "open_buy_qty" + | "open_sell_qty" + | "latest_open_order_id" + | "latest_open_order_status" + | "latest_open_order_unfilled_qty" + | "has_process_events" + | "process_event_count" + | "current_process_kind" + | "current_process_order_id" + | "current_process_symbol" + | "current_process_side" + | "current_process_detail" + | "latest_process_kind" + | "latest_process_order_id" + | "latest_process_symbol" + | "latest_process_side" + | "latest_process_detail" + | "process_event_counts" + | "day_factors" + | "symbol" + | "market_cap" + | "market_cap_bn" + | "free_float_cap" + | "free_float_cap_bn" + | "free_float_market_cap" + | "pe_ttm" + | "volume" + | "tick_volume" + | "bid1_volume" + | "ask1_volume" + | "turnover_ratio" + | "effective_turnover_ratio" + | "open" + | "high" + | "low" + | "close" + | "last" + | "last_price" + | "prev_close" + | "amount" + | "upper_limit" + | "lower_limit" + | "price_tick" + | "round_lot" + | "paused" + | "is_st" + | "is_kcb" + | "is_one_yuan" + | "is_new_listing" + | "allow_buy" + | "allow_sell" + | "touched_upper_limit" + | "touched_lower_limit" + | "hit_upper_limit" + | "hit_lower_limit" + | "listed_days" + | "symbol_open_order_count" + | "symbol_open_buy_qty" + | "symbol_open_sell_qty" + | "latest_symbol_open_order_id" + | "latest_symbol_open_order_status" + | "latest_symbol_open_order_unfilled_qty" + | "stock_ma_short" + | "stock_ma_mid" + | "stock_ma_long" + | "stock_ma5" + | "stock_ma10" + | "stock_ma20" + | "stock_ma30" + | "ma5" + | "ma10" + | "ma20" + | "ma30" + | "factors" + | "order_book_id" + | "avg_cost" + | "avg_price" + | "current_price" + | "position_prev_close" + | "prev_position_close" + | "holding_return" + | "quantity" + | "sellable_qty" + | "sellable" + | "closable" + | "old_quantity" + | "buy_quantity" + | "sell_quantity" + | "bought_quantity" + | "sold_quantity" + | "buy_avg_price" + | "sell_avg_price" + | "bought_value" + | "sold_value" + | "transaction_cost" + | "position_market_value" + | "equity" + | "value_percent" + | "unrealized_pnl" + | "realized_pnl" + | "pnl" + | "day_trade_quantity_delta" + | "profit_pct" + | "trading_pnl" + | "position_pnl" + | "dividend_receivable" + | "at_upper_limit" + | "at_lower_limit" + | "candidate_market_cap" + | "candidate_market_cap_bn" + | "candidate_free_float_cap" + | "candidate_free_float_cap_bn" + ) } fn is_runtime_helper(name: &str) -> bool { @@ -1117,14 +1136,97 @@ impl PlatformExprStrategy { .and_then(|quote| quote.buy_price()) } - fn projected_quote_execution_price( + fn aiquant_scheduled_last_price( &self, - quote: &crate::data::IntradayExecutionQuote, - side: OrderSide, + ctx: &StrategyContext<'_>, + date: NaiveDate, + symbol: &str, ) -> Option { + self.aiquant_scheduled_quote(ctx, date, symbol) + .and_then(|quote| { + (quote.last_price.is_finite() && quote.last_price > 0.0).then_some(quote.last_price) + }) + } + + fn projected_apply_slippage( + &self, + market: &DailyMarketSnapshot, + side: OrderSide, + raw_price: f64, + quantity: Option, + ) -> f64 { + if !raw_price.is_finite() || raw_price <= 0.0 { + return raw_price; + } + let order_value = quantity.and_then(|qty| (qty > 0).then_some(raw_price * qty as f64)); + let adjusted = match self.config.slippage_model { + SlippageModel::None | SlippageModel::LimitPrice => raw_price, + SlippageModel::PriceRatio(ratio) => { + let ratio = ratio.max(0.0); + match side { + OrderSide::Buy => raw_price * (1.0 + ratio), + OrderSide::Sell => raw_price * (1.0 - ratio), + } + } + SlippageModel::TickSize(ticks) => { + let tick = market.effective_price_tick(); + let ticks = ticks.max(0.0); + match side { + OrderSide::Buy => raw_price + tick * ticks, + OrderSide::Sell => raw_price - tick * ticks, + } + } + SlippageModel::Dynamic(config) => { + let ratio = config.ratio(market, raw_price, order_value); + match side { + OrderSide::Buy => raw_price * (1.0 + ratio), + OrderSide::Sell => raw_price * (1.0 - ratio), + } + } + }; + Self::projected_clamp_execution_price(market, side, adjusted) + } + + fn projected_clamp_execution_price( + market: &DailyMarketSnapshot, + side: OrderSide, + adjusted_price: f64, + ) -> f64 { + if !adjusted_price.is_finite() { + return adjusted_price; + } + let mut bounded = adjusted_price.max(market.effective_price_tick()); match side { - OrderSide::Buy => quote.buy_price(), - OrderSide::Sell => quote.sell_price(), + OrderSide::Buy => { + if market.upper_limit.is_finite() && market.upper_limit > 0.0 { + bounded = bounded.min(market.upper_limit); + } + } + OrderSide::Sell => { + if market.lower_limit.is_finite() && market.lower_limit > 0.0 { + bounded = bounded.max(market.lower_limit); + } + } + } + bounded + } + + fn projected_execution_limit_rejection_reason( + market: &DailyMarketSnapshot, + side: OrderSide, + execution_price: f64, + ) -> Option<&'static str> { + if !execution_price.is_finite() || execution_price <= 0.0 { + return None; + } + match side { + OrderSide::Buy if market.is_at_upper_limit_price(execution_price) => { + Some("open at or above upper limit") + } + OrderSide::Sell if market.is_at_lower_limit_price(execution_price) => { + Some("open at or below lower limit") + } + _ => None, } } @@ -1180,6 +1282,7 @@ impl PlatformExprStrategy { if requested_qty == 0 { return None; } + let market = ctx.data.market(date, symbol)?; let start_cursor = self.projected_execution_start_cursor_at_time( ctx, @@ -1200,7 +1303,10 @@ impl PlatformExprStrategy { let mut last_timestamp = None; for quote in selected_quotes { - let Some(quote_price) = self.projected_quote_execution_price(quote, side) else { + let Some(raw_quote_price) = (match side { + OrderSide::Buy => quote.buy_price(), + OrderSide::Sell => quote.sell_price(), + }) else { continue; }; let available_qty = if self.config.quote_quantity_limit { @@ -1230,8 +1336,27 @@ impl PlatformExprStrategy { continue; } + let mut quote_price = + self.projected_apply_slippage(market, side, raw_quote_price, Some(take_qty)); + if Self::projected_execution_limit_rejection_reason(market, side, quote_price).is_some() + { + continue; + } + if let Some(cash) = cash_limit { while take_qty > 0 { + quote_price = self.projected_apply_slippage( + market, + side, + raw_quote_price, + Some(take_qty), + ); + if Self::projected_execution_limit_rejection_reason(market, side, quote_price) + .is_some() + { + take_qty = 0; + break; + } let candidate_gross = gross_amount + quote_price * take_qty as f64; if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { take_qty = self.decrement_order_quantity( @@ -1255,6 +1380,12 @@ impl PlatformExprStrategy { } } + quote_price = + self.projected_apply_slippage(market, side, raw_quote_price, Some(take_qty)); + if Self::projected_execution_limit_rejection_reason(market, side, quote_price).is_some() + { + continue; + } gross_amount += quote_price * take_qty as f64; filled_qty += take_qty; last_timestamp = Some(quote.timestamp); @@ -1324,7 +1455,10 @@ impl PlatformExprStrategy { execution_state: &mut ProjectedExecutionState, execution_time: Option, ) -> Option { - let quantity = projected.position(symbol)?.quantity; + let position = projected.position(symbol)?; + let current_qty = position.quantity; + let sellable_qty = position.sellable_qty(date); + let quantity = current_qty.min(sellable_qty); if quantity == 0 { return None; } @@ -1345,7 +1479,7 @@ impl PlatformExprStrategy { round_lot, minimum_order_quantity, order_step_size, - true, + sellable_qty >= current_qty, None, None, execution_state, @@ -1358,7 +1492,8 @@ impl PlatformExprStrategy { symbol, execution_state, execution_time, - ) { + ) && !ctx.data.has_execution_quotes_on_date(date) + { Some(ProjectedExecutionFill { price: self.projected_execution_price(market, OrderSide::Sell), quantity, @@ -1410,15 +1545,22 @@ impl PlatformExprStrategy { return self.project_target_zero(ctx, projected, date, symbol, execution_state); } let market = ctx.data.market(date, symbol)?; - let valuation_price = if market.close.is_finite() && market.close > 0.0 { - market.close + let current_value = if self.config.aiquant_transaction_cost { + self.projected_position_value_at_execution_price(ctx, projected, date, symbol) } else { - self.projected_execution_price(market, OrderSide::Buy) + let valuation_price = if market.close.is_finite() && market.close > 0.0 { + market.close + } else { + self.projected_execution_price(market, OrderSide::Buy) + }; + if !valuation_price.is_finite() || valuation_price <= 0.0 { + return None; + } + valuation_price * current_qty as f64 }; - if !valuation_price.is_finite() || valuation_price <= 0.0 { + if !current_value.is_finite() || current_value <= 0.0 { return None; } - let current_value = valuation_price * current_qty as f64; let cash_delta = target_value.max(0.0) - current_value; if cash_delta.abs() <= f64::EPSILON { return None; @@ -1453,13 +1595,18 @@ impl PlatformExprStrategy { let round_lot = self.projected_round_lot(ctx, symbol); let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol); let order_step_size = self.projected_order_step_size(ctx, symbol); + let sellable_qty = projected.position(symbol)?.sellable_qty(date); + if sellable_qty == 0 { + return None; + } let requested_qty = self .round_lot_quantity( ((cash_delta.abs()) / sizing_price).floor() as u32, minimum_order_quantity, order_step_size, ) - .min(current_qty); + .min(current_qty) + .min(sellable_qty); if requested_qty == 0 { return None; } @@ -1472,7 +1619,7 @@ impl PlatformExprStrategy { round_lot, minimum_order_quantity, order_step_size, - requested_qty >= current_qty, + requested_qty >= current_qty && sellable_qty >= current_qty, None, None, execution_state, @@ -1495,29 +1642,11 @@ impl PlatformExprStrategy { Some(fill.quantity) } - fn projected_position_count_excluding( - projected: &PortfolioState, - excluded_symbols: &BTreeSet, - ) -> usize { - if excluded_symbols.is_empty() { - return projected.positions().len(); - } + fn projected_position_is_flat(projected: &PortfolioState, symbol: &str) -> bool { projected - .positions() - .keys() - .filter(|symbol| !excluded_symbols.contains(*symbol)) - .count() - } - - fn pending_exit_exclusion_symbols( - unresolved_stop_loss_symbols: &BTreeSet, - exit_symbols: &BTreeSet, - delayed_sold_symbols: &BTreeSet, - ) -> BTreeSet { - let mut excluded = unresolved_stop_loss_symbols.clone(); - excluded.extend(exit_symbols.iter().cloned()); - excluded.extend(delayed_sold_symbols.iter().cloned()); - excluded + .position(symbol) + .map(|position| position.quantity == 0) + .unwrap_or(true) } fn projected_position_value_at_execution_price( @@ -1579,7 +1708,9 @@ impl PlatformExprStrategy { .filter(|value| value.is_finite() && *value > 0.0) .sum::(); let working_value = active_value + pending_buy_value.max(0.0); - (target_budget - working_value).max(0.0) / slots_remaining as f64 + let per_slot_budget = (target_budget - working_value).max(0.0) / slots_remaining as f64; + let single_slot_cap = target_budget / selection_limit as f64; + per_slot_budget.min(single_slot_cap) } fn project_order_value( @@ -1616,8 +1747,17 @@ impl PlatformExprStrategy { { return 0; } - let execution_price = self.projected_execution_price(market, OrderSide::Buy); - let sizing_price = execution_price; + let raw_sizing_price = if self.config.aiquant_transaction_cost { + self.aiquant_scheduled_last_price(ctx, date, symbol) + .unwrap_or_else(|| self.projected_execution_price(market, OrderSide::Buy)) + } else { + self.projected_execution_price(market, OrderSide::Buy) + }; + let sizing_price = if self.config.aiquant_transaction_cost { + self.projected_apply_slippage(market, OrderSide::Buy, raw_sizing_price, None) + } else { + raw_sizing_price + }; if !sizing_price.is_finite() || sizing_price <= 0.0 { return 0; } @@ -1639,7 +1779,7 @@ impl PlatformExprStrategy { projected.cash() }; while quantity > 0 { - let gross_amount = execution_price * quantity as f64; + let gross_amount = sizing_price * quantity as f64; if gross_limit.map_or(true, |limit| gross_amount <= limit + 1e-6) && gross_amount + self.buy_commission(gross_amount) <= cash_limit + 1e-6 { @@ -1667,9 +1807,11 @@ impl PlatformExprStrategy { execution_state, ) .or_else(|| { - if !self.has_execution_quote_at_or_before(ctx, date, symbol, execution_state) { + if !self.has_execution_quote_at_or_before(ctx, date, symbol, execution_state) + && !ctx.data.has_execution_quotes_on_date(date) + { Some(ProjectedExecutionFill { - price: execution_price, + price: sizing_price, quantity, next_cursor: date.and_time(self.intraday_execution_start_time()) + Duration::seconds(1), @@ -1981,8 +2123,10 @@ impl PlatformExprStrategy { } else { factor.extra_factors.get("amount").copied().unwrap_or(0.0) }; - let market_cap = decision_market_cap_bn(factor, market); - let free_float_cap = decision_free_float_cap_bn(factor, market); + let market_cap_bn = decision_market_cap_bn(factor, market); + let free_float_cap_bn = decision_free_float_cap_bn(factor, market); + let market_cap = Self::market_cap_storage_to_strategy_unit(market_cap_bn); + let free_float_cap = Self::market_cap_storage_to_strategy_unit(free_float_cap_bn); let expression_high = if intraday_same_day_factor { f64::NAN } else { @@ -2010,7 +2154,9 @@ impl PlatformExprStrategy { Ok(StockExpressionState { symbol: symbol.to_string(), market_cap, + market_cap_bn, free_float_cap, + free_float_cap_bn, pe_ttm: factor.pe_ttm, volume: expression_volume, tick_volume: market.tick_volume as i64, @@ -2403,7 +2549,10 @@ impl PlatformExprStrategy { ); scope.push("symbol", stock.symbol.clone()); scope.push("market_cap", stock.market_cap); + scope.push("market_cap_bn", stock.market_cap_bn); scope.push("free_float_cap", stock.free_float_cap); + scope.push("free_float_cap_bn", stock.free_float_cap_bn); + scope.push("free_float_market_cap", stock.free_float_cap); scope.push("pe_ttm", stock.pe_ttm); scope.push("volume", stock.volume); scope.push("tick_volume", stock.tick_volume); @@ -2493,7 +2642,16 @@ impl PlatformExprStrategy { let mut factors = Map::new(); factors.insert("symbol".into(), Dynamic::from(stock.symbol.clone())); factors.insert("market_cap".into(), Dynamic::from(stock.market_cap)); + factors.insert("market_cap_bn".into(), Dynamic::from(stock.market_cap_bn)); factors.insert("free_float_cap".into(), Dynamic::from(stock.free_float_cap)); + factors.insert( + "free_float_cap_bn".into(), + Dynamic::from(stock.free_float_cap_bn), + ); + factors.insert( + "free_float_market_cap".into(), + Dynamic::from(stock.free_float_cap), + ); factors.insert("pe_ttm".into(), Dynamic::from(stock.pe_ttm)); factors.insert("volume".into(), Dynamic::from(stock.volume)); factors.insert("tick_volume".into(), Dynamic::from(stock.tick_volume)); @@ -2709,9 +2867,8 @@ impl PlatformExprStrategy { self.expand_runtime_helpers(ctx, day, stock, &normalized_expr, &mut scope)?; let prelude_declared_identifiers = Self::declared_prelude_identifiers(&self.config.prelude); if let Some(item) = stock { - let reserved_names = Self::reserved_scope_names(); for identifier in Self::extract_identifier_candidates(&expanded_expr) { - if reserved_names.contains(identifier.as_str()) + if Self::is_reserved_scope_name(identifier.as_str()) || prelude_declared_identifiers.contains(&identifier) || (!day.available_factor_names.contains(&identifier) && !day.available_text_factor_names.contains(&identifier)) @@ -5228,8 +5385,12 @@ impl PlatformExprStrategy { fn field_value(&self, row: &EligibleUniverseSnapshot) -> f64 { match self.config.market_cap_field.as_str() { - "free_float_cap" | "free_float_market_cap" => row.free_float_cap_bn, - _ => row.market_cap_bn, + "market_cap_bn" => row.market_cap_bn, + "free_float_cap" | "free_float_market_cap" => { + Self::market_cap_storage_to_strategy_unit(row.free_float_cap_bn) + } + "free_float_cap_bn" => row.free_float_cap_bn, + _ => Self::market_cap_storage_to_strategy_unit(row.market_cap_bn), } } @@ -5290,7 +5451,7 @@ impl PlatformExprStrategy { candidate: &crate::data::CandidateEligibility, market: &DailyMarketSnapshot, ) -> Option<&'static str> { - ChinaAShareRiskControl::selection_rejection_reason( + ChinaAShareRiskControl::baseline_rejection_reason( date, candidate, market, @@ -5335,7 +5496,9 @@ impl PlatformExprStrategy { ) -> Option { match field { "market_cap" => Some(stock.market_cap), + "market_cap_bn" => Some(stock.market_cap_bn), "free_float_cap" | "free_float_market_cap" => Some(stock.free_float_cap), + "free_float_cap_bn" => Some(stock.free_float_cap_bn), "pe_ttm" => Some(stock.pe_ttm), "volume" => Some(stock.volume), "tick_volume" => Some(stock.tick_volume as f64), @@ -5384,8 +5547,14 @@ impl PlatformExprStrategy { "is_kcb" => Some(if stock.is_kcb { 1.0 } else { 0.0 }), "is_one_yuan" => Some(if stock.is_one_yuan { 1.0 } else { 0.0 }), "is_new_listing" => Some(if stock.is_new_listing { 1.0 } else { 0.0 }), - "candidate_market_cap" => Some(candidate.market_cap_bn), - "candidate_free_float_cap" => Some(candidate.free_float_cap_bn), + "candidate_market_cap" => Some(Self::market_cap_storage_to_strategy_unit( + candidate.market_cap_bn, + )), + "candidate_market_cap_bn" => Some(candidate.market_cap_bn), + "candidate_free_float_cap" => Some(Self::market_cap_storage_to_strategy_unit( + candidate.free_float_cap_bn, + )), + "candidate_free_float_cap_bn" => Some(candidate.free_float_cap_bn), other => stock.extra_factors.get(other).copied(), } } @@ -5396,10 +5565,14 @@ impl PlatformExprStrategy { stock: &StockExpressionState, ) -> f64 { match self.config.market_cap_field.as_str() { - "market_cap" | "market_cap_bn" => return candidate.market_cap_bn, - "free_float_cap" | "free_float_market_cap" | "free_float_cap_bn" => { - return candidate.free_float_cap_bn; + "market_cap" => { + return Self::market_cap_storage_to_strategy_unit(candidate.market_cap_bn); } + "market_cap_bn" => return candidate.market_cap_bn, + "free_float_cap" | "free_float_market_cap" => { + return Self::market_cap_storage_to_strategy_unit(candidate.free_float_cap_bn); + } + "free_float_cap_bn" => return candidate.free_float_cap_bn, _ => {} } self.stock_numeric_field_value(candidate, stock, self.config.market_cap_field.as_str()) @@ -5439,11 +5612,15 @@ impl PlatformExprStrategy { let Ok(candidate) = ctx.data.require_candidate(date, symbol) else { return false; }; - let lower_limit_check_price = market.price(PriceField::Last); - !(market.paused - || candidate.is_paused - || !candidate.allow_sell - || market.is_at_lower_limit_price(lower_limit_check_price)) + ChinaAShareRiskControl::sell_rejection_reason( + date, + candidate, + market, + ctx.data.instrument(symbol), + Some(position), + market.price(PriceField::Last), + ) + .is_none() } fn buy_rejection_reason( @@ -5594,6 +5771,8 @@ impl PlatformExprStrategy { | "bid1_volume" | "ask1_volume" | "tick_volume" + | "at_upper_limit" + | "at_lower_limit" | "touched_upper_limit" | "touched_lower_limit" | "hit_upper_limit" @@ -5755,7 +5934,7 @@ impl PlatformExprStrategy { fn stop_take_action( &self, ctx: &StrategyContext<'_>, - signal_date: NaiveDate, + _signal_date: NaiveDate, execution_date: NaiveDate, day: &DayExpressionState, symbol: &str, @@ -5782,7 +5961,12 @@ impl PlatformExprStrategy { if position.quantity == 0 || avg_price <= 0.0 { return Ok((false, false)); } - let stock = match self.stock_state(ctx, signal_date, symbol) { + let stock = match self.stock_state_at_time( + ctx, + execution_date, + symbol, + Some(self.intraday_execution_start_time()), + ) { Ok(stock) => stock, Err(BacktestError::Data(crate::data::DataSetError::MissingSnapshot { .. })) => { return Ok((false, false)); @@ -5888,6 +6072,19 @@ impl Strategy for PlatformExprStrategy { self.config.strategy_name.as_str() } + fn decision_quote_times(&self) -> Vec { + let mut times = BTreeSet::new(); + if self.config.aiquant_transaction_cost || self.config.intraday_execution_time.is_some() { + times.insert(self.intraday_execution_start_time()); + } + if self.config.delayed_limit_open_exit_enabled { + if let Some(time) = self.config.delayed_limit_open_exit_time { + times.insert(time); + } + } + times.into_iter().collect() + } + fn open_auction( &mut self, ctx: &StrategyContext<'_>, @@ -6076,6 +6273,7 @@ impl Strategy for PlatformExprStrategy { Some(delayed_limit_exit_time), ) .is_some() + && Self::projected_position_is_flat(&projected, &symbol) }; if projected_sold { same_day_sold_symbols.insert(symbol.clone()); @@ -6089,6 +6287,13 @@ impl Strategy for PlatformExprStrategy { } else { projected.cash() }; + let mut slot_working_symbols = ctx + .portfolio + .positions() + .keys() + .filter(|symbol| !delayed_sold_symbols.contains(*symbol)) + .cloned() + .collect::>(); if self.config.aiquant_transaction_cost && self.config.rotation_enabled @@ -6149,6 +6354,9 @@ impl Strategy for PlatformExprStrategy { target_value: 0.0, reason: "stop_loss_exit".to_string(), }); + if self.config.release_slot_on_exit_signal { + same_day_sold_symbols.insert(position.symbol.clone()); + } if can_sell { if self .project_target_zero( @@ -6159,8 +6367,10 @@ impl Strategy for PlatformExprStrategy { &mut projected_execution_state, ) .is_some() + && Self::projected_position_is_flat(&projected, &position.symbol) { same_day_sold_symbols.insert(position.symbol.clone()); + slot_working_symbols.remove(&position.symbol); } } else { unresolved_stop_loss_symbols.insert(position.symbol.clone()); @@ -6169,7 +6379,12 @@ impl Strategy for PlatformExprStrategy { } if self.config.delayed_limit_open_exit_enabled { - let stock = match self.stock_state(ctx, execution_date, &position.symbol) { + let stock = match self.stock_state_at_time( + ctx, + execution_date, + &position.symbol, + Some(self.intraday_execution_start_time()), + ) { Ok(stock) => stock, Err(BacktestError::Data(crate::data::DataSetError::MissingSnapshot { .. @@ -6190,6 +6405,9 @@ impl Strategy for PlatformExprStrategy { target_value: 0.0, reason: "take_profit_exit".to_string(), }); + if self.config.release_slot_on_exit_signal { + same_day_sold_symbols.insert(position.symbol.clone()); + } if can_sell && self .project_target_zero( @@ -6200,40 +6418,32 @@ impl Strategy for PlatformExprStrategy { &mut projected_execution_state, ) .is_some() + && Self::projected_position_is_flat(&projected, &position.symbol) { same_day_sold_symbols.insert(position.symbol.clone()); + slot_working_symbols.remove(&position.symbol); } } } if self.config.daily_top_up_enabled && self.config.rotation_enabled + && !periodic_rebalance && trading_ratio > 0.0 && selection_limit > 0 { let target_budget = aiquant_total_value * trading_ratio; - let mut working_symbols = projected - .positions() - .keys() - .filter(|symbol| { - !same_day_sold_symbols.contains(*symbol) - && !exit_symbols.contains(*symbol) - && !delayed_sold_symbols.contains(*symbol) - && !unresolved_stop_loss_symbols.contains(*symbol) - }) - .cloned() - .collect::>(); + let mut working_symbols = slot_working_symbols.clone(); + if self.config.release_slot_on_exit_signal { + for symbol in &same_day_sold_symbols { + working_symbols.remove(symbol); + slot_working_symbols.remove(symbol); + } + } let value_symbols = working_symbols.clone(); let mut pending_buy_value = 0.0_f64; loop { - let excluded_symbols = Self::pending_exit_exclusion_symbols( - &unresolved_stop_loss_symbols, - &exit_symbols, - &delayed_sold_symbols, - ); - if Self::projected_position_count_excluding(&projected, &excluded_symbols) - >= selection_limit - { + if working_symbols.len() >= selection_limit { break; } let slot_buy_cash = self.remaining_buy_cash_per_slot( @@ -6296,14 +6506,20 @@ impl Strategy for PlatformExprStrategy { buy_cash, &mut projected_execution_state, ); + let spent = if filled_qty > 0 { + (cash_before_buy - projected.cash()).max(0.0) + } else { + available_buy_cash + }; + aiquant_available_cash = (aiquant_available_cash - spent).max(0.0); + working_symbols.insert(symbol.clone()); + slot_working_symbols.insert(symbol.clone()); + pending_buy_value += available_buy_cash; + filled_any = true; if filled_qty > 0 { - let spent = (cash_before_buy - projected.cash()).max(0.0); - aiquant_available_cash = (aiquant_available_cash - spent).max(0.0); - working_symbols.insert(symbol.clone()); - pending_buy_value += available_buy_cash; - filled_any = true; break; } + break; } if !attempted_any || !filled_any { break; @@ -6338,9 +6554,11 @@ impl Strategy for PlatformExprStrategy { &mut projected_execution_state, ) .is_some() + && Self::projected_position_is_flat(&projected, symbol) { same_day_sold_symbols.insert(symbol.clone()); } + slot_working_symbols.remove(symbol); } if !self.config.aiquant_transaction_cost { @@ -6348,17 +6566,13 @@ impl Strategy for PlatformExprStrategy { } let fixed_buy_cash = aiquant_total_value * trading_ratio / selection_limit as f64; let target_budget = aiquant_total_value * trading_ratio; - let mut rebalance_working_symbols = projected - .positions() - .keys() - .filter(|symbol| { - !same_day_sold_symbols.contains(*symbol) - && !exit_symbols.contains(*symbol) - && !delayed_sold_symbols.contains(*symbol) - && !unresolved_stop_loss_symbols.contains(*symbol) - }) - .cloned() - .collect::>(); + let mut rebalance_working_symbols = slot_working_symbols.clone(); + if self.config.release_slot_on_exit_signal { + for symbol in &same_day_sold_symbols { + rebalance_working_symbols.remove(symbol); + slot_working_symbols.remove(symbol); + } + } let rebalance_value_symbols = rebalance_working_symbols.clone(); let mut rebalance_pending_buy_value = 0.0_f64; for symbol in stock_list.iter().take(selection_limit) { @@ -6418,14 +6632,12 @@ impl Strategy for PlatformExprStrategy { let spent = (cash_before_buy - projected.cash()).max(0.0); aiquant_available_cash = (aiquant_available_cash - spent).max(0.0); intraday_attempted_buys.insert(symbol.clone()); + rebalance_working_symbols.insert(symbol.clone()); + slot_working_symbols.insert(symbol.clone()); } continue; } - if Self::projected_position_count_excluding( - &projected, - &unresolved_stop_loss_symbols, - ) >= selection_limit - { + if rebalance_working_symbols.len() >= selection_limit { break; } if pre_rebalance_symbols.contains(symbol) @@ -6480,6 +6692,7 @@ impl Strategy for PlatformExprStrategy { let spent = (cash_before_buy - projected.cash()).max(0.0); aiquant_available_cash = (aiquant_available_cash - spent).max(0.0); rebalance_working_symbols.insert(symbol.clone()); + slot_working_symbols.insert(symbol.clone()); rebalance_pending_buy_value += buy_cash; } } @@ -7853,6 +8066,7 @@ mod tests { assert_eq!(stock.volume, 12_300.0); assert_eq!(stock.last, 19.06); assert_eq!(stock.market_cap, 8.0); + assert_eq!(stock.market_cap_bn, 8.0); assert!(!stock.touched_upper_limit); } @@ -7959,7 +8173,9 @@ mod tests { .stock_state_with_factor_date(&ctx, date, date, symbol) .expect("stock state"); assert!((stock.market_cap - 9.0).abs() < 1e-9); + assert!((stock.market_cap_bn - 9.0).abs() < 1e-9); assert!((stock.free_float_cap - 4.5).abs() < 1e-9); + assert!((stock.free_float_cap_bn - 4.5).abs() < 1e-9); assert_eq!(stock.close, 10.0); assert!(stock.high.is_nan()); assert!(stock.low.is_nan()); @@ -7972,6 +8188,126 @@ mod tests { assert!(selected.is_empty()); } + #[test] + fn platform_market_cap_field_uses_storage_unit_without_extra_scaling() { + let date = d(2025, 4, 8); + let symbols = ["003008.SZ", "300999.SZ"]; + let data = DataSet::from_components( + symbols + .iter() + .map(|symbol| Instrument { + symbol: (*symbol).to_string(), + name: (*symbol).to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }) + .collect(), + symbols + .iter() + .map(|symbol| DailyMarketSnapshot { + date, + symbol: (*symbol).to_string(), + timestamp: Some("2025-04-08 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.5, + low: 9.8, + close: 10.0, + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + prev_close: 10.0, + volume: 120_000, + tick_volume: 1_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }) + .collect(), + symbols + .iter() + .map(|symbol| DailyFactorSnapshot { + date, + symbol: (*symbol).to_string(), + market_cap_bn: if *symbol == "003008.SZ" { 20.0 } else { 150.0 }, + free_float_cap_bn: if *symbol == "003008.SZ" { 15.0 } else { 120.0 }, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }) + .collect(), + symbols + .iter() + .map(|symbol| CandidateEligibility { + date, + symbol: (*symbol).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, + }) + .collect(), + vec![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + let portfolio = PortfolioState::new(1_000_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 40, + data: &data, + portfolio: &portfolio, + futures_account: None, + open_orders: &[], + dynamic_universe: None, + subscriptions: &subscriptions, + process_events: &[], + active_process_event: None, + active_datetime: None, + order_events: &[], + fills: &[], + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.aiquant_transaction_cost = true; + cfg.signal_symbol = "003008.SZ".to_string(); + cfg.stock_filter_expr = "true".to_string(); + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + let strategy = PlatformExprStrategy::new(cfg.clone()); + let day = strategy.day_state(&ctx, date).expect("day state"); + + let (selected_yi, _) = strategy + .select_symbols(&ctx, date, date, date, &day, 11.0, 39.0, 10) + .expect("selection"); + assert_eq!(selected_yi, vec!["003008.SZ".to_string()]); + + cfg.market_cap_field = "market_cap_bn".to_string(); + let strategy_bn = PlatformExprStrategy::new(cfg); + let (selected_bn, _) = strategy_bn + .select_symbols(&ctx, date, date, date, &day, 140.0, 160.0, 10) + .expect("selection by bn"); + assert_eq!(selected_bn, vec!["300999.SZ".to_string()]); + } + #[test] fn platform_stock_expr_rejects_when_volume_ma5_exceeds_market_volume_ma100() { let current = d(2023, 5, 4); @@ -10580,7 +10916,7 @@ mod tests { cfg.benchmark_short_ma_days = 1; cfg.benchmark_long_ma_days = 1; cfg.market_cap_lower_expr = "0".to_string(); - cfg.market_cap_upper_expr = "100".to_string(); + cfg.market_cap_upper_expr = "1000".to_string(); cfg.selection_limit_expr = "2".to_string(); cfg.stock_filter_expr = "close > 0".to_string(); cfg.daily_top_up_enabled = true; @@ -10608,7 +10944,7 @@ mod tests { } #[test] - fn platform_daily_top_up_allocates_remaining_budget_over_empty_slots() { + fn platform_daily_top_up_caps_empty_slot_to_equal_weight_target() { let prev_date = d(2025, 2, 2); let date = d(2025, 2, 3); let symbols = ["000001.SZ", "000002.SZ"]; @@ -10690,7 +11026,7 @@ mod tests { ) .expect("dataset"); - let mut portfolio = PortfolioState::new(20_000.0); + let mut portfolio = PortfolioState::new(10_000.0); portfolio .position_mut("000001.SZ") .buy(prev_date, 1_000, 10.0); @@ -10718,7 +11054,7 @@ mod tests { cfg.benchmark_short_ma_days = 1; cfg.benchmark_long_ma_days = 1; cfg.market_cap_lower_expr = "0".to_string(); - cfg.market_cap_upper_expr = "100".to_string(); + cfg.market_cap_upper_expr = "1000".to_string(); cfg.selection_limit_expr = "2".to_string(); cfg.stock_filter_expr = "close > 0".to_string(); cfg.daily_top_up_enabled = true; @@ -10735,7 +11071,7 @@ mod tests { reason, } if symbol == "000002.SZ" && reason == "daily_top_up_buy" - && (*value - 20_000.0).abs() < 1e-6 + && (*value - 10_000.0).abs() < 1e-6 ))); } @@ -11463,7 +11799,7 @@ mod tests { } #[test] - fn platform_daily_top_up_excludes_unsellable_stop_loss_from_target_count() { + fn platform_daily_top_up_keeps_unsellable_stop_loss_in_target_count() { let prev_date = d(2026, 3, 31); let date = d(2026, 4, 1); let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"]; @@ -11589,6 +11925,156 @@ mod tests { let decision = strategy.on_day(&ctx).expect("platform decision"); + assert!( + decision.order_intents.iter().any(|intent| matches!( + intent, + OrderIntent::TargetValue { + symbol, + target_value, + .. + } if symbol == "000001.SZ" && *target_value == 0.0 + )), + "{:?}", + decision.order_intents + ); + assert!( + !decision + .order_intents + .iter() + .any(|intent| matches!(intent, OrderIntent::Value { .. })), + "{:?}", + decision.order_intents + ); + } + + #[test] + fn platform_daily_top_up_can_release_unsellable_exit_signal_slot() { + let prev_date = d(2026, 3, 31); + let date = d(2026, 4, 1); + let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"]; + let data = DataSet::from_components( + symbols + .iter() + .map(|symbol| Instrument { + symbol: (*symbol).to_string(), + name: (*symbol).to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }) + .collect(), + symbols + .iter() + .map(|symbol| DailyMarketSnapshot { + date, + symbol: (*symbol).to_string(), + timestamp: Some("2026-04-01 09:33:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.5, + low: 9.8, + close: 10.0, + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + prev_close: 10.0, + volume: 1_000_000, + tick_volume: 10_000, + bid1_volume: 2_000, + ask1_volume: 2_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }) + .collect(), + symbols + .iter() + .map(|symbol| DailyFactorSnapshot { + date, + symbol: (*symbol).to_string(), + market_cap_bn: match *symbol { + "000003.SZ" => 8.0, + "000002.SZ" => 9.0, + _ => 20.0, + }, + free_float_cap_bn: 10.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }) + .collect(), + symbols + .iter() + .map(|symbol| CandidateEligibility { + date, + symbol: (*symbol).to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: *symbol != "000001.SZ", + is_kcb: false, + is_one_yuan: false, + }) + .collect(), + vec![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + + let mut portfolio = PortfolioState::new(20_000.0); + portfolio + .position_mut("000001.SZ") + .buy(prev_date, 100, 12.0); + portfolio + .position_mut("000002.SZ") + .buy(prev_date, 100, 10.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 2, + data: &data, + portfolio: &portfolio, + futures_account: None, + open_orders: &[], + dynamic_universe: None, + subscriptions: &subscriptions, + process_events: &[], + active_process_event: None, + active_datetime: None, + order_events: &[], + fills: &[], + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.signal_symbol = "000001.SZ".to_string(); + cfg.refresh_rate = 99; + cfg.max_positions = 2; + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + cfg.market_cap_lower_expr = "0".to_string(); + cfg.market_cap_upper_expr = "100".to_string(); + cfg.selection_limit_expr = "2".to_string(); + cfg.stock_filter_expr = "close > 0".to_string(); + cfg.stop_loss_expr = "0.9".to_string(); + cfg.daily_top_up_enabled = true; + cfg.release_slot_on_exit_signal = true; + let mut strategy = PlatformExprStrategy::new(cfg); + strategy.rebalance_day_counter = 2; + + let decision = strategy.on_day(&ctx).expect("platform decision"); + assert!( decision.order_intents.iter().any(|intent| matches!( intent, @@ -11752,6 +12238,7 @@ mod tests { cfg.stock_filter_expr = "close > 0".to_string(); cfg.take_profit_expr.clear(); cfg.stop_loss_expr.clear(); + cfg.daily_top_up_enabled = true; let mut strategy = PlatformExprStrategy::new(cfg); strategy.rebalance_day_counter = 20; @@ -11768,6 +12255,14 @@ mod tests { .count(); assert_eq!(periodic_buys, 2, "{:?}", decision.order_intents); + assert!( + !decision.order_intents.iter().any(|intent| matches!( + intent, + OrderIntent::Value { reason, .. } if reason == "daily_top_up_buy" + )), + "{:?}", + decision.order_intents + ); assert!( decision.order_intents.iter().any(|intent| matches!( intent, @@ -11783,7 +12278,7 @@ mod tests { } #[test] - fn platform_periodic_rebalance_allocates_remaining_budget_over_empty_slots() { + fn platform_periodic_rebalance_caps_empty_slot_to_equal_weight_target() { let prev_date = d(2025, 5, 13); let date = d(2025, 5, 14); let symbols = ["000001.SZ", "000002.SZ"]; @@ -11882,7 +12377,7 @@ mod tests { ) .expect("dataset"); - let mut portfolio = PortfolioState::new(20_000.0); + let mut portfolio = PortfolioState::new(10_000.0); portfolio .position_mut("000001.SZ") .buy(prev_date, 1_000, 10.0); @@ -11921,16 +12416,20 @@ mod tests { let decision = strategy.on_day(&ctx).expect("platform decision"); - assert!(decision.order_intents.iter().any(|intent| matches!( - intent, - OrderIntent::Value { - symbol, - value, - reason, - } if symbol == "000002.SZ" - && reason == "periodic_rebalance_buy" - && (*value - 20_000.0).abs() < 1e-6 - ))); + assert!( + decision.order_intents.iter().any(|intent| matches!( + intent, + OrderIntent::Value { + symbol, + value, + reason, + } if symbol == "000002.SZ" + && reason == "periodic_rebalance_buy" + && (*value - 10_000.0).abs() < 1e-6 + )), + "{:?}", + decision.order_intents + ); } #[test] @@ -14276,14 +14775,14 @@ mod tests { } #[test] - fn daily_limit_state_does_not_require_intraday_selection_quotes() { + fn selection_limit_state_requires_intraday_selection_quotes() { let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); cfg.stock_filter_expr = "close > 1.0 && !at_upper_limit && !at_lower_limit".to_string(); let strategy = PlatformExprStrategy::new(cfg); assert!( - !strategy.stock_filter_uses_intraday_quote_fields(), - "daily limit-state aliases are derived from daily close/limit fields" + strategy.stock_filter_uses_intraday_quote_fields(), + "selection limit-state aliases must use the scheduled intraday quote" ); let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 77f021a..61e8a92 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -295,6 +295,12 @@ pub struct StrategyExpressionTradingConfig { #[serde(default)] pub retry_empty_rebalance: Option, #[serde(default)] + pub delayed_limit_open_exit: Option, + #[serde(default)] + pub delayed_limit_open_exit_time: Option, + #[serde(default)] + pub release_slot_on_exit_signal: Option, + #[serde(default)] pub subscription_guard_required: Option, #[serde(default)] pub actions: Vec, @@ -815,6 +821,20 @@ pub fn platform_expr_config_from_spec( if let Some(enabled) = trading.retry_empty_rebalance { cfg.retry_empty_rebalance = enabled; } + if let Some(enabled) = trading.release_slot_on_exit_signal { + cfg.release_slot_on_exit_signal = enabled; + } + if let Some(enabled) = trading.delayed_limit_open_exit { + cfg.delayed_limit_open_exit_enabled = enabled; + if enabled { + cfg.delayed_limit_open_exit_time = trading + .delayed_limit_open_exit_time + .as_deref() + .and_then(|value| parse_schedule_clock_time(Some(value))); + } else { + cfg.delayed_limit_open_exit_time = None; + } + } if let Some(enabled) = spec .engine_config .as_ref() @@ -945,7 +965,13 @@ pub fn platform_expr_config_from_spec( if let Some(main_trade_time) = trade_times.last().copied() { cfg.intraday_execution_time = Some(main_trade_time); } - if aiquant_compat && trade_times.len() > 1 { + let delayed_limit_open_exit_explicit = spec + .runtime_expressions + .as_ref() + .and_then(|runtime_expr| runtime_expr.trading.as_ref()) + .and_then(|trading| trading.delayed_limit_open_exit) + .is_some(); + if aiquant_compat && !delayed_limit_open_exit_explicit && trade_times.len() > 1 { let delayed_time = trade_times[0]; if trade_times .last() @@ -1565,4 +1591,50 @@ mod tests { Some(NaiveTime::from_hms_opt(10, 31, 0).unwrap()) ); } + + #[test] + fn parses_explicit_delayed_limit_open_exit() { + let spec = serde_json::json!({ + "execution": { "compatibilityProfile": "aiquant_rqalpha" }, + "runtimeExpressions": { + "schedule": { "frequency": "daily", "time": "10:40" }, + "trading": { + "delayedLimitOpenExit": true, + "delayedLimitOpenExitTime": "10:31" + } + } + }); + + let cfg = platform_expr_config_from_value("", "", &spec).expect("config"); + + assert_eq!( + cfg.intraday_execution_time, + Some(NaiveTime::from_hms_opt(10, 40, 0).unwrap()) + ); + assert!(cfg.delayed_limit_open_exit_enabled); + assert_eq!( + cfg.delayed_limit_open_exit_time, + Some(NaiveTime::from_hms_opt(10, 31, 0).unwrap()) + ); + } + + #[test] + fn explicit_delayed_limit_open_exit_false_overrides_aiquant_trade_times() { + let spec = serde_json::json!({ + "execution": { "compatibilityProfile": "aiquant_rqalpha" }, + "rebalance": { "tradeTimes": ["10:31", "10:40"] }, + "runtimeExpressions": { + "schedule": { "frequency": "daily", "time": "10:40" }, + "trading": { + "delayedLimitOpenExit": false, + "delayedLimitOpenExitTime": "10:31" + } + } + }); + + let cfg = platform_expr_config_from_value("", "", &spec).expect("config"); + + assert!(!cfg.delayed_limit_open_exit_enabled); + assert_eq!(cfg.delayed_limit_open_exit_time, None); + } } diff --git a/crates/fidc-core/src/risk_control.rs b/crates/fidc-core/src/risk_control.rs index e756216..5740cd8 100644 --- a/crates/fidc-core/src/risk_control.rs +++ b/crates/fidc-core/src/risk_control.rs @@ -41,6 +41,21 @@ impl ChinaAShareRiskControl { candidate: &CandidateEligibility, market: &DailyMarketSnapshot, instrument: Option<&Instrument>, + ) -> Option<&'static str> { + if let Some(reason) = Self::baseline_rejection_reason(date, candidate, market, instrument) { + return Some(reason); + } + if !candidate.allow_buy || !candidate.allow_sell { + return Some("trade_disabled"); + } + None + } + + pub fn baseline_rejection_reason( + date: NaiveDate, + candidate: &CandidateEligibility, + market: &DailyMarketSnapshot, + instrument: Option<&Instrument>, ) -> Option<&'static str> { if let Some(reason) = Self::instrument_rejection_reason(instrument, date) { return Some(reason); @@ -60,9 +75,6 @@ impl ChinaAShareRiskControl { if candidate.is_one_yuan || market.day_open <= 1.0 { return Some("one_yuan"); } - if !candidate.allow_buy || !candidate.allow_sell { - return Some("trade_disabled"); - } None } @@ -73,8 +85,7 @@ impl ChinaAShareRiskControl { instrument: Option<&Instrument>, check_price: f64, ) -> Option<&'static str> { - if let Some(reason) = Self::selection_rejection_reason(date, candidate, market, instrument) - { + if let Some(reason) = Self::baseline_rejection_reason(date, candidate, market, instrument) { return Some(reason); } if !candidate.allow_buy { diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 861e61c..7ff6bf0 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -40,6 +40,9 @@ pub trait Strategy { fn schedule_rules(&self) -> Vec { Vec::new() } + fn decision_quote_times(&self) -> Vec { + Vec::new() + } fn on_scheduled( &mut self, _ctx: &StrategyContext<'_>, diff --git a/crates/fidc-core/tests/decision_quote_preload.rs b/crates/fidc-core/tests/decision_quote_preload.rs new file mode 100644 index 0000000..614e79e --- /dev/null +++ b/crates/fidc-core/tests/decision_quote_preload.rs @@ -0,0 +1,224 @@ +use chrono::{NaiveDate, NaiveTime}; +use fidc_core::{ + BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, + ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, + IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext, + StrategyDecision, +}; + +fn d(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).expect("valid date") +} + +fn t(hour: u32, minute: u32, second: u32) -> NaiveTime { + NaiveTime::from_hms_opt(hour, minute, second).expect("valid time") +} + +#[derive(Default)] +struct DecisionQuoteReader { + day_count: usize, +} + +impl Strategy for DecisionQuoteReader { + fn name(&self) -> &str { + "decision_quote_reader" + } + + fn decision_quote_times(&self) -> Vec { + vec![t(10, 40, 0)] + } + + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + self.day_count += 1; + if self.day_count == 1 { + return Ok(StrategyDecision { + order_intents: vec![OrderIntent::Value { + symbol: "000001.SZ".to_string(), + value: 5_000.0, + reason: "seed_position".to_string(), + }], + ..StrategyDecision::default() + }); + } + + assert!( + ctx.portfolio.position("000001.SZ").is_some(), + "second day should carry the first day position" + ); + let quote_loaded_before_decision = ctx + .data + .execution_quotes_on(ctx.execution_date, "000001.SZ") + .iter() + .any(|quote| quote.timestamp.time() == t(10, 40, 0) && quote.last_price == 11.0); + assert!( + quote_loaded_before_decision, + "engine must load declared decision quote before strategy.on_day" + ); + Ok(StrategyDecision::default()) + } +} + +#[test] +fn engine_preloads_declared_decision_quotes_for_current_positions() { + let first = d(2026, 1, 5); + let second = d(2026, 1, 6); + let data = DataSet::from_components( + Vec::new(), + vec![ + DailyMarketSnapshot { + date: first, + symbol: "000001.SZ".to_string(), + timestamp: Some("2026-01-05 15:00:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.2, + low: 9.9, + close: 10.0, + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + prev_close: 9.8, + volume: 10_000, + tick_volume: 1_000, + bid1_volume: 10_000, + ask1_volume: 10_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 10.78, + lower_limit: 8.82, + price_tick: 0.01, + }, + DailyMarketSnapshot { + date: second, + symbol: "000001.SZ".to_string(), + timestamp: Some("2026-01-06 15:00:00".to_string()), + day_open: 10.5, + open: 10.5, + high: 11.2, + low: 10.4, + close: 10.6, + last_price: 10.6, + bid1: 10.6, + ask1: 10.6, + prev_close: 10.0, + volume: 10_000, + tick_volume: 1_000, + bid1_volume: 10_000, + ask1_volume: 10_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }, + ], + vec![ + DailyFactorSnapshot { + date: first, + symbol: "000001.SZ".to_string(), + market_cap_bn: 10.0, + free_float_cap_bn: 10.0, + pe_ttm: 10.0, + turnover_ratio: None, + effective_turnover_ratio: None, + extra_factors: Default::default(), + }, + DailyFactorSnapshot { + date: second, + symbol: "000001.SZ".to_string(), + market_cap_bn: 10.0, + free_float_cap_bn: 10.0, + pe_ttm: 10.0, + turnover_ratio: None, + effective_turnover_ratio: None, + extra_factors: Default::default(), + }, + ], + vec![ + CandidateEligibility { + date: first, + symbol: "000001.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, + }, + CandidateEligibility { + date: second, + symbol: "000001.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: first, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1000.0, + prev_close: 990.0, + volume: 1_000_000, + }, + BenchmarkSnapshot { + date: second, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1001.0, + prev_close: 1000.0, + volume: 1_000_000, + }, + ], + ) + .expect("dataset"); + + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Last, + ) + .with_matching_type(MatchingType::NextTickLast) + .with_intraday_execution_start_time(t(10, 40, 0)); + let config = BacktestConfig { + initial_cash: 10_000.0, + benchmark_code: "000852.SH".to_string(), + start_date: Some(first), + end_date: Some(second), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Last, + }; + let mut engine = BacktestEngine::new(data, DecisionQuoteReader::default(), broker, config) + .with_execution_quote_loader(move |request| { + Ok(request + .symbols + .into_iter() + .map(|symbol| IntradayExecutionQuote { + date: request.date, + symbol, + timestamp: request + .date + .and_time(request.start_time.unwrap_or(t(10, 40, 0))), + last_price: if request.date == second { 11.0 } else { 10.0 }, + bid1: if request.date == second { 11.0 } else { 10.0 }, + ask1: if request.date == second { 11.0 } else { 10.0 }, + bid1_volume: 10_000, + ask1_volume: 10_000, + volume_delta: 10_000, + amount_delta: 100_000.0, + trading_phase: Some("continuous".to_string()), + }) + .collect()) + }); + + engine.run().expect("backtest should run"); +}