diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index fe2d61b..ddc1202 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -13831,10 +13831,10 @@ mod tests { ) .expect("dataset"); - let mut portfolio = PortfolioState::new(100.0); + let mut portfolio = PortfolioState::new(1_000.0); portfolio .position_mut("000003.SZ") - .buy(prev_date, 100, 10.0); + .buy(prev_date, 1_000, 10.0); let subscriptions = BTreeSet::new(); let ctx = StrategyContext { execution_date: date, @@ -13865,6 +13865,7 @@ mod tests { cfg.take_profit_expr.clear(); cfg.stop_loss_expr.clear(); cfg.daily_top_up_enabled = true; + cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(9, 33, 0).unwrap()); let mut strategy = PlatformExprStrategy::new(cfg); strategy.rebalance_day_counter = 20; @@ -14037,6 +14038,7 @@ mod tests { cfg.take_profit_expr.clear(); cfg.stop_loss_expr.clear(); cfg.aiquant_transaction_cost = true; + cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(14, 59, 0).unwrap()); let mut strategy = PlatformExprStrategy::new(cfg); strategy.rebalance_day_counter = 20; @@ -14045,13 +14047,13 @@ mod tests { assert!( decision.order_intents.iter().any(|intent| matches!( intent, - OrderIntent::Value { + OrderIntent::Shares { symbol, - value, + quantity, reason, } if symbol == "000002.SZ" && reason == "periodic_rebalance_buy" - && (*value - 10_000.0).abs() < 1e-6 + && *quantity == 900 )), "{:?}", decision.order_intents diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index b9fe4ff..d0026ad 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -5,9 +5,10 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::{ - PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind, - PlatformExplicitOrderKind, PlatformExprStrategyConfig, PlatformRebalanceSchedule, - PlatformScheduleFrequency, PlatformTradeAction, PlatformUniverseActionKind, ScheduleTimeRule, + DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage, + PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategyConfig, + PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction, + PlatformUniverseActionKind, ScheduleTimeRule, SlippageModel, }; #[derive(Debug, Clone, Default, Deserialize, Serialize)] @@ -176,6 +177,10 @@ pub struct MovingAverageFilterConfig { #[serde(default)] pub long_days: Option, #[serde(default)] + pub volume_short_days: Option, + #[serde(default)] + pub volume_long_days: Option, + #[serde(default)] pub rsi_rate: Option, } @@ -401,6 +406,97 @@ fn apply_cost_overrides( } } +fn normalize_model_name(value: &str) -> String { + value.trim().to_ascii_lowercase().replace('-', "_") +} + +fn parse_matching_type(value: Option<&str>) -> Option { + match normalize_model_name(value?).as_str() { + "open_auction" => Some(MatchingType::OpenAuction), + "current_bar_close" => Some(MatchingType::CurrentBarClose), + "next_bar_open" => Some(MatchingType::NextBarOpen), + "next_tick_last" => Some(MatchingType::NextTickLast), + "next_tick_best_own" => Some(MatchingType::NextTickBestOwn), + "next_tick_best_counterparty" => Some(MatchingType::NextTickBestCounterparty), + "counterparty_offer" => Some(MatchingType::CounterpartyOffer), + "vwap" => Some(MatchingType::Vwap), + "twap" => Some(MatchingType::Twap), + _ => None, + } +} + +fn parse_slippage_model( + model: Option<&str>, + value: Option, + impact_coefficient: Option, + volatility_coefficient: Option, + max_value: Option, +) -> Option { + let value = valid_non_negative(value); + let impact_coefficient = valid_non_negative(impact_coefficient); + let volatility_coefficient = valid_non_negative(volatility_coefficient); + let max_value = valid_non_negative(max_value); + let model = model + .map(normalize_model_name) + .filter(|item| !item.is_empty()) + .unwrap_or_else(|| { + if value.is_some_and(|item| item > 0.0) { + "price_ratio".to_string() + } else { + "none".to_string() + } + }); + + match model.as_str() { + "none" => Some(SlippageModel::None), + "price_ratio" => Some(SlippageModel::PriceRatio(value.unwrap_or(0.0))), + "tick_size" => Some(SlippageModel::TickSize(value.unwrap_or(0.0))), + "limit_price" => Some(SlippageModel::LimitPrice), + "dynamic" | "dynamic_volume_volatility" => { + Some(SlippageModel::Dynamic(DynamicSlippageConfig::new( + impact_coefficient.unwrap_or(0.5), + volatility_coefficient.unwrap_or(0.3), + max_value.or(value).unwrap_or(0.01), + ))) + } + _ => None, + } +} + +fn apply_execution_behavior_overrides( + cfg: &mut PlatformExprStrategyConfig, + matching_type: Option<&str>, + slippage_model: Option<&str>, + slippage_value: Option, + slippage_impact_coefficient: Option, + slippage_volatility_coefficient: Option, + slippage_max_value: Option, + strict_value_budget: Option, +) { + if let Some(matching_type) = parse_matching_type(matching_type) { + cfg.matching_type = matching_type; + } + if slippage_model.is_some() + || slippage_value.is_some() + || slippage_impact_coefficient.is_some() + || slippage_volatility_coefficient.is_some() + || slippage_max_value.is_some() + { + if let Some(parsed) = parse_slippage_model( + slippage_model, + slippage_value, + slippage_impact_coefficient, + slippage_volatility_coefficient, + slippage_max_value, + ) { + cfg.slippage_model = parsed; + } + } + if let Some(enabled) = strict_value_budget { + cfg.strict_value_budget = enabled; + } +} + fn parse_usize_after(text: &str, start: usize) -> Option<(usize, usize)> { let bytes = text.as_bytes(); let mut end = start; @@ -624,6 +720,16 @@ pub fn platform_expr_config_from_spec( engine.stamp_tax_rate_before_change, engine.stamp_tax_rate_after_change, ); + apply_execution_behavior_overrides( + &mut cfg, + engine.matching_type.as_deref(), + engine.slippage_model.as_deref(), + engine.slippage_value, + engine.slippage_impact_coefficient, + engine.slippage_volatility_coefficient, + engine.slippage_max_value, + engine.strict_value_budget, + ); } if let Some(spec_signal_symbol) = spec @@ -991,6 +1097,16 @@ pub fn platform_expr_config_from_spec( execution.stamp_tax_rate_before_change, execution.stamp_tax_rate_after_change, ); + apply_execution_behavior_overrides( + &mut cfg, + execution.matching_type.as_deref(), + execution.slippage_model.as_deref(), + execution.slippage_value, + execution.slippage_impact_coefficient, + execution.slippage_volatility_coefficient, + execution.slippage_max_value, + execution.strict_value_budget, + ); } if cfg.aiquant_transaction_cost && cfg @@ -1469,6 +1585,50 @@ mod tests { assert_eq!(cfg.stamp_tax_rate_after_change, Some(0.0005)); } + #[test] + fn parses_execution_slippage_overrides_into_platform_config() { + let spec = serde_json::json!({ + "execution": { + "compatibilityProfile": "aiquant_rqalpha", + "matchingType": "next_tick_last", + "slippageModel": "price_ratio", + "slippageValue": 0.001, + "strictValueBudget": true + }, + "engineConfig": { + "matchingType": "current_bar_close", + "slippageModel": "none", + "slippageValue": 0.0, + "strictValueBudget": false + } + }); + + let cfg = platform_expr_config_from_value("", "", &spec).expect("config"); + + assert_eq!(cfg.matching_type, MatchingType::NextTickLast); + assert_eq!(cfg.slippage_model, SlippageModel::PriceRatio(0.001)); + assert!(cfg.strict_value_budget); + } + + #[test] + fn parses_dynamic_slippage_into_platform_config() { + let spec = serde_json::json!({ + "execution": { + "slippageModel": "dynamic", + "slippageImpactCoefficient": 0.6, + "slippageVolatilityCoefficient": 0.2, + "slippageMaxValue": 0.015 + } + }); + + let cfg = platform_expr_config_from_value("", "", &spec).expect("config"); + + assert_eq!( + cfg.slippage_model, + SlippageModel::Dynamic(DynamicSlippageConfig::new(0.6, 0.2, 0.015)) + ); + } + #[test] fn aiquant_profile_defaults_to_daily_top_up_and_empty_retry() { let spec = serde_json::json!({ diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 7ff6bf0..23b3e6c 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -1539,6 +1539,8 @@ pub struct OmniMicroCapConfig { pub stock_short_ma_days: usize, pub stock_mid_ma_days: usize, pub stock_long_ma_days: usize, + pub stock_volume_short_ma_days: usize, + pub stock_volume_long_ma_days: usize, pub rsi_rate: f64, pub trade_rate: f64, pub stop_loss_ratio: f64, @@ -1565,6 +1567,8 @@ impl OmniMicroCapConfig { stock_short_ma_days: 5, stock_mid_ma_days: 10, stock_long_ma_days: 20, + stock_volume_short_ma_days: 5, + stock_volume_long_ma_days: 60, rsi_rate: 1.0001, trade_rate: 0.5, stop_loss_ratio: 0.93, @@ -1593,6 +1597,8 @@ impl OmniMicroCapConfig { stock_short_ma_days: 5, stock_mid_ma_days: 10, stock_long_ma_days: 30, + stock_volume_short_ma_days: 5, + stock_volume_long_ma_days: 60, rsi_rate: 1.0001, trade_rate: 0.5, stop_loss_ratio: 0.92, @@ -2271,62 +2277,33 @@ impl OmniMicroCapStrategy { return false; }; - // MA filter: ma_short > ma_mid * rsi_rate && ma_mid * rsi_rate > ma_long let ma_pass = ma_short > ma_mid * self.config.rsi_rate && ma_mid * self.config.rsi_rate > ma_long; - // Debug logging for ALL stocks on first decision date - static DEBUG_DATE: std::sync::Mutex> = std::sync::Mutex::new(None); - let mut debug_date = DEBUG_DATE.lock().unwrap(); - let should_debug = if let Some(d) = *debug_date { - d == date - } else { - *debug_date = Some(date); - true - }; - - if should_debug { - eprintln!( - "[MA_FILTER] {} cap={:.2} ma5={:.4} ma10={:.4} ma30={:.4} ma10*rsi={:.4} pass={} ({}>{:.4}? {} && {:.4}>{}? {})", - symbol, - ctx.data.market_decision_close(date, symbol).unwrap_or(0.0), - ma_short, - ma_mid, - ma_long, - ma_mid * self.config.rsi_rate, - ma_pass, - ma_short, - ma_mid * self.config.rsi_rate, - ma_short > ma_mid * self.config.rsi_rate, - ma_mid * self.config.rsi_rate, - ma_long, - ma_mid * self.config.rsi_rate > ma_long - ); - } - if !ma_pass { return false; } - // Volume filter: V5 < V60 (applied for omni_microcap strategies) if self.config.strategy_name.contains("aiquant") || self.config.strategy_name.contains("AiQuant") || self.config.strategy_name.contains("omni") { - let Some(volume_ma5) = ctx - .data - .market_decision_volume_moving_average(date, symbol, 5) - else { + let Some(volume_ma5) = ctx.data.market_decision_volume_moving_average( + date, + symbol, + self.config.stock_volume_short_ma_days, + ) else { return false; }; - let Some(volume_ma60) = ctx - .data - .market_decision_volume_moving_average(date, symbol, 60) - else { + let Some(volume_ma_long) = ctx.data.market_decision_volume_moving_average( + date, + symbol, + self.config.stock_volume_long_ma_days, + ) else { return false; }; - if volume_ma5 >= volume_ma60 { + if volume_ma5 >= volume_ma_long { return false; } } @@ -2519,18 +2496,6 @@ fn omni_truth_stock_list_candidates() -> Vec { } } } - - let suffix = PathBuf::from("data/demo/engine_truth_stock_list.csv"); - let manifest_root = Path::new(env!("CARGO_MANIFEST_DIR")); - push_unique_truth_path( - &mut candidates, - manifest_root.join("../../../").join(&suffix), - ); - if let Ok(current_dir) = env::current_dir() { - for ancestor in current_dir.ancestors() { - push_unique_truth_path(&mut candidates, ancestor.join(&suffix)); - } - } candidates } @@ -2699,10 +2664,6 @@ impl Strategy for OmniMicroCapStrategy { }; // 使用前一交易日的指数价格计算市值区间(模拟实盘场景) let (band_low, band_high) = self.market_cap_band(prev_index_level); - eprintln!( - "[DEBUG] date={} current_index={:.2} prev_index={:.2} band=[{:.0}, {:.0}]", - date, index_level, prev_index_level, band_low, band_high - ); let (stock_list, selection_notes) = self.select_symbols(ctx, date, band_low, band_high)?; let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0; let mut projected = ctx.portfolio.clone(); @@ -2726,7 +2687,8 @@ impl Strategy for OmniMicroCapStrategy { + self.stop_loss_tolerance(market); let profit_hit = current_price / position.average_cost > self.config.take_profit_ratio; let can_sell = self.can_sell_position(ctx, date, &position.symbol); - if stop_hit || profit_hit { + let at_upper_limit = market.is_at_upper_limit_price(current_price); + if stop_hit || (profit_hit && !at_upper_limit) { let sell_reason = if stop_hit { "stop_loss_exit" } else {