diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 8487743..a77b2a7 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -35,8 +35,9 @@ pub use events::{ pub use instrument::Instrument; pub use metrics::{BacktestMetrics, compute_backtest_metrics}; pub use platform_expr_strategy::{ - PlatformExprStrategy, PlatformExprStrategyConfig, PlatformRebalanceSchedule, - PlatformScheduleFrequency, + PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategy, + PlatformExprStrategyConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency, + PlatformTradeAction, }; pub use portfolio::{CashReceivable, HoldingSummary, PortfolioState, Position}; pub use rules::{ChinaEquityRuleHooks, EquityRuleHooks, RuleCheck}; diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index c76e165..860c765 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -73,6 +73,48 @@ impl PlatformRebalanceSchedule { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PlatformExplicitOrderKind { + Shares, + LimitShares, + Lots, + LimitLots, + Value, + LimitValue, + Percent, + LimitPercent, + TargetValue, + LimitTargetValue, + TargetPercent, + LimitTargetPercent, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PlatformExplicitCancelKind { + Order, + Symbol, + All, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PlatformTradeAction { + Order { + kind: PlatformExplicitOrderKind, + symbol: String, + amount_expr: String, + limit_price_expr: Option, + when_expr: Option, + reason: String, + }, + Cancel { + kind: PlatformExplicitCancelKind, + symbol: Option, + order_id_expr: Option, + when_expr: Option, + reason: String, + }, +} + #[derive(Debug, Clone)] pub struct PlatformExprStrategyConfig { pub strategy_name: String, @@ -102,6 +144,8 @@ pub struct PlatformExprStrategyConfig { pub stock_long_ma_days: usize, pub skip_month_day_ranges: Vec<(u32, u32, u32)>, pub rebalance_schedule: Option, + pub rotation_enabled: bool, + pub explicit_actions: Vec, } impl PlatformExprStrategyConfig { @@ -147,6 +191,8 @@ fn band_low(index_close) { stock_long_ma_days: 20, skip_month_day_ranges: Vec::new(), rebalance_schedule: None, + rotation_enabled: true, + explicit_actions: Vec::new(), } } @@ -1855,6 +1901,336 @@ impl PlatformExprStrategy { .map(|value| value.clamp(0.0, 1.0)) } + fn eval_i32( + &self, + ctx: &StrategyContext<'_>, + expr: &str, + day: &DayExpressionState, + stock: Option<&StockExpressionState>, + position: Option<&PositionExpressionState>, + ) -> Result { + let value = self.eval_float(ctx, expr, day, stock, position)?; + if !value.is_finite() { + return Err(BacktestError::Execution(format!( + "platform expr did not produce a finite integer: {}", + expr + ))); + } + Ok(value.round().clamp(i32::MIN as f64, i32::MAX as f64) as i32) + } + + fn eval_u64( + &self, + ctx: &StrategyContext<'_>, + expr: &str, + day: &DayExpressionState, + stock: Option<&StockExpressionState>, + position: Option<&PositionExpressionState>, + ) -> Result { + let value = self.eval_float(ctx, expr, day, stock, position)?; + if !value.is_finite() { + return Err(BacktestError::Execution(format!( + "platform expr did not produce a finite order id: {}", + expr + ))); + } + Ok(value.round().max(0.0).min(u64::MAX as f64) as u64) + } + + fn action_stock_state( + &self, + ctx: &StrategyContext<'_>, + date: NaiveDate, + symbol: Option<&str>, + ) -> Result, BacktestError> { + let Some(symbol) = symbol else { + return Ok(None); + }; + if ctx.data.market(date, symbol).is_none() + || ctx.data.factor(date, symbol).is_none() + || ctx.data.candidate(date, symbol).is_none() + { + return Ok(None); + } + self.stock_state(ctx, date, symbol).map(Some) + } + + fn action_when_matches( + &self, + ctx: &StrategyContext<'_>, + day: &DayExpressionState, + stock: Option<&StockExpressionState>, + expr: Option<&str>, + ) -> Result { + let Some(expr) = expr.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(true); + }; + self.eval_bool(ctx, expr, day, stock, None) + } + + fn explicit_action_intents( + &self, + ctx: &StrategyContext<'_>, + date: NaiveDate, + day: &DayExpressionState, + ) -> Result, BacktestError> { + let mut intents = Vec::new(); + for action in &self.config.explicit_actions { + match action { + PlatformTradeAction::Order { + kind, + symbol, + amount_expr, + limit_price_expr, + when_expr, + reason, + } => { + let stock_state = self.action_stock_state(ctx, date, Some(symbol))?; + if !self.action_when_matches( + ctx, + day, + stock_state.as_ref(), + when_expr.as_deref(), + )? { + continue; + } + match kind { + PlatformExplicitOrderKind::Shares => { + let quantity = + self.eval_i32(ctx, amount_expr, day, stock_state.as_ref(), None)?; + if quantity == 0 { + continue; + } + intents.push(OrderIntent::Shares { + symbol: symbol.clone(), + quantity, + reason: reason.clone(), + }); + } + PlatformExplicitOrderKind::LimitShares => { + let quantity = + self.eval_i32(ctx, amount_expr, day, stock_state.as_ref(), None)?; + if quantity == 0 { + continue; + } + let limit_price = self.eval_float( + ctx, + limit_price_expr.as_deref().unwrap_or_default(), + day, + stock_state.as_ref(), + None, + )?; + intents.push(OrderIntent::LimitShares { + symbol: symbol.clone(), + quantity, + limit_price, + reason: reason.clone(), + }); + } + PlatformExplicitOrderKind::Lots => { + let lots = + self.eval_i32(ctx, amount_expr, day, stock_state.as_ref(), None)?; + if lots == 0 { + continue; + } + intents.push(OrderIntent::Lots { + symbol: symbol.clone(), + lots, + reason: reason.clone(), + }); + } + PlatformExplicitOrderKind::LimitLots => { + let lots = + self.eval_i32(ctx, amount_expr, day, stock_state.as_ref(), None)?; + if lots == 0 { + continue; + } + let limit_price = self.eval_float( + ctx, + limit_price_expr.as_deref().unwrap_or_default(), + day, + stock_state.as_ref(), + None, + )?; + intents.push(OrderIntent::LimitLots { + symbol: symbol.clone(), + lots, + limit_price, + reason: reason.clone(), + }); + } + PlatformExplicitOrderKind::Value => { + let value = + self.eval_float(ctx, amount_expr, day, stock_state.as_ref(), None)?; + if value.abs() <= f64::EPSILON { + continue; + } + intents.push(OrderIntent::Value { + symbol: symbol.clone(), + value, + reason: reason.clone(), + }); + } + PlatformExplicitOrderKind::LimitValue => { + let value = + self.eval_float(ctx, amount_expr, day, stock_state.as_ref(), None)?; + if value.abs() <= f64::EPSILON { + continue; + } + let limit_price = self.eval_float( + ctx, + limit_price_expr.as_deref().unwrap_or_default(), + day, + stock_state.as_ref(), + None, + )?; + intents.push(OrderIntent::LimitValue { + symbol: symbol.clone(), + value, + limit_price, + reason: reason.clone(), + }); + } + PlatformExplicitOrderKind::Percent => { + let percent = + self.eval_float(ctx, amount_expr, day, stock_state.as_ref(), None)?; + if percent.abs() <= f64::EPSILON { + continue; + } + intents.push(OrderIntent::Percent { + symbol: symbol.clone(), + percent, + reason: reason.clone(), + }); + } + PlatformExplicitOrderKind::LimitPercent => { + let percent = + self.eval_float(ctx, amount_expr, day, stock_state.as_ref(), None)?; + if percent.abs() <= f64::EPSILON { + continue; + } + let limit_price = self.eval_float( + ctx, + limit_price_expr.as_deref().unwrap_or_default(), + day, + stock_state.as_ref(), + None, + )?; + intents.push(OrderIntent::LimitPercent { + symbol: symbol.clone(), + percent, + limit_price, + reason: reason.clone(), + }); + } + PlatformExplicitOrderKind::TargetValue => { + let target_value = + self.eval_float(ctx, amount_expr, day, stock_state.as_ref(), None)?; + intents.push(OrderIntent::TargetValue { + symbol: symbol.clone(), + target_value, + reason: reason.clone(), + }); + } + PlatformExplicitOrderKind::LimitTargetValue => { + let target_value = + self.eval_float(ctx, amount_expr, day, stock_state.as_ref(), None)?; + let limit_price = self.eval_float( + ctx, + limit_price_expr.as_deref().unwrap_or_default(), + day, + stock_state.as_ref(), + None, + )?; + intents.push(OrderIntent::LimitTargetValue { + symbol: symbol.clone(), + target_value, + limit_price, + reason: reason.clone(), + }); + } + PlatformExplicitOrderKind::TargetPercent => { + let target_percent = + self.eval_float(ctx, amount_expr, day, stock_state.as_ref(), None)?; + intents.push(OrderIntent::TargetPercent { + symbol: symbol.clone(), + target_percent, + reason: reason.clone(), + }); + } + PlatformExplicitOrderKind::LimitTargetPercent => { + let target_percent = + self.eval_float(ctx, amount_expr, day, stock_state.as_ref(), None)?; + let limit_price = self.eval_float( + ctx, + limit_price_expr.as_deref().unwrap_or_default(), + day, + stock_state.as_ref(), + None, + )?; + intents.push(OrderIntent::LimitTargetPercent { + symbol: symbol.clone(), + target_percent, + limit_price, + reason: reason.clone(), + }); + } + } + } + PlatformTradeAction::Cancel { + kind, + symbol, + order_id_expr, + when_expr, + reason, + } => { + let stock_state = self.action_stock_state(ctx, date, symbol.as_deref())?; + if !self.action_when_matches( + ctx, + day, + stock_state.as_ref(), + when_expr.as_deref(), + )? { + continue; + } + match kind { + PlatformExplicitCancelKind::Order => { + let order_id = self.eval_u64( + ctx, + order_id_expr.as_deref().unwrap_or_default(), + day, + stock_state.as_ref(), + None, + )?; + if order_id == 0 { + continue; + } + intents.push(OrderIntent::CancelOrder { + order_id, + reason: reason.clone(), + }); + } + PlatformExplicitCancelKind::Symbol => { + let Some(symbol) = symbol.clone() else { + continue; + }; + intents.push(OrderIntent::CancelSymbol { + symbol, + reason: reason.clone(), + }); + } + PlatformExplicitCancelKind::All => { + intents.push(OrderIntent::CancelAll { + reason: reason.clone(), + }); + } + } + } + } + } + Ok(intents) + } + fn stock_passes_expr( &self, ctx: &StrategyContext<'_>, @@ -2194,17 +2570,40 @@ impl Strategy for PlatformExprStrategy { } let day = self.day_state(ctx, date)?; - let trading_ratio = self.trading_ratio(ctx, &day)?; - let (band_low, band_high) = self.market_cap_band(ctx, &day)?; - let selection_limit = self - .selection_limit(ctx, &day)? - .min(self.config.max_positions.max(1)); - let (stock_list, selection_notes) = - self.select_symbols(ctx, date, &day, band_low, band_high, selection_limit)?; - let periodic_rebalance = if let Some(schedule) = &self.config.rebalance_schedule { - schedule.matches(ctx.data.calendar(), date) + let explicit_action_intents = self.explicit_action_intents(ctx, date, &day)?; + let mut selection_notes = Vec::new(); + let trading_ratio = if self.config.rotation_enabled { + self.trading_ratio(ctx, &day)? } else { - ctx.decision_index % self.config.refresh_rate == 0 + 0.0 + }; + let (band_low, band_high) = if self.config.rotation_enabled { + self.market_cap_band(ctx, &day)? + } else { + (0.0, 0.0) + }; + let selection_limit = if self.config.rotation_enabled { + self.selection_limit(ctx, &day)? + .min(self.config.max_positions.max(1)) + } else { + 0 + }; + let stock_list = if self.config.rotation_enabled { + let (stock_list, notes) = + self.select_symbols(ctx, date, &day, band_low, band_high, selection_limit)?; + selection_notes = notes; + stock_list + } else { + Vec::new() + }; + let periodic_rebalance = if self.config.rotation_enabled { + if let Some(schedule) = &self.config.rebalance_schedule { + schedule.matches(ctx.data.calendar(), date) + } else { + ctx.decision_index % self.config.refresh_rate == 0 + } + } else { + false }; let mut projected = ctx.portfolio.clone(); let mut projected_execution_state = ProjectedExecutionState::default(); @@ -2240,7 +2639,7 @@ impl Strategy for PlatformExprStrategy { ); } - if projected.positions().len() < selection_limit { + if self.config.rotation_enabled && projected.positions().len() < selection_limit { let remaining_slots = selection_limit - projected.positions().len(); if remaining_slots > 0 { let replacement_cash = @@ -2353,17 +2752,30 @@ impl Strategy for PlatformExprStrategy { } } + if !explicit_action_intents.is_empty() { + order_intents.extend(explicit_action_intents); + } + let mut diagnostics = vec![ - format!( - "platform_expr signal={} last={:.2} ma_short={:.2} ma_long={:.2} band={:.2}-{:.2} tr={:.2}", - self.config.signal_symbol, - day.signal_close, - day.benchmark_ma_short, - day.benchmark_ma_long, - band_low, - band_high, - trading_ratio - ), + if self.config.rotation_enabled { + format!( + "platform_expr signal={} last={:.2} ma_short={:.2} ma_long={:.2} band={:.2}-{:.2} tr={:.2}", + self.config.signal_symbol, + day.signal_close, + day.benchmark_ma_short, + day.benchmark_ma_long, + band_low, + band_high, + trading_ratio + ) + } else { + format!( + "platform_expr signal={} last={:.2} explicit_actions={} rotation=false", + self.config.signal_symbol, + day.signal_close, + self.config.explicit_actions.len() + ) + }, format!( "selected={} periodic_rebalance={} exits={} projected_positions={} intents={} limit={}", stock_list.len(), @@ -2395,10 +2807,19 @@ impl Strategy for PlatformExprStrategy { #[cfg(test)] mod tests { + use std::collections::BTreeMap; + use chrono::NaiveDate; - use super::{PlatformRebalanceSchedule, PlatformScheduleFrequency}; - use crate::TradingCalendar; + use super::{ + PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategy, + PlatformExprStrategyConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency, + PlatformTradeAction, + }; + use crate::{ + BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, + Instrument, PortfolioState, Strategy, StrategyContext, TradingCalendar, + }; fn d(year: i32, month: u32, day: u32) -> NaiveDate { NaiveDate::from_ymd_opt(year, month, day).expect("valid date") @@ -2436,4 +2857,133 @@ mod tests { assert!(schedule.matches(&calendar, d(2025, 2, 3))); assert!(!schedule.matches(&calendar, d(2025, 2, 4))); } + + #[test] + fn platform_strategy_emits_explicit_actions_when_rotation_is_disabled() { + let date = d(2025, 2, 3); + let data = DataSet::from_components( + vec![Instrument { + symbol: "000001.SZ".to_string(), + name: "Ping An Bank".to_string(), + board: "SZSE".to_string(), + round_lot: 100, + listed_at: Some(d(2010, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: "000001.SZ".to_string(), + timestamp: Some("10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.2, + low: 9.9, + close: 10.1, + last_price: 10.05, + bid1: 10.04, + ask1: 10.05, + prev_close: 9.95, + volume: 1_000_000, + tick_volume: 5_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 10.94, + lower_limit: 8.96, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: "000001.SZ".to_string(), + market_cap_bn: 12.0, + free_float_cap_bn: 10.0, + pe_ttm: 8.0, + turnover_ratio: Some(22.0), + effective_turnover_ratio: Some(18.0), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + 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, + 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 ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 0, + data: &data, + portfolio: &portfolio, + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.signal_symbol = "000001.SZ".to_string(); + cfg.rotation_enabled = false; + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + cfg.explicit_actions = vec![ + PlatformTradeAction::Order { + kind: PlatformExplicitOrderKind::Value, + symbol: "000001.SZ".to_string(), + amount_expr: "cash * 0.1".to_string(), + limit_price_expr: None, + when_expr: Some("allow_buy && !touched_upper_limit".to_string()), + reason: "platform_explicit_value".to_string(), + }, + PlatformTradeAction::Cancel { + kind: PlatformExplicitCancelKind::Symbol, + symbol: Some("000001.SZ".to_string()), + order_id_expr: None, + when_expr: Some("allow_buy".to_string()), + reason: "platform_cancel_symbol".to_string(), + }, + ]; + let mut strategy = PlatformExprStrategy::new(cfg); + + let decision = strategy.on_day(&ctx).expect("platform decision"); + + assert_eq!(decision.order_intents.len(), 2); + match &decision.order_intents[0] { + crate::strategy::OrderIntent::Value { + symbol, + value, + reason, + } => { + assert_eq!(symbol, "000001.SZ"); + assert!((*value - 100_000.0).abs() < 1e-6); + assert_eq!(reason, "platform_explicit_value"); + } + other => panic!("unexpected first explicit order intent: {other:?}"), + } + match &decision.order_intents[1] { + crate::strategy::OrderIntent::CancelSymbol { symbol, reason } => { + assert_eq!(symbol, "000001.SZ"); + assert_eq!(reason, "platform_cancel_symbol"); + } + other => panic!("unexpected explicit cancel intent: {other:?}"), + } + assert!( + decision + .diagnostics + .iter() + .any(|item| item.contains("rotation=false")) + ); + } } diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index f410f57..017f377 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -118,6 +118,10 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { title: "execution.matching_type / execution.slippage".to_string(), detail: "设置撮合模式和滑点。支持 execution.matching_type(\"next_tick_last\" | \"next_tick_best_own\" | \"next_tick_best_counterparty\" | \"counterparty_offer\" | \"vwap\" | \"current_bar_close\" | \"next_bar_open\" | \"open_auction\")。其中 next_tick_last 使用 tick 的 last_price;next_tick_best_own / next_tick_best_counterparty 会按 L1 买一卖一近似 rqalpha 的 tick 最优价语义,counterparty_offer 当前也按 L1 对手方报价近似实现;vwap 会在盘中执行价链路上聚合多笔成交为单条 VWAP 成交;open_auction 使用当日集合竞价开盘价 day_open 进行撮合,且不额外施加滑点,并按竞价成交量而不是盘口一档流动性限制成交;滑点支持 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 0.001) / execution.slippage(\"tick_size\", 1) / execution.slippage(\"limit_price\"),其中 limit_price 会在限价单成交时按挂单价模拟 rqalpha 的最坏成交价。".to_string(), }, + ManualSection { + title: "trading.rotation / order.* / cancel.*".to_string(), + detail: "支持显式下单和撤单。可以用 trading.rotation(false) 关闭默认轮动链路,再写 order.shares(\"600000.SH\", 1000)、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()。symbol 使用标准证券代码;数量、金额、仓位、限价和 order_id 都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(), + }, ManualSection { title: "when / unless / else".to_string(), detail: "条件块支持按日期、指数、仓位等动态切换规则。".to_string(), @@ -206,6 +210,10 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { title: "next tick 撮合 + tick 滑点".to_string(), code: "execution.matching_type(\"next_tick_last\")\nexecution.slippage(\"tick_size\", 1)".to_string(), }, + ManualExample { + title: "显式下单并关闭默认轮动".to_string(), + code: "trading.rotation(false)\norder.value(\"600000.SH\", cash * 0.25, \"manual_entry\")\ncancel.symbol(\"600000.SH\", \"manual_cancel\")".to_string(), + }, ], } }