From f17aabc9d305d891819ce3233f98730f5bf23ec9 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 23 Apr 2026 06:05:56 -0700 Subject: [PATCH] Expose smart target-portfolio platform actions --- .../fidc-core/src/platform_expr_strategy.rs | 217 ++++++++++++++++++ crates/fidc-core/src/strategy_ai.rs | 2 +- docs/rqalpha-gap-roadmap.md | 4 +- 3 files changed, 220 insertions(+), 3 deletions(-) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 9f2c6f1..3e1744b 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -108,6 +108,13 @@ pub enum PlatformTradeAction { when_expr: Option, reason: String, }, + TargetPortfolioSmart { + target_weights_expr: String, + order_prices_expr: Option, + valuation_prices_expr: Option, + when_expr: Option, + reason: String, + }, Cancel { kind: PlatformExplicitCancelKind, symbol: Option, @@ -2141,6 +2148,85 @@ impl PlatformExprStrategy { Ok(value.round().max(0.0).min(u64::MAX as f64) as u64) } + fn eval_float_map_expr( + &self, + ctx: &StrategyContext<'_>, + expr: &str, + day: &DayExpressionState, + stock: Option<&StockExpressionState>, + position: Option<&PositionExpressionState>, + ) -> Result, BacktestError> { + let trimmed = expr.trim(); + if trimmed.is_empty() { + return Ok(BTreeMap::new()); + } + let inner = trimmed + .strip_prefix('{') + .and_then(|value| value.strip_suffix('}')) + .ok_or_else(|| { + BacktestError::Execution(format!( + "platform float map expr must use {{...}} object literal syntax: {trimmed}" + )) + })?; + let mut output = BTreeMap::new(); + for entry in Self::split_top_level_args(inner) { + let Some((raw_key, raw_value)) = Self::split_top_level_key_value(&entry) else { + return Err(BacktestError::Execution(format!( + "platform float map entry must be key: value, got {entry}" + ))); + }; + let key = Self::parse_string_literal_key(raw_key)?; + let value = self.eval_float(ctx, raw_value, day, stock, position)?; + output.insert(key, value); + } + Ok(output) + } + + fn split_top_level_key_value(input: &str) -> Option<(&str, &str)> { + let mut paren_depth = 0i32; + let mut brace_depth = 0i32; + let mut bracket_depth = 0i32; + let mut in_single_quote = false; + let mut in_double_quote = false; + let mut escaped = false; + for (idx, ch) in input.char_indices() { + if escaped { + escaped = false; + continue; + } + match ch { + '\\' if in_single_quote || in_double_quote => escaped = true, + '\'' if !in_double_quote => in_single_quote = !in_single_quote, + '"' if !in_single_quote => in_double_quote = !in_double_quote, + _ if in_single_quote || in_double_quote => {} + '(' => paren_depth += 1, + ')' => paren_depth -= 1, + '{' => brace_depth += 1, + '}' => brace_depth -= 1, + '[' => bracket_depth += 1, + ']' => bracket_depth -= 1, + ':' if paren_depth == 0 && brace_depth == 0 && bracket_depth == 0 => { + return Some((input[..idx].trim(), input[idx + 1..].trim())); + } + _ => {} + } + } + None + } + + fn parse_string_literal_key(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.len() >= 2 + && ((trimmed.starts_with('"') && trimmed.ends_with('"')) + || (trimmed.starts_with('\'') && trimmed.ends_with('\''))) + { + return Ok(trimmed[1..trimmed.len() - 1].to_string()); + } + Err(BacktestError::Execution(format!( + "platform float map key must be a quoted string literal: {trimmed}" + ))) + } + fn action_stock_state( &self, ctx: &StrategyContext<'_>, @@ -2456,6 +2542,36 @@ impl PlatformExprStrategy { } } } + PlatformTradeAction::TargetPortfolioSmart { + target_weights_expr, + order_prices_expr, + valuation_prices_expr, + when_expr, + reason, + } => { + if !self.action_when_matches(ctx, day, None, when_expr.as_deref())? { + continue; + } + let target_weights = + self.eval_float_map_expr(ctx, target_weights_expr, day, None, None)?; + if target_weights.is_empty() { + continue; + } + let order_prices = order_prices_expr + .as_deref() + .map(|expr| self.eval_float_map_expr(ctx, expr, day, None, None)) + .transpose()?; + let valuation_prices = valuation_prices_expr + .as_deref() + .map(|expr| self.eval_float_map_expr(ctx, expr, day, None, None)) + .transpose()?; + intents.push(OrderIntent::TargetPortfolioSmart { + target_weights, + order_prices, + valuation_prices, + reason: reason.clone(), + }); + } } } Ok(intents) @@ -3388,6 +3504,107 @@ mod tests { } } + #[test] + fn platform_strategy_emits_target_portfolio_smart_explicit_action() { + let date = d(2025, 2, 3); + let data = DataSet::from_components( + vec![], + vec![DailyMarketSnapshot { + date, + symbol: "000001.SH".to_string(), + timestamp: Some("2025-02-03 10:18:00".to_string()), + day_open: 1000.0, + open: 1000.0, + high: 1002.0, + low: 998.0, + close: 1001.0, + last_price: 1001.0, + bid1: 1000.5, + ask1: 1001.5, + prev_close: 999.0, + volume: 100_000, + tick_volume: 5_000, + bid1_volume: 2_500, + ask1_volume: 2_500, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 1098.9, + lower_limit: 899.1, + price_tick: 0.01, + }], + vec![], + vec![], + vec![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1001.0, + prev_close: 999.0, + volume: 100_000, + }], + ) + .expect("dataset"); + let portfolio = PortfolioState::new(1_000_000.0); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 0, + data: &data, + portfolio: &portfolio, + open_orders: &[], + process_events: &[], + active_process_event: None, + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.rotation_enabled = false; + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + cfg.explicit_actions = vec![PlatformTradeAction::TargetPortfolioSmart { + target_weights_expr: + "{\"000001.SZ\": 0.30, \"000002.SZ\": 0.20}".to_string(), + order_prices_expr: Some( + "{\"000001.SZ\": signal_open * 1.01, \"000002.SZ\": benchmark_open / 100.0}" + .to_string(), + ), + valuation_prices_expr: Some( + "{\"000001.SZ\": signal_close, \"000002.SZ\": benchmark_close / 100.0}" + .to_string(), + ), + when_expr: Some("benchmark_close > 0".to_string()), + reason: "platform_target_portfolio_smart".to_string(), + }]; + let mut strategy = PlatformExprStrategy::new(cfg); + + let decision = strategy.on_day(&ctx).expect("platform decision"); + + assert_eq!(decision.order_intents.len(), 1); + match &decision.order_intents[0] { + crate::strategy::OrderIntent::TargetPortfolioSmart { + target_weights, + order_prices, + valuation_prices, + reason, + } => { + assert_eq!(reason, "platform_target_portfolio_smart"); + assert_eq!(target_weights.get("000001.SZ").copied(), Some(0.30)); + assert_eq!(target_weights.get("000002.SZ").copied(), Some(0.20)); + assert_eq!( + order_prices + .as_ref() + .and_then(|map| map.get("000001.SZ").copied()), + Some(1010.0) + ); + assert_eq!( + valuation_prices + .as_ref() + .and_then(|map| map.get("000002.SZ").copied()), + Some(10.01) + ); + } + other => panic!("unexpected explicit target portfolio intent: {other:?}"), + } + } + #[test] fn platform_strategy_emits_explicit_actions_in_open_auction_stage() { let date = d(2025, 2, 3); diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index ad97573..8b0e5d9 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -120,7 +120,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { }, ManualSection { title: "trading.rotation / order.* / cancel.*".to_string(), - detail: "支持显式下单和撤单。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段,用 trading.schedule.daily() / trading.schedule.weekly(weekday=5) / trading.schedule.weekly(tradingday=-1) / trading.schedule.monthly(tradingday=1) 指定触发频率,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99)、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()。其中 order.target_shares(...) 对应 rqalpha 的 order_to 语义。symbol 使用标准证券代码;数量、金额、仓位、限价和 order_id 都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(), + detail: "支持显式下单和撤单。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段,用 trading.schedule.daily() / trading.schedule.weekly(weekday=5) / trading.schedule.weekly(tradingday=-1) / trading.schedule.monthly(tradingday=1) 指定触发频率,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99)、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices={\"600000.SH\": open * 0.99}, valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()。其中 order.target_shares(...) 对应 rqalpha 的 order_to,order.target_portfolio_smart(...) 对应 rqalpha 的 order_target_portfolio_smart 批量目标权重语义。symbol 使用标准证券代码;数量、金额、仓位、限价和 order_id 都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(), }, ManualSection { title: "when / unless / else".to_string(), diff --git a/docs/rqalpha-gap-roadmap.md b/docs/rqalpha-gap-roadmap.md index 12607bf..e66c12f 100644 --- a/docs/rqalpha-gap-roadmap.md +++ b/docs/rqalpha-gap-roadmap.md @@ -15,7 +15,7 @@ current alignment pass. ### Phase 1: Strategy API parity - [x] `order_to` / target-shares style explicit order primitive -- [ ] `order_target_portfolio(_smart)` style public API surface +- [x] `order_target_portfolio(_smart)` style public API surface - [ ] richer explicit order styles exposed to platform scripts ### Phase 2: Scheduling and execution surface @@ -57,4 +57,4 @@ current alignment pass. ## Current Step -Active implementation target: Phase 1, batch target-portfolio smart semantics. +Active implementation target: Phase 2, minute-level time_rule semantics.