diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index b4f0387..740328e 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -11,7 +11,7 @@ use crate::events::{ ProcessEventKind, }; use crate::portfolio::PortfolioState; -use crate::rules::EquityRuleHooks; +use crate::rules::{EquityRuleHooks, RuleCheck}; use crate::strategy::{ AlgoOrderStyle, OpenOrderView, OrderIntent, StrategyDecision, TargetPortfolioOrderPricing, }; @@ -111,6 +111,7 @@ pub struct BrokerSimulator { inactive_limit: bool, liquidity_limit: bool, strict_value_budget: bool, + aiquant_rqalpha_execution_rules: bool, same_day_buy_close_mark_at_fill: bool, intraday_execution_start_time: Option, runtime_intraday_start_time: Cell>, @@ -133,6 +134,7 @@ impl BrokerSimulator { inactive_limit: true, liquidity_limit: true, strict_value_budget: false, + aiquant_rqalpha_execution_rules: false, same_day_buy_close_mark_at_fill: false, intraday_execution_start_time: None, runtime_intraday_start_time: Cell::new(None), @@ -159,6 +161,7 @@ impl BrokerSimulator { inactive_limit: true, liquidity_limit: true, strict_value_budget: false, + aiquant_rqalpha_execution_rules: false, same_day_buy_close_mark_at_fill: false, intraday_execution_start_time: None, runtime_intraday_start_time: Cell::new(None), @@ -188,6 +191,11 @@ impl BrokerSimulator { self } + pub fn with_aiquant_rqalpha_execution_rules(mut self, enabled: bool) -> Self { + self.aiquant_rqalpha_execution_rules = enabled; + self + } + pub fn with_same_day_buy_close_mark_at_fill(mut self, enabled: bool) -> Self { self.same_day_buy_close_mark_at_fill = enabled; self @@ -1825,6 +1833,68 @@ where Ok(()) } + fn aiquant_limit_check_price( + &self, + snapshot: &crate::data::DailyMarketSnapshot, + side: OrderSide, + ) -> f64 { + match (self.execution_price_field, side) { + (PriceField::Last, _) => snapshot.price(PriceField::Last), + (_, OrderSide::Buy) => snapshot.buy_price(self.execution_price_field), + (_, OrderSide::Sell) => snapshot.sell_price(self.execution_price_field), + } + } + + fn buy_rule_check( + &self, + date: NaiveDate, + snapshot: &crate::data::DailyMarketSnapshot, + candidate: &crate::data::CandidateEligibility, + ) -> RuleCheck { + if !self.aiquant_rqalpha_execution_rules { + return self + .rules + .can_buy(date, snapshot, candidate, self.execution_price_field); + } + if snapshot.paused || candidate.is_paused { + return RuleCheck::reject("paused"); + } + let check_price = self.aiquant_limit_check_price(snapshot, OrderSide::Buy); + if snapshot.is_at_upper_limit_price(check_price) { + return RuleCheck::reject("open at or above upper limit"); + } + RuleCheck::allow() + } + + fn sell_rule_check( + &self, + date: NaiveDate, + snapshot: &crate::data::DailyMarketSnapshot, + candidate: &crate::data::CandidateEligibility, + position: &crate::portfolio::Position, + ) -> RuleCheck { + if !self.aiquant_rqalpha_execution_rules { + return self.rules.can_sell( + date, + snapshot, + candidate, + position, + self.execution_price_field, + ); + } + if snapshot.paused || candidate.is_paused { + return RuleCheck::reject("paused"); + } + let check_price = self.aiquant_limit_check_price(snapshot, OrderSide::Sell); + if snapshot.is_at_lower_limit_price(check_price) { + return RuleCheck::reject("open at or below lower limit"); + } + if position.sellable_qty(date) == 0 { + return RuleCheck::reject("t+1 sellable quantity is zero"); + } + RuleCheck::allow() + } + fn minimum_target_quantity( &self, date: NaiveDate, @@ -1847,13 +1917,7 @@ where let Ok(candidate) = data.require_candidate(date, symbol) else { return current_qty; }; - let rule = self.rules.can_sell( - date, - snapshot, - candidate, - position, - self.execution_price_field, - ); + let rule = self.sell_rule_check(date, snapshot, candidate, position); if !rule.allowed { return current_qty; } @@ -1891,9 +1955,7 @@ where let Ok(candidate) = data.require_candidate(date, symbol) else { return current_qty; }; - let rule = self - .rules - .can_buy(date, snapshot, candidate, self.execution_price_field); + let rule = self.buy_rule_check(date, snapshot, candidate); if !rule.allowed { return current_qty; } @@ -1937,13 +1999,7 @@ where let position = portfolio.position(symbol)?; let snapshot = data.require_market(date, symbol).ok()?; let candidate = data.require_candidate(date, symbol).ok()?; - let rule = self.rules.can_sell( - date, - snapshot, - candidate, - position, - self.execution_price_field, - ); + let rule = self.sell_rule_check(date, snapshot, candidate, position); if !rule.allowed { return rule.reason; } @@ -1983,9 +2039,7 @@ where ) -> Option { let snapshot = data.require_market(date, symbol).ok()?; let candidate = data.require_candidate(date, symbol).ok()?; - let rule = self - .rules - .can_buy(date, snapshot, candidate, self.execution_price_field); + let rule = self.buy_rule_check(date, snapshot, candidate); if !rule.allowed { return rule.reason; } @@ -2055,13 +2109,7 @@ where ); } - let rule = self.rules.can_sell( - date, - snapshot, - candidate, - position, - self.execution_price_field, - ); + let rule = self.sell_rule_check(date, snapshot, candidate, position); if !rule.allowed { let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string(); let status = match rule.reason.as_deref() { @@ -3494,9 +3542,7 @@ where ); } - let rule = self - .rules - .can_buy(date, snapshot, candidate, self.execution_price_field); + let rule = self.buy_rule_check(date, snapshot, candidate); if !rule.allowed { let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string(); let status = match rule.reason.as_deref() { @@ -4717,7 +4763,9 @@ fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str { mod tests { use super::{BrokerSimulator, MatchingType}; use crate::cost::ChinaAShareCostModel; - use crate::data::{DailyMarketSnapshot, IntradayExecutionQuote, PriceField}; + use crate::data::{ + CandidateEligibility, DailyMarketSnapshot, IntradayExecutionQuote, PriceField, + }; use crate::events::OrderSide; use crate::rules::ChinaEquityRuleHooks; @@ -4765,6 +4813,21 @@ mod tests { } } + fn limit_test_candidate(allow_buy: bool, allow_sell: bool) -> CandidateEligibility { + let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); + CandidateEligibility { + date, + symbol: "000001.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy, + allow_sell, + is_kcb: false, + is_one_yuan: false, + } + } + #[test] fn next_tick_last_without_volume_or_liquidity_limit_does_not_cap_quote_quantity() { let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) @@ -4849,6 +4912,38 @@ mod tests { assert_eq!(fill.unfilled_reason, Some("open at or above upper limit")); } + #[test] + fn aiquant_rules_allow_buy_when_day_flags_block_but_last_price_is_tradable() { + let mut snapshot = limit_test_snapshot(); + snapshot.open = 11.0; + snapshot.day_open = 11.0; + snapshot.last_price = 10.98; + snapshot.ask1 = 11.0; + let candidate = limit_test_candidate(false, true); + let date = snapshot.date; + + let default_broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Last, + ); + let default_rule = default_broker.buy_rule_check(date, &snapshot, &candidate); + assert!(!default_rule.allowed); + assert_eq!( + default_rule.reason.as_deref(), + Some("buy disabled by eligibility flags") + ); + + let aiquant_broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Last, + ) + .with_aiquant_rqalpha_execution_rules(true); + let aiquant_rule = aiquant_broker.buy_rule_check(date, &snapshot, &candidate); + assert!(aiquant_rule.allowed); + } + #[test] fn intraday_execution_rejects_sell_at_lower_limit_price() { let broker = BrokerSimulator::new_with_execution_price( diff --git a/crates/fidc-core/src/cost.rs b/crates/fidc-core/src/cost.rs index 036ac25..77acaad 100644 --- a/crates/fidc-core/src/cost.rs +++ b/crates/fidc-core/src/cost.rs @@ -53,6 +53,14 @@ impl Default for ChinaAShareCostModel { } impl ChinaAShareCostModel { + pub fn aiquant_rqalpha_default() -> Self { + Self { + stamp_tax_rate_before_change: 0.0005, + stamp_tax_rate_after_change: 0.0005, + ..Self::default() + } + } + pub fn commission_for(&self, gross_amount: f64) -> f64 { if gross_amount <= 0.0 { return 0.0; diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 8d8e7f8..e689b5c 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -313,6 +313,7 @@ pub struct BacktestEngine { broker: BrokerSimulator, config: BacktestConfig, dividend_reinvestment: bool, + cash_dividends_enabled: bool, process_event_bus: ProcessEventBus, dynamic_universe: Option>, subscriptions: BTreeSet, @@ -338,6 +339,7 @@ impl BacktestEngine { broker, config, dividend_reinvestment: false, + cash_dividends_enabled: true, process_event_bus: ProcessEventBus::new(), dynamic_universe: None, subscriptions: BTreeSet::new(), @@ -356,6 +358,11 @@ impl BacktestEngine { self } + pub fn with_cash_dividends(mut self, enabled: bool) -> Self { + self.cash_dividends_enabled = enabled; + self + } + pub fn with_futures_account(mut self, account: FuturesAccountState) -> Self { self.futures_account = Some(account); self @@ -2521,7 +2528,7 @@ where continue; } - if action.share_cash.abs() > f64::EPSILON { + if self.cash_dividends_enabled && action.share_cash.abs() > f64::EPSILON { let cash_before = portfolio.cash(); let (cash_delta, quantity_after, average_cost) = { let position = portfolio @@ -2990,24 +2997,17 @@ where } let quantity = position.quantity; - let fallback_reference_price = if position.last_price > 0.0 { + let settlement_price = if position.last_price.is_finite() && position.last_price > 0.0 { position.last_price - } else { + } else if position.average_cost.is_finite() && position.average_cost > 0.0 { position.average_cost + } else { + 0.0 }; let effective_delisted_at = instrument .delisted_at .or_else(|| self.data.calendar().previous_day(date)) .unwrap_or(date); - let settlement_price = self - .data - .price_on_or_before(effective_delisted_at, &symbol, PriceField::Close) - .or_else(|| { - self.data - .price_on_or_before(date, &symbol, PriceField::Close) - }) - .filter(|price| price.is_finite() && *price > 0.0) - .unwrap_or(fallback_reference_price); if !settlement_price.is_finite() || settlement_price <= 0.0 { return Err(BacktestError::Execution(format!( "missing delisting settlement price for {} on {}", diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index c969378..513ed4b 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -201,8 +201,11 @@ pub struct PlatformExprStrategyConfig { pub rotation_enabled: bool, pub daily_top_up_enabled: bool, pub retry_empty_rebalance: bool, + pub calendar_rebalance_interval: bool, + pub aiquant_transaction_cost: bool, pub strict_value_budget: bool, pub quote_quantity_limit: bool, + pub intraday_execution_time: Option, pub explicit_action_stage: PlatformExplicitActionStage, pub explicit_action_schedule: Option, pub subscription_guard_required: bool, @@ -255,8 +258,11 @@ fn band_low(index_close) { rotation_enabled: true, daily_top_up_enabled: false, retry_empty_rebalance: false, + calendar_rebalance_interval: false, + aiquant_transaction_cost: false, strict_value_budget: false, quote_quantity_limit: true, + intraday_execution_time: None, explicit_action_stage: PlatformExplicitActionStage::OnDay, explicit_action_schedule: None, subscription_guard_required: false, @@ -431,6 +437,7 @@ pub struct PlatformExprStrategy { config: PlatformExprStrategyConfig, engine: Engine, rebalance_day_counter: usize, + last_rebalance_date: Option, pending_highlimit_holdings: BTreeSet, /// 已编译表达式 AST 缓存。 /// Key 是经过 normalize/expand_runtime_helpers 之后的完整 script 文本, @@ -485,17 +492,25 @@ impl PlatformExprStrategy { engine.register_fn("starts_with", |value: &str, prefix: &str| { value.starts_with(prefix) }); + engine.register_fn("startswith", |value: &str, prefix: &str| { + value.starts_with(prefix) + }); engine.register_fn("ends_with", |value: &str, suffix: &str| { value.ends_with(suffix) }); + engine.register_fn("endswith", |value: &str, suffix: &str| { + value.ends_with(suffix) + }); engine.register_fn("lower", |value: &str| value.to_lowercase()); engine.register_fn("upper", |value: &str| value.to_uppercase()); engine.register_fn("trim", |value: &str| value.trim().to_string()); engine.register_fn("strlen", |value: &str| value.chars().count() as i64); + engine.register_fn("code_number", code_number_value); Self { config, engine, rebalance_day_counter: 0, + last_rebalance_date: None, pending_highlimit_holdings: BTreeSet::new(), compiled_cache: RefCell::new(HashMap::new()), cache_hits: RefCell::new(0), @@ -789,19 +804,29 @@ impl PlatformExprStrategy { } fn intraday_execution_start_time(&self) -> NaiveTime { - NaiveTime::from_hms_opt(10, 18, 0).expect("valid 10:18") + self.config + .intraday_execution_time + .unwrap_or_else(|| NaiveTime::from_hms_opt(10, 18, 0).expect("valid 10:18")) } fn buy_commission(&self, gross_amount: f64) -> f64 { - ChinaAShareCostModel::default().commission_for(gross_amount) + self.cost_model().commission_for(gross_amount) } fn sell_cost(&self, date: NaiveDate, gross_amount: f64) -> f64 { - let model = ChinaAShareCostModel::default(); + let model = self.cost_model(); model.commission_for(gross_amount) + model.stamp_tax_for(date, OrderSide::Sell, gross_amount) } + fn cost_model(&self) -> ChinaAShareCostModel { + if self.config.aiquant_transaction_cost { + ChinaAShareCostModel::aiquant_rqalpha_default() + } else { + ChinaAShareCostModel::default() + } + } + fn marked_total_value(&self, ctx: &StrategyContext<'_>, date: NaiveDate) -> f64 { let mut total = ctx.portfolio.cash(); for position in ctx.portfolio.positions().values() { @@ -909,6 +934,12 @@ impl PlatformExprStrategy { } fn projected_execution_price(&self, market: &DailyMarketSnapshot, side: OrderSide) -> f64 { + if self.config.aiquant_transaction_cost { + let last = market.price(PriceField::Last); + if last.is_finite() && last > 0.0 { + return last; + } + } match side { OrderSide::Buy => market.buy_price(PriceField::Last), OrderSide::Sell => market.sell_price(PriceField::Last), @@ -917,13 +948,64 @@ impl PlatformExprStrategy { fn projected_execution_start_cursor( &self, + ctx: &StrategyContext<'_>, date: NaiveDate, _symbol: &str, _execution_state: &ProjectedExecutionState, ) -> NaiveDateTime { + if self.config.aiquant_transaction_cost { + return date.and_time(self.intraday_execution_start_time()); + } + if let Some(active_datetime) = ctx.active_datetime + && active_datetime.date() == date + { + return active_datetime; + } date.and_time(self.intraday_execution_start_time()) } + fn aiquant_scheduled_quote<'a>( + &self, + ctx: &'a StrategyContext<'_>, + date: NaiveDate, + symbol: &str, + ) -> Option<&'a crate::data::IntradayExecutionQuote> { + if !self.config.aiquant_transaction_cost { + return None; + } + let start_cursor = self.projected_execution_start_cursor( + ctx, + date, + symbol, + &ProjectedExecutionState::default(), + ); + ctx.data + .execution_quotes_on(date, symbol) + .iter() + .find(|quote| quote.timestamp >= start_cursor) + } + + fn aiquant_scheduled_buy_price( + &self, + ctx: &StrategyContext<'_>, + date: NaiveDate, + symbol: &str, + ) -> Option { + self.aiquant_scheduled_quote(ctx, date, symbol) + .and_then(|quote| quote.buy_price()) + } + + fn projected_quote_execution_price( + &self, + quote: &crate::data::IntradayExecutionQuote, + side: OrderSide, + ) -> Option { + match side { + OrderSide::Buy => quote.buy_price(), + OrderSide::Sell => quote.sell_price(), + } + } + fn projected_select_execution_fill( &self, ctx: &StrategyContext<'_>, @@ -943,7 +1025,8 @@ impl PlatformExprStrategy { return None; } - let start_cursor = self.projected_execution_start_cursor(date, symbol, execution_state); + let start_cursor = + self.projected_execution_start_cursor(ctx, date, symbol, execution_state); let quotes = ctx.data.execution_quotes_on(date, symbol); let mut filled_qty = 0_u32; let mut gross_amount = 0.0_f64; @@ -953,11 +1036,7 @@ impl PlatformExprStrategy { if quote.timestamp < start_cursor { continue; } - let fallback_quote_price = match side { - OrderSide::Buy => quote.buy_price(), - OrderSide::Sell => quote.sell_price(), - }; - let Some(quote_price) = fallback_quote_price else { + let Some(quote_price) = self.projected_quote_execution_price(quote, side) else { continue; }; let available_qty = if self.config.quote_quantity_limit { @@ -1077,12 +1156,17 @@ impl PlatformExprStrategy { } })?; let gross_amount = fill.price * fill.quantity as f64; - let net_cash = gross_amount - self.sell_cost(date, gross_amount); + let sell_cost = self.sell_cost(date, gross_amount); + let cash_delta = if self.config.aiquant_transaction_cost { + -sell_cost + } else { + gross_amount - sell_cost + }; projected .position_mut(symbol) .sell(fill.quantity, fill.price) .ok()?; - projected.apply_cash_delta(net_cash); + projected.apply_cash_delta(cash_delta); *execution_state .intraday_turnover .entry(symbol.to_string()) @@ -1394,6 +1478,7 @@ impl PlatformExprStrategy { ) -> Result { let market = ctx.data.require_market(date, symbol)?; let feature_market = ctx.data.market(factor_date, symbol).unwrap_or(market); + let decision_quote = self.aiquant_scheduled_quote(ctx, date, symbol); let factor = ctx.data.require_factor(factor_date, symbol)?; let candidate = ctx.data.require_candidate(date, symbol)?; let instrument = ctx.data.instrument(symbol); @@ -1466,7 +1551,9 @@ impl PlatformExprStrategy { high: feature_market.high, low: feature_market.low, close: feature_market.close, - last: market.last_price, + last: decision_quote + .and_then(|quote| quote.buy_price()) + .unwrap_or(market.last_price), prev_close: market.prev_close, amount, upper_limit: market.upper_limit, @@ -2177,7 +2264,7 @@ impl PlatformExprStrategy { } fn normalize_expr(expr: &str) -> String { - Self::rewrite_ternary(expr.trim()) + Self::rewrite_ternary(&Self::normalize_runtime_field_aliases(expr.trim())) } fn normalize_prelude_for_eval(prelude: &str) -> String { @@ -2186,22 +2273,154 @@ impl PlatformExprStrategy { .map(|line| { let trimmed = line.trim_end(); let head = trimmed.trim_start(); + let normalized_assignment = Self::normalize_prelude_assignment_expr(trimmed); + let normalized_head = normalized_assignment.trim_start(); if head.is_empty() || head.starts_with("//") - || trimmed.ends_with(';') - || trimmed.ends_with('{') - || trimmed.ends_with('}') - || !(head.starts_with("let ") || head.starts_with("const ")) + || normalized_assignment.ends_with(';') + || normalized_assignment.ends_with('{') + || normalized_assignment.ends_with('}') + || !(normalized_head.starts_with("let ") + || normalized_head.starts_with("const ")) { - trimmed.to_string() + normalized_assignment } else { - format!("{trimmed};") + format!("{normalized_assignment};") } }) .collect::>() .join("\n") } + fn normalize_prelude_assignment_expr(line: &str) -> String { + let head = line.trim_start(); + if !(head.starts_with("let ") || head.starts_with("const ")) { + return line.to_string(); + } + let (body, suffix) = line + .strip_suffix(';') + .map(|body| (body, ";")) + .unwrap_or((line, "")); + let Some(eq_idx) = body.find('=') else { + return line.to_string(); + }; + let lhs = body[..=eq_idx].trim_end(); + let rhs = body[eq_idx + 1..].trim(); + if rhs.is_empty() { + return line.to_string(); + } + let rhs = Self::normalize_runtime_field_aliases(rhs); + let rhs = Self::normalize_prelude_runtime_helpers(&rhs); + format!("{lhs} {}{suffix}", Self::rewrite_ternary(&rhs)) + } + + fn normalize_runtime_field_aliases(expr: &str) -> String { + expr.replace("signal.close", "signal_close") + .replace("signal.open", "signal_open") + .replace("benchmark.close", "benchmark_close") + .replace("benchmark.open", "benchmark_open") + } + + fn normalize_prelude_runtime_helpers(expr: &str) -> String { + Self::normalize_day_factor_helper_calls(expr) + } + + fn normalize_day_factor_helper_calls(expr: &str) -> String { + let mut output = String::with_capacity(expr.len()); + let mut cursor = 0usize; + let mut in_single_quote = false; + let mut in_double_quote = false; + let mut escaped = false; + while cursor < expr.len() { + let Some(ch) = expr[cursor..].chars().next() else { + break; + }; + if escaped { + output.push(ch); + escaped = false; + cursor += ch.len_utf8(); + continue; + } + if ch == '\\' && (in_single_quote || in_double_quote) { + output.push(ch); + escaped = true; + cursor += ch.len_utf8(); + continue; + } + if ch == '\'' && !in_double_quote { + output.push(ch); + in_single_quote = !in_single_quote; + cursor += ch.len_utf8(); + continue; + } + if ch == '"' && !in_single_quote { + output.push(ch); + in_double_quote = !in_double_quote; + cursor += ch.len_utf8(); + continue; + } + if in_single_quote || in_double_quote { + output.push(ch); + cursor += ch.len_utf8(); + continue; + } + if !expr[cursor..].starts_with("day_factor") { + output.push(ch); + cursor += ch.len_utf8(); + continue; + } + let ident_end = cursor + "day_factor".len(); + let prev_ok = cursor == 0 + || expr[..cursor] + .chars() + .next_back() + .is_none_or(|prev| !(prev == '_' || prev.is_ascii_alphanumeric())); + let next_ok = expr[ident_end..] + .chars() + .next() + .is_some_and(|next| next == '(' || next.is_whitespace()); + if !prev_ok || !next_ok { + output.push(ch); + cursor += ch.len_utf8(); + continue; + } + let mut call_cursor = ident_end; + while call_cursor < expr.len() + && expr[call_cursor..] + .chars() + .next() + .is_some_and(char::is_whitespace) + { + call_cursor += expr[call_cursor..].chars().next().unwrap().len_utf8(); + } + if !expr[call_cursor..].starts_with('(') { + output.push(ch); + cursor += ch.len_utf8(); + continue; + } + let Some(close_idx) = Self::find_matching_paren(expr, call_cursor) else { + output.push_str(&expr[cursor..ident_end]); + cursor = ident_end; + continue; + }; + let args = Self::split_top_level_args(&expr[call_cursor + 1..close_idx]); + if args.len() != 1 { + output.push_str(&expr[cursor..=close_idx]); + cursor = close_idx + 1; + continue; + } + let Ok(key) = Self::parse_string_or_identifier(&args[0]) else { + output.push_str(&expr[cursor..=close_idx]); + cursor = close_idx + 1; + continue; + }; + let key = Self::normalize_runtime_factor_key(&key); + output.push_str(&format!("day_factors[{}]", Self::quote_rhai_string(&key))); + cursor = close_idx + 1; + } + output + } + fn expand_runtime_helpers( &self, ctx: &StrategyContext<'_>, @@ -4362,7 +4581,7 @@ impl PlatformExprStrategy { let Some(market) = ctx.data.market(date, &factor.symbol) else { continue; }; - if market.paused { + if market.paused && !self.config.aiquant_transaction_cost { continue; } if !self.stock_passes_universe_exclude(candidate, market, false) { @@ -4407,6 +4626,9 @@ impl PlatformExprStrategy { { return false; } + if self.config.aiquant_transaction_cost { + return true; + } candidate.allow_buy && candidate.allow_sell } @@ -4548,11 +4770,18 @@ impl PlatformExprStrategy { if excludes.iter().any(|item| item == "one_yuan") && stock.is_one_yuan { return Ok(Some("one_yuan".to_string())); } - if !candidate.allow_buy { + if !self.config.aiquant_transaction_cost && !candidate.allow_buy { return Ok(Some("buy_disabled".to_string())); } - if market.is_at_upper_limit_price(market.day_open) - || market.is_at_upper_limit_price(market.buy_price(PriceField::Last)) + let upper_limit_check_price = if self.config.aiquant_transaction_cost { + self.aiquant_scheduled_buy_price(ctx, date, symbol) + .unwrap_or_else(|| market.price(PriceField::Last)) + } else { + market.buy_price(PriceField::Last) + }; + if (!self.config.aiquant_transaction_cost + && market.is_at_upper_limit_price(market.day_open)) + || market.is_at_upper_limit_price(upper_limit_check_price) { return Ok(Some("upper_limit".to_string())); } @@ -4805,6 +5034,13 @@ impl Strategy for PlatformExprStrategy { .data .previous_trading_date(decision_date, 1) .unwrap_or(decision_date); + let selection_market_date = if self.config.aiquant_transaction_cost + && self.config.intraday_execution_time.is_some() + { + execution_date + } else { + decision_date + }; let (explicit_action_intents, explicit_action_diagnostics) = if self.config.explicit_action_stage == PlatformExplicitActionStage::OnDay && self.explicit_actions_active(ctx.data.calendar(), execution_date) @@ -4841,7 +5077,7 @@ impl Strategy for PlatformExprStrategy { let stock_list = if self.config.rotation_enabled { let (stock_list, notes) = self.select_symbols( ctx, - decision_date, + selection_market_date, selection_factor_date, &day, band_low, @@ -4863,6 +5099,14 @@ impl Strategy for PlatformExprStrategy { ScheduleStage::OnDay, default_stage_time(ScheduleStage::OnDay), ) || empty_rebalance_retry + } else if self.config.calendar_rebalance_interval { + self.last_rebalance_date + .map(|last| { + execution_date.signed_duration_since(last).num_days() + >= self.config.refresh_rate as i64 + }) + .unwrap_or(true) + || empty_rebalance_retry } else { self.rebalance_day_counter % self.config.refresh_rate == 0 || empty_rebalance_retry } @@ -5172,8 +5416,12 @@ impl Strategy for PlatformExprStrategy { symbol, )?; let stock_scale = self.buy_scale(ctx, &day, &decision_stock)?; - let buy_cash = (fixed_buy_cash * stock_scale) - .min(aiquant_available_cash / slots_remaining as f64); + let cash_cap = if self.config.aiquant_transaction_cost { + aiquant_available_cash + } else { + aiquant_available_cash / slots_remaining as f64 + }; + let buy_cash = (fixed_buy_cash * stock_scale).min(cash_cap); if buy_cash <= 0.0 { break; } @@ -5209,7 +5457,11 @@ impl Strategy for PlatformExprStrategy { } } if self.config.rotation_enabled && self.config.rebalance_schedule.is_none() { - if periodic_rebalance { + if self.config.calendar_rebalance_interval { + if periodic_rebalance { + self.last_rebalance_date = Some(execution_date); + } + } else if periodic_rebalance { self.rebalance_day_counter = 1; } else { self.rebalance_day_counter = self.rebalance_day_counter.saturating_add(1); @@ -5240,7 +5492,7 @@ impl Strategy for PlatformExprStrategy { ) }, format!( - "selected={} periodic_rebalance={} exits={} projected_positions={} intents={} limit={} decision_date={} selection_factor_date={} execution_date={} budget_total={:.2} marked_total={:.2} day_total={:.2}", + "selected={} periodic_rebalance={} exits={} projected_positions={} intents={} limit={} decision_date={} selection_market_date={} selection_factor_date={} execution_date={} budget_total={:.2} marked_total={:.2} day_total={:.2}", stock_list.len(), periodic_rebalance, exit_symbols.len(), @@ -5248,6 +5500,7 @@ impl Strategy for PlatformExprStrategy { order_intents.len(), selection_limit, decision_date, + selection_market_date, selection_factor_date, execution_date, aiquant_total_value, @@ -5305,6 +5558,15 @@ fn rolling_zscore(values: &[f64]) -> f64 { } } +fn code_number_value(value: &str) -> i64 { + value + .chars() + .filter(|ch| ch.is_ascii_digit()) + .collect::() + .parse::() + .unwrap_or(0) +} + #[cfg(test)] mod tests { use std::collections::{BTreeMap, BTreeSet}; @@ -5337,6 +5599,58 @@ mod tests { assert!(!rewritten.contains('?')); } + #[test] + fn platform_expr_rewrites_prelude_assignment_ternary() { + let prelude = "let cap_low = sig_close <= 0 ? 7 : max(5, min(cap_low_raw, 70));"; + let rewritten = PlatformExprStrategy::normalize_prelude_for_eval(prelude); + assert!(rewritten.contains( + "let cap_low = (if sig_close <= 0 { 7 } else { max(5, min(cap_low_raw, 70)) });" + )); + assert!(!rewritten.contains('?')); + } + + #[test] + fn platform_expr_normalizes_signal_field_alias_in_prelude() { + let prelude = "let sig_close = signal.close;"; + let rewritten = PlatformExprStrategy::normalize_prelude_for_eval(prelude); + assert!(rewritten.contains("let sig_close = signal_close;")); + } + + #[test] + fn platform_expr_expands_day_factor_in_prelude() { + let prelude = concat!( + "let cap_low_raw = round((day_factor(\"signal.close\") - 2000) * 3 / 500 + 7);\n", + "let exposure = day_factor('benchmark.close') > 0 ? 1.0 : 0.5;" + ); + let rewritten = PlatformExprStrategy::normalize_prelude_for_eval(prelude); + + assert!(rewritten.contains("day_factors[\"signal_close\"]")); + assert!(rewritten.contains("day_factors[\"benchmark_close\"]")); + assert!(!rewritten.contains("day_factor(")); + assert!(!rewritten.contains('?')); + } + + #[test] + fn platform_expr_code_number_extracts_stock_digits() { + assert_eq!(super::code_number_value("002113.SZ"), 2113); + assert_eq!(super::code_number_value("688001.SH"), 688001); + assert_eq!(super::code_number_value("not-a-code"), 0); + } + + #[test] + fn platform_expr_supports_python_style_string_aliases() { + let strategy = PlatformExprStrategy::new(PlatformExprStrategyConfig::microcap_rotation()); + + assert!( + strategy + .engine + .eval::( + "startswith(\"002113.SZ\", \"002\") && endswith(\"002113.SZ\", \".SZ\")", + ) + .expect("string aliases should evaluate") + ); + } + #[test] fn platform_order_value_quantity_includes_buy_commission_budget() { let strategy = PlatformExprStrategy::new(PlatformExprStrategyConfig::microcap_rotation()); @@ -5904,6 +6218,371 @@ mod tests { assert!(!stock.touched_upper_limit); } + #[test] + fn platform_aiquant_stock_expr_uses_scheduled_tick_price_for_limit_check() { + let date = d(2024, 1, 30); + let symbol = "002087.SZ"; + let data = DataSet::from_components_with_actions_and_quotes( + vec![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(), + }], + vec![DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some("2024-01-30 10:18:00".to_string()), + day_open: 1.38, + open: 1.38, + high: 1.38, + low: 1.35, + close: 1.35, + last_price: 1.35, + bid1: 1.35, + ask1: 1.36, + prev_close: 1.42, + volume: 1_000_000, + tick_volume: 1_000, + bid1_volume: 7_000, + ask1_volume: 4_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 1.49, + lower_limit: 1.35, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 11.6, + free_float_cap_bn: 11.6, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: symbol.to_string(), + is_st: true, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }], + Vec::new(), + vec![IntradayExecutionQuote { + date, + symbol: symbol.to_string(), + timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"), + last_price: 1.35, + bid1: 1.35, + ask1: 1.36, + bid1_volume: 575, + ask1_volume: 2853, + volume_delta: 100, + amount_delta: 135.0, + trading_phase: Some("continuous".to_string()), + }], + ) + .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: Some(date.and_hms_opt(10, 18, 0).expect("valid timestamp")), + order_events: &[], + fills: &[], + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.aiquant_transaction_cost = true; + cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(9, 33, 0).unwrap()); + cfg.signal_symbol = symbol.to_string(); + cfg.stock_filter_expr = "!at_lower_limit".to_string(); + let strategy = PlatformExprStrategy::new(cfg); + let day = strategy.day_state(&ctx, date).expect("day state"); + let stock = strategy + .stock_state(&ctx, date, symbol) + .expect("stock state"); + + assert_eq!(stock.last, 1.36); + assert!( + strategy + .stock_passes_expr(&ctx, &day, &stock) + .expect("stock expr") + ); + } + + #[test] + fn platform_aiquant_intraday_selection_filters_limits_on_execution_date() { + let factor_date = d(2023, 11, 10); + let decision_date = d(2023, 11, 13); + let execution_date = d(2023, 11, 14); + let signal = "000001.SZ"; + let limit_symbol = "600462.SH"; + let fallback_symbol = "000002.SZ"; + let market = + |date: NaiveDate, symbol: &str, last_price: f64, upper_limit: f64, lower_limit: f64| { + DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some(format!("{} 09:33:00", date)), + day_open: last_price, + open: last_price, + high: last_price, + low: last_price, + close: last_price, + last_price, + bid1: last_price, + ask1: last_price, + prev_close: last_price, + volume: 1_000_000, + tick_volume: 1_000, + bid1_volume: 10_000, + ask1_volume: 10_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit, + lower_limit, + price_tick: 0.01, + } + }; + let data = DataSet::from_components_with_actions_and_quotes( + vec![ + Instrument { + symbol: signal.to_string(), + name: signal.to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }, + Instrument { + symbol: limit_symbol.to_string(), + name: limit_symbol.to_string(), + board: "SH".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }, + Instrument { + symbol: fallback_symbol.to_string(), + name: fallback_symbol.to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }, + ], + vec![ + market(factor_date, signal, 10.0, 11.0, 9.0), + market(decision_date, signal, 10.0, 11.0, 9.0), + market(execution_date, signal, 10.0, 11.0, 9.0), + market(decision_date, limit_symbol, 2.20, 2.42, 1.98), + market(decision_date, fallback_symbol, 5.00, 5.50, 4.50), + market(execution_date, limit_symbol, 2.33, 2.33, 2.11), + market(execution_date, fallback_symbol, 5.00, 5.50, 4.50), + ], + vec![ + DailyFactorSnapshot { + date: factor_date, + symbol: limit_symbol.to_string(), + market_cap_bn: 1.0, + free_float_cap_bn: 1.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }, + DailyFactorSnapshot { + date: factor_date, + symbol: fallback_symbol.to_string(), + market_cap_bn: 2.0, + free_float_cap_bn: 2.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }, + DailyFactorSnapshot { + date: execution_date, + symbol: limit_symbol.to_string(), + market_cap_bn: 1.0, + free_float_cap_bn: 1.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }, + DailyFactorSnapshot { + date: execution_date, + symbol: fallback_symbol.to_string(), + market_cap_bn: 2.0, + free_float_cap_bn: 2.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }, + ], + [decision_date, execution_date] + .into_iter() + .flat_map(|date| { + [limit_symbol, fallback_symbol] + .into_iter() + .map(move |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(), + [factor_date, decision_date, execution_date] + .into_iter() + .map(|date| BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }) + .collect(), + Vec::new(), + vec![ + IntradayExecutionQuote { + date: execution_date, + symbol: limit_symbol.to_string(), + timestamp: execution_date + .and_hms_opt(9, 33, 0) + .expect("valid timestamp"), + last_price: 2.33, + bid1: 2.33, + ask1: 2.33, + bid1_volume: 10_000, + ask1_volume: 10_000, + volume_delta: 100, + amount_delta: 233.0, + trading_phase: Some("continuous".to_string()), + }, + IntradayExecutionQuote { + date: execution_date, + symbol: fallback_symbol.to_string(), + timestamp: execution_date + .and_hms_opt(9, 33, 0) + .expect("valid timestamp"), + last_price: 5.00, + bid1: 5.00, + ask1: 5.00, + bid1_volume: 10_000, + ask1_volume: 10_000, + volume_delta: 100, + amount_delta: 500.0, + trading_phase: Some("continuous".to_string()), + }, + ], + ) + .expect("dataset"); + let portfolio = PortfolioState::new(500_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date, + decision_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: Some( + execution_date + .and_hms_opt(9, 33, 0) + .expect("valid timestamp"), + ), + order_events: &[], + fills: &[], + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.aiquant_transaction_cost = true; + cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(9, 33, 0).unwrap()); + cfg.signal_symbol = signal.to_string(); + cfg.max_positions = 1; + cfg.market_cap_lower_expr = "0".to_string(); + cfg.market_cap_upper_expr = "100".to_string(); + cfg.selection_limit_expr = "1".to_string(); + cfg.stock_filter_expr = + "last_price <= 0 || (!at_upper_limit && !at_lower_limit)".to_string(); + cfg.exposure_expr = "1.0".to_string(); + let mut strategy = PlatformExprStrategy::new(cfg); + + let decision = strategy.on_day(&ctx).expect("platform decision"); + + assert!( + decision + .diagnostics + .iter() + .any(|item| item.contains("selection_market_date=2023-11-14")), + "{:?}", + decision.diagnostics + ); + assert!( + decision + .diagnostics + .iter() + .any(|item| item == "selected_symbols=000002.SZ"), + "{:?}", + decision.diagnostics + ); + assert!( + decision + .diagnostics + .iter() + .any(|item| item == "600462.SH rejected by upper_limit"), + "{:?}", + decision.diagnostics + ); + assert!(matches!( + decision.order_intents.first(), + Some(OrderIntent::Value { symbol, .. }) if symbol == fallback_symbol + )); + } + #[test] fn platform_buy_rejection_allows_lower_limit_buy_when_ask_is_available() { let date = d(2025, 4, 7); @@ -6002,6 +6681,120 @@ mod tests { assert_eq!(rejection, None); } + #[test] + fn platform_aiquant_buy_rejection_uses_scheduled_price_not_open_limit() { + let date = d(2025, 4, 8); + let symbol = "002740.SZ"; + let data = DataSet::from_components( + vec![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(), + }], + vec![DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some("2025-04-08 09:33:00".to_string()), + day_open: 3.66, + open: 3.66, + high: 3.66, + low: 3.60, + close: 3.61, + last_price: 3.63, + bid1: 3.62, + ask1: 3.63, + prev_close: 3.49, + volume: 10_000_000, + tick_volume: 10_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 3.66, + lower_limit: 3.32, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 15.0, + free_float_cap_bn: 10.0, + pe_ttm: 8.0, + turnover_ratio: Some(2.0), + effective_turnover_ratio: Some(2.0), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: symbol.to_string(), + is_st: true, + is_new_listing: false, + is_paused: false, + allow_buy: false, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }], + 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 default_cfg = PlatformExprStrategyConfig::microcap_rotation(); + default_cfg.universe_exclude.clear(); + let default_strategy = PlatformExprStrategy::new(default_cfg); + let stock = default_strategy + .stock_state(&ctx, date, symbol) + .expect("stock state"); + assert!( + default_strategy + .buy_rejection_reason(&ctx, date, symbol, &stock) + .expect("default rejection") + .is_some() + ); + + let mut aiquant_cfg = PlatformExprStrategyConfig::microcap_rotation(); + aiquant_cfg.universe_exclude.clear(); + aiquant_cfg.aiquant_transaction_cost = true; + let aiquant_strategy = PlatformExprStrategy::new(aiquant_cfg); + let stock = aiquant_strategy + .stock_state(&ctx, date, symbol) + .expect("stock state"); + + let rejection = aiquant_strategy + .buy_rejection_reason(&ctx, date, symbol, &stock) + .expect("aiquant rejection"); + + assert_eq!(rejection, None); + } + fn sample_calendar() -> TradingCalendar { TradingCalendar::new(vec![ d(2025, 1, 30), @@ -7653,6 +8446,171 @@ mod tests { ); } + #[test] + fn platform_aiquant_profile_uses_calendar_day_rebalance_interval() { + let dates = [d(2023, 1, 12), d(2023, 1, 13)]; + let symbols = ["000001.SZ", "000002.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(), + dates + .iter() + .flat_map(|date| { + symbols.iter().map(move |symbol| DailyMarketSnapshot { + date: *date, + symbol: (*symbol).to_string(), + timestamp: Some(format!("{date} 09:33:00")), + day_open: 10.0, + open: 10.0, + high: 10.5, + low: 9.8, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + prev_close: 9.9, + 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(), + dates + .iter() + .flat_map(|date| { + symbols + .iter() + .enumerate() + .map(move |(index, symbol)| DailyFactorSnapshot { + date: *date, + symbol: (*symbol).to_string(), + market_cap_bn: 10.0 + index as f64, + free_float_cap_bn: 10.0 + index as f64, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }) + }) + .collect(), + dates + .iter() + .flat_map(|date| { + symbols.iter().map(move |symbol| CandidateEligibility { + date: *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(), + dates + .iter() + .map(|date| BenchmarkSnapshot { + date: *date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }) + .collect(), + ) + .expect("dataset"); + + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.signal_symbol = "000001.SZ".to_string(); + cfg.refresh_rate = 10; + 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.calendar_rebalance_interval = true; + let mut strategy = PlatformExprStrategy::new(cfg); + strategy.last_rebalance_date = Some(d(2023, 1, 3)); + let subscriptions = BTreeSet::new(); + let mut portfolio = PortfolioState::new(30_000.0); + portfolio + .position_mut("000001.SZ") + .buy(d(2023, 1, 3), 100, 10.0); + + let before_ctx = StrategyContext { + execution_date: dates[0], + decision_date: dates[0], + decision_index: 8, + 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 before = strategy.on_day(&before_ctx).expect("before decision"); + assert!( + before + .diagnostics + .iter() + .any(|item| item.contains("periodic_rebalance=false")), + "{:?}", + before.diagnostics + ); + + let due_ctx = StrategyContext { + execution_date: dates[1], + decision_date: dates[1], + decision_index: 9, + 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 due = strategy.on_day(&due_ctx).expect("due decision"); + assert!( + due.diagnostics + .iter() + .any(|item| item.contains("periodic_rebalance=true")), + "{:?}", + due.diagnostics + ); + assert_eq!(strategy.last_rebalance_date, Some(d(2023, 1, 13))); + } + #[test] fn platform_daily_top_up_does_not_use_same_day_sell_cash() { let prev_date = d(2025, 2, 25); @@ -8294,6 +9252,130 @@ mod tests { ); } + #[test] + fn platform_aiquant_projection_reserves_sell_cost_without_same_callback_proceeds() { + let prev_date = d(2025, 5, 13); + let date = d(2025, 5, 14); + let symbol = "000003.SZ"; + let data = DataSet::from_components_with_actions_and_quotes( + vec![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(), + }], + vec![DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some("2025-05-14 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: 9.9, + 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, + }], + vec![DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 10.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(), + }], + vec![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, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }], + Vec::new(), + vec![IntradayExecutionQuote { + date, + symbol: symbol.to_string(), + timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"), + last_price: 10.0, + bid1: 10.0, + ask1: 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()), + }], + ) + .expect("dataset"); + + let mut portfolio = PortfolioState::new(100.0); + portfolio.position_mut(symbol).buy(prev_date, 100, 10.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 20, + 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; + let strategy = PlatformExprStrategy::new(cfg); + let mut projected = portfolio.clone(); + let mut execution_state = super::ProjectedExecutionState::default(); + + let filled = + strategy.project_target_zero(&ctx, &mut projected, date, symbol, &mut execution_state); + + assert_eq!(filled, Some(100)); + assert!( + (projected.cash() - 94.5).abs() < 1e-6, + "{}", + projected.cash() + ); + assert_eq!( + projected.position(symbol).map(|position| position.quantity), + None + ); + } + #[test] fn platform_projection_does_not_consume_cash_from_unfillable_sell() { let prev_date = d(2025, 3, 18); diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 0927c1e..a5a489e 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -52,6 +52,8 @@ pub struct StrategyUniverseSpec { #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StrategyExecutionSpec { + #[serde(default)] + pub compatibility_profile: Option, #[serde(default)] pub matching_type: Option, #[serde(default)] @@ -370,6 +372,13 @@ pub fn platform_expr_config_from_spec( { cfg.rebalance_schedule = Some(schedule); } + if let Some(time) = engine + .rebalance_schedule + .as_ref() + .and_then(parse_schedule_execution_time) + { + cfg.intraday_execution_time = Some(time); + } if let Some(stock_ma_filter) = engine.stock_ma_filter.as_ref() { if let Some(days) = stock_ma_filter.short_days.filter(|value| *value > 0) { cfg.stock_short_ma_days = days; @@ -499,6 +508,13 @@ pub fn platform_expr_config_from_spec( { cfg.rebalance_schedule = Some(schedule); } + if let Some(time) = runtime_expr + .schedule + .as_ref() + .and_then(parse_schedule_execution_time) + { + cfg.intraday_execution_time = Some(time); + } if let Some(selection) = runtime_expr.selection.as_ref() { if let Some(expr) = selection .limit_expr @@ -628,6 +644,13 @@ pub fn platform_expr_config_from_spec( { cfg.explicit_action_schedule = Some(schedule); } + if let Some(time) = trading + .schedule + .as_ref() + .and_then(parse_schedule_execution_time) + { + cfg.intraday_execution_time = Some(time); + } cfg.explicit_actions = trading .actions .iter() @@ -688,6 +711,16 @@ pub fn platform_expr_config_from_spec( if !cfg.benchmark_symbol.trim().is_empty() { cfg.benchmark_symbol = normalize_symbol(&cfg.benchmark_symbol, None); } + if spec + .execution + .as_ref() + .and_then(|execution| execution.compatibility_profile.as_deref()) + .map(|value| value.trim().to_ascii_lowercase()) + .is_some_and(|value| value == "aiquant_rqalpha" || value == "aiquant") + { + cfg.calendar_rebalance_interval = true; + cfg.aiquant_transaction_cost = true; + } cfg } @@ -744,6 +777,16 @@ fn parse_schedule_time_rule( } } +fn parse_schedule_execution_time(schedule: &StrategyExpressionScheduleConfig) -> Option { + match parse_schedule_time_rule(schedule)? { + ScheduleTimeRule::BeforeTrading => NaiveTime::from_hms_opt(9, 0, 0), + ScheduleTimeRule::MinuteOfDay(minutes) => { + let seconds = minutes.checked_mul(60)?; + NaiveTime::from_num_seconds_from_midnight_opt(seconds, 0) + } + } +} + fn parse_schedule_clock_time(raw: Option<&str>) -> Option { let value = raw?.trim(); if value.is_empty() { @@ -1060,6 +1103,7 @@ mod tests { "signalSymbol": "000852.SH", "benchmark": { "instrumentId": "000852.SH" }, "universe": { "exclude": ["paused", "st", "kcb", "one_yuan"] }, + "execution": { "compatibilityProfile": "aiquant_rqalpha" }, "runtimeExpressions": { "prelude": "let stocknum = 8;", "selection": { @@ -1094,10 +1138,32 @@ mod tests { assert!(!cfg.rotation_enabled); assert!(cfg.daily_top_up_enabled); assert!(cfg.retry_empty_rebalance); + assert!(cfg.calendar_rebalance_interval); + assert!(cfg.aiquant_transaction_cost); assert_eq!(cfg.explicit_actions.len(), 1); assert_eq!( cfg.explicit_action_stage, PlatformExplicitActionStage::OpenAuction ); } + + #[test] + fn parses_daily_schedule_time_for_aiquant_execution_quotes() { + let spec = serde_json::json!({ + "execution": { "compatibilityProfile": "aiquant_rqalpha" }, + "runtimeExpressions": { + "schedule": { "frequency": "daily", "time": "09:33" } + } + }); + + let cfg = platform_expr_config_from_value("", "", &spec).expect("config"); + + assert_eq!(cfg.rebalance_schedule, None); + assert_eq!( + cfg.intraday_execution_time, + Some(NaiveTime::from_hms_opt(9, 33, 0).unwrap()) + ); + assert!(cfg.calendar_rebalance_interval); + assert!(cfg.aiquant_transaction_cost); + } } diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index e2b89db..ff8847a 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -546,8 +546,8 @@ pub fn build_optimization_prompt( prompt.push_str("你是 OmniQuant 平台策略脚本优化器。必须输出完整、可运行的平台策略脚本,不要输出解释文本。\n"); prompt.push_str("输出格式硬约束:回复第一行必须是 strategy(\"...\")、let、fn、const 或 //;回复中不得包含 Markdown、解释、思考过程、手册复述、JSON 包装或自然语言总结。\n"); prompt.push_str("长度硬约束:策略代码目标 80 行以内,只保留必要 let/fn/strategy 块;不要复制下面的手册片段、历史策略全文或字段清单。\n"); - prompt.push_str("只修改与优化目标相关的少量参数或过滤条件,保留原策略的市场、基准、信号指数和核心风控;不要引入手册未列出的字段或外部平台 API 名称。\n"); - prompt.push_str("优化可以调整调仓周期、持仓数、市值带、filter.stock_expr、ordering.rank_expr、allocation.buy_scale、止盈止损;如上一轮无交易或质量分过低,必须先放宽过滤条件并优先使用已入库指标因子、rolling_mean/ma/vma/rolling_stddev/pct_change 等支持函数。\n"); + prompt.push_str("优化不限制在原策略已有参数或少量扰动。只要 OmniQuant/FIDC 已支持,可以自由增加、修改、删除策略代码、参数、候选池、过滤函数、排序、仓位、止盈止损、调仓周期、指标因子和辅助函数;不得引入手册未列出的字段或外部平台 API 名称。\n"); + prompt.push_str("可以使用所有已入库日频字段、指标因子和表达式函数,例如 rolling_mean/ma/vma/rolling_sum/rolling_stddev/pct_change/factor/factor_value/factors;如上一轮无交易或质量分过低,必须先扩大候选覆盖并修正不可交易过滤,再优化收益。\n"); prompt.push_str("优化目标:\n"); prompt.push_str(&format!("- {}\n\n", request.objective)); prompt.push_str("当前策略代码如下,仅作为输入参考;回复时不要包含 Markdown 代码围栏:\n"); diff --git a/crates/fidc-core/tests/delisting.rs b/crates/fidc-core/tests/delisting.rs index 314512b..4c79830 100644 --- a/crates/fidc-core/tests/delisting.rs +++ b/crates/fidc-core/tests/delisting.rs @@ -492,7 +492,7 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() { .iter() .find(|holding| holding.symbol == "000002.SZ") .expect("successor holding exists"); - assert_eq!(successor_holding.quantity, 500); + assert_eq!(successor_holding.quantity, 450); assert!( result .holdings_summary @@ -503,6 +503,6 @@ fn engine_applies_successor_conversion_before_delisted_cash_settlement() { event .note .contains("successor_conversion 000001.SZ->000002.SZ") - && event.note.contains("cash=1000.00") + && event.note.contains("cash=900.00") })); }