diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index ec8b3f2..865ac02 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -208,8 +208,13 @@ pub struct PlatformExprStrategyConfig { pub retry_empty_rebalance: bool, pub calendar_rebalance_interval: bool, pub aiquant_transaction_cost: bool, + pub commission_rate: Option, + pub minimum_commission: Option, + pub stamp_tax_rate_before_change: Option, + pub stamp_tax_rate_after_change: Option, pub strict_value_budget: bool, pub quote_quantity_limit: bool, + pub current_day_precomputed_factors: bool, pub intraday_execution_time: Option, pub explicit_action_stage: PlatformExplicitActionStage, pub explicit_action_schedule: Option, @@ -266,8 +271,13 @@ fn band_low(index_close) { retry_empty_rebalance: false, calendar_rebalance_interval: false, aiquant_transaction_cost: false, + commission_rate: None, + minimum_commission: None, + stamp_tax_rate_before_change: None, + stamp_tax_rate_after_change: None, strict_value_budget: false, quote_quantity_limit: true, + current_day_precomputed_factors: false, intraday_execution_time: None, explicit_action_stage: PlatformExplicitActionStage::OnDay, explicit_action_schedule: None, @@ -879,11 +889,24 @@ impl PlatformExprStrategy { } fn cost_model(&self) -> ChinaAShareCostModel { - if self.config.aiquant_transaction_cost { + let mut model = if self.config.aiquant_transaction_cost { ChinaAShareCostModel::aiquant_rqalpha_default() } else { ChinaAShareCostModel::default() + }; + if let Some(value) = self.config.commission_rate { + model.commission_rate = value; } + if let Some(value) = self.config.minimum_commission { + model.minimum_commission = value; + } + if let Some(value) = self.config.stamp_tax_rate_before_change { + model.stamp_tax_rate_before_change = value; + } + if let Some(value) = self.config.stamp_tax_rate_after_change { + model.stamp_tax_rate_after_change = value; + } + model } fn marked_total_value(&self, ctx: &StrategyContext<'_>, date: NaiveDate) -> f64 { @@ -4007,14 +4030,16 @@ impl PlatformExprStrategy { Ok(value.round().max(1.0) as usize) } - fn selection_dates(&self, ctx: &StrategyContext<'_>) -> (NaiveDate, NaiveDate) { + fn selection_dates(&self, ctx: &StrategyContext<'_>) -> (NaiveDate, NaiveDate, NaiveDate) { let decision_date = ctx.decision_date; - let factor_date = if self.config.aiquant_transaction_cost { + let previous_factor_date = ctx + .data + .previous_trading_date(decision_date, 1) + .unwrap_or(decision_date); + let stock_factor_date = if self.config.current_day_precomputed_factors { decision_date } else { - ctx.data - .previous_trading_date(decision_date, 1) - .unwrap_or(decision_date) + previous_factor_date }; let selection_date = if self.config.aiquant_transaction_cost && self.config.intraday_execution_time.is_some() @@ -4023,7 +4048,7 @@ impl PlatformExprStrategy { } else { decision_date }; - (selection_date, factor_date) + (selection_date, previous_factor_date, stock_factor_date) } fn buy_scale( @@ -5138,6 +5163,13 @@ impl PlatformExprStrategy { candidate: &EligibleUniverseSnapshot, 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; + } + _ => {} + } self.stock_numeric_field_value(candidate, stock, self.config.market_cap_field.as_str()) .unwrap_or_else(|| self.field_value(candidate)) } @@ -5236,18 +5268,19 @@ impl PlatformExprStrategy { &self, ctx: &StrategyContext<'_>, date: NaiveDate, - factor_date: NaiveDate, + universe_factor_date: NaiveDate, + stock_factor_date: NaiveDate, day: &DayExpressionState, band_low: f64, band_high: f64, limit: usize, ) -> Result<(Vec, Vec), BacktestError> { - let universe = self.selectable_universe_on(ctx, date, factor_date); + let universe = self.selectable_universe_on(ctx, date, universe_factor_date); let mut diagnostics = Vec::new(); let mut candidates = Vec::new(); for candidate in universe { let stock = - self.stock_state_with_factor_date(ctx, date, factor_date, &candidate.symbol)?; + self.stock_state_with_factor_date(ctx, date, stock_factor_date, &candidate.symbol)?; let field_value = self.selection_field_value(&candidate, &stock); if !field_value.is_finite() { if diagnostics.len() < 12 { @@ -5341,19 +5374,20 @@ impl PlatformExprStrategy { &self, ctx: &StrategyContext<'_>, date: NaiveDate, - factor_date: NaiveDate, + universe_factor_date: NaiveDate, + stock_factor_date: NaiveDate, day: &DayExpressionState, band_low: f64, band_high: f64, selection_limit: usize, ) -> Result<(Vec, Vec, Vec), BacktestError> { - let universe = self.selectable_universe_on(ctx, date, factor_date); + let universe = self.selectable_universe_on(ctx, date, universe_factor_date); let mut diagnostics = Vec::new(); let mut candidates = Vec::new(); let apply_stock_filter = !self.stock_filter_uses_intraday_quote_fields(); for candidate in universe { let stock = - self.stock_state_with_factor_date(ctx, date, factor_date, &candidate.symbol)?; + self.stock_state_with_factor_date(ctx, date, stock_factor_date, &candidate.symbol)?; let field_value = self.selection_field_value(&candidate, &stock); if !field_value.is_finite() { if diagnostics.len() < 12 { @@ -5406,7 +5440,8 @@ impl PlatformExprStrategy { processed_symbols.push(symbol.clone()); } for (symbol, _) in &candidates[cursor..end] { - let stock = self.stock_state_with_factor_date(ctx, date, factor_date, symbol)?; + let stock = + self.stock_state_with_factor_date(ctx, date, stock_factor_date, symbol)?; if let Some(reason) = self.buy_rejection_reason(ctx, date, symbol, &stock)? { if diagnostics.len() < 12 { diagnostics.push(format!("{symbol} quote_plan rejected by {reason}")); @@ -5453,7 +5488,7 @@ impl PlatformExprStrategy { } let day = self.day_state(ctx, ctx.decision_date)?; - let (selection_date, factor_date) = self.selection_dates(ctx); + let (selection_date, universe_factor_date, stock_factor_date) = self.selection_dates(ctx); let requires_intraday_selection_quotes = self.stock_filter_uses_intraday_quote_fields(); let (band_low, band_high) = self.market_cap_band(ctx, &day)?; let selection_limit = self @@ -5462,7 +5497,8 @@ impl PlatformExprStrategy { let (candidate_symbols, order_symbols, diagnostics) = self.select_quote_plan_symbols( ctx, selection_date, - factor_date, + universe_factor_date, + stock_factor_date, &day, band_low, band_high, @@ -5472,7 +5508,7 @@ impl PlatformExprStrategy { execution_date: ctx.execution_date, decision_date: ctx.decision_date, selection_date, - factor_date, + factor_date: stock_factor_date, requires_intraday_selection_quotes, band_low, band_high, @@ -5658,7 +5694,8 @@ impl Strategy for PlatformExprStrategy { } let day = self.day_state(ctx, decision_date)?; - let (selection_market_date, selection_factor_date) = self.selection_dates(ctx); + let (selection_market_date, selection_universe_factor_date, selection_factor_date) = + self.selection_dates(ctx); let (explicit_action_intents, explicit_action_diagnostics) = if self.config.explicit_action_stage == PlatformExplicitActionStage::OnDay && self.explicit_actions_active(ctx.data.calendar(), execution_date) @@ -5696,6 +5733,7 @@ impl Strategy for PlatformExprStrategy { let (stock_list, notes) = self.select_symbols( ctx, selection_market_date, + selection_universe_factor_date, selection_factor_date, &day, band_low, @@ -6207,7 +6245,7 @@ impl Strategy for PlatformExprStrategy { ) }, format!( - "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}", + "selected={} periodic_rebalance={} exits={} projected_positions={} intents={} limit={} decision_date={} selection_market_date={} selection_universe_factor_date={} selection_factor_date={} execution_date={} budget_total={:.2} marked_total={:.2} day_total={:.2}", stock_list.len(), periodic_rebalance, exit_symbols.len(), @@ -6216,6 +6254,7 @@ impl Strategy for PlatformExprStrategy { selection_limit, decision_date, selection_market_date, + selection_universe_factor_date, selection_factor_date, execution_date, aiquant_total_value, @@ -6331,6 +6370,18 @@ mod tests { )); } + #[test] + fn platform_expr_cost_model_uses_commission_override() { + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.aiquant_transaction_cost = true; + cfg.commission_rate = Some(0.0003); + cfg.minimum_commission = Some(5.0); + let strategy = PlatformExprStrategy::new(cfg); + + assert!((strategy.buy_commission(100_000.0) - 30.0).abs() < 1e-9); + assert!((strategy.buy_commission(1_000.0) - 5.0).abs() < 1e-9); + } + #[test] fn platform_expr_rewrites_prelude_assignment_ternary() { let prelude = "let cap_low = sig_close <= 0 ? 7 : max(5, min(cap_low_raw, 70));"; @@ -7404,7 +7455,7 @@ mod tests { let day = strategy.day_state(&ctx, date).expect("day state"); let (selected, _) = strategy - .select_symbols(&ctx, date, date, &day, 10.0, 20.0, 1) + .select_symbols(&ctx, date, date, date, &day, 10.0, 20.0, 1) .expect("selection"); assert!(selected.is_empty()); } @@ -7908,6 +7959,14 @@ mod tests { "{:?}", decision.diagnostics ); + assert!( + decision + .diagnostics + .iter() + .any(|item| item.contains("selection_factor_date=2023-11-10")), + "{:?}", + decision.diagnostics + ); assert!( decision .diagnostics diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index a51be9d..cbbda04 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -67,6 +67,14 @@ pub struct StrategyExecutionSpec { #[serde(default)] pub slippage_max_value: Option, #[serde(default)] + pub commission_rate: Option, + #[serde(default)] + pub minimum_commission: Option, + #[serde(default)] + pub stamp_tax_rate_before_change: Option, + #[serde(default)] + pub stamp_tax_rate_after_change: Option, + #[serde(default)] pub strict_value_budget: Option, } @@ -108,6 +116,14 @@ pub struct StrategyEngineConfig { #[serde(default)] pub slippage_max_value: Option, #[serde(default)] + pub commission_rate: Option, + #[serde(default)] + pub minimum_commission: Option, + #[serde(default)] + pub stamp_tax_rate_before_change: Option, + #[serde(default)] + pub stamp_tax_rate_after_change: Option, + #[serde(default)] pub strict_value_budget: Option, #[serde(default)] pub dividend_reinvestment: Option, @@ -343,6 +359,31 @@ pub fn platform_expr_config_from_value( )) } +fn valid_non_negative(value: Option) -> Option { + value.filter(|item| item.is_finite() && *item >= 0.0) +} + +fn apply_cost_overrides( + cfg: &mut PlatformExprStrategyConfig, + commission_rate: Option, + minimum_commission: Option, + stamp_tax_rate_before_change: Option, + stamp_tax_rate_after_change: Option, +) { + if let Some(value) = valid_non_negative(commission_rate) { + cfg.commission_rate = Some(value); + } + if let Some(value) = valid_non_negative(minimum_commission) { + cfg.minimum_commission = Some(value); + } + if let Some(value) = valid_non_negative(stamp_tax_rate_before_change) { + cfg.stamp_tax_rate_before_change = Some(value); + } + if let Some(value) = valid_non_negative(stamp_tax_rate_after_change) { + cfg.stamp_tax_rate_after_change = Some(value); + } +} + pub fn platform_expr_config_from_spec( strategy_id: &str, signal_symbol: &str, @@ -440,6 +481,13 @@ pub fn platform_expr_config_from_spec( { cfg.benchmark_symbol = spec_benchmark_symbol.clone(); } + apply_cost_overrides( + &mut cfg, + engine.commission_rate, + engine.minimum_commission, + engine.stamp_tax_rate_before_change, + engine.stamp_tax_rate_after_change, + ); } if let Some(spec_signal_symbol) = spec @@ -741,6 +789,15 @@ pub fn platform_expr_config_from_spec( { cfg.aiquant_transaction_cost = true; } + if let Some(execution) = spec.execution.as_ref() { + apply_cost_overrides( + &mut cfg, + execution.commission_rate, + execution.minimum_commission, + execution.stamp_tax_rate_before_change, + execution.stamp_tax_rate_after_change, + ); + } cfg } @@ -1169,6 +1226,30 @@ mod tests { ); } + #[test] + fn parses_execution_cost_overrides_into_platform_config() { + let spec = serde_json::json!({ + "execution": { + "compatibilityProfile": "aiquant_rqalpha", + "commissionRate": 0.0003, + "minimumCommission": 5.0, + "stampTaxRateBeforeChange": 0.0005, + "stampTaxRateAfterChange": 0.0005 + }, + "engineConfig": { + "commissionRate": 0.0008 + } + }); + + let cfg = platform_expr_config_from_value("", "", &spec).expect("config"); + + assert!(cfg.aiquant_transaction_cost); + assert_eq!(cfg.commission_rate, Some(0.0003)); + assert_eq!(cfg.minimum_commission, Some(5.0)); + assert_eq!(cfg.stamp_tax_rate_before_change, Some(0.0005)); + assert_eq!(cfg.stamp_tax_rate_after_change, Some(0.0005)); + } + #[test] fn parses_daily_schedule_time_for_aiquant_execution_quotes() { let spec = serde_json::json!({