From f805a4b26dfa11b5bbac595581dd021b795cd2af Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 23 Apr 2026 05:49:17 -0700 Subject: [PATCH] Add target-shares parity and rqalpha roadmap --- crates/fidc-core/src/broker.rs | 212 ++++++++++++++++++ .../fidc-core/src/platform_expr_strategy.rs | 138 ++++++++++++ crates/fidc-core/src/strategy.rs | 11 + crates/fidc-core/src/strategy_ai.rs | 2 +- crates/fidc-core/tests/explicit_order_flow.rs | 104 +++++++++ docs/rqalpha-gap-roadmap.md | 60 +++++ 6 files changed, 526 insertions(+), 1 deletion(-) create mode 100644 docs/rqalpha-gap-roadmap.md diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 94861fa..ddc3f0d 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -576,6 +576,42 @@ where commission_state, report, ), + OrderIntent::TargetShares { + symbol, + target_quantity, + reason, + } => self.process_target_shares( + date, + portfolio, + data, + symbol, + *target_quantity, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ), + OrderIntent::LimitTargetShares { + symbol, + target_quantity, + limit_price, + reason, + } => self.process_limit_target_shares( + date, + portfolio, + data, + symbol, + *target_quantity, + *limit_price, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ), OrderIntent::TargetValue { symbol, target_value, @@ -2153,6 +2189,101 @@ where Ok(()) } + fn process_target_shares( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + symbol: &str, + target_quantity: i32, + reason: &str, + intraday_turnover: &mut BTreeMap, + execution_cursors: &mut BTreeMap, + global_execution_cursor: &mut Option, + commission_state: &mut BTreeMap, + report: &mut BrokerExecutionReport, + ) -> Result<(), BacktestError> { + let current_qty = portfolio + .position(symbol) + .map(|pos| pos.quantity) + .unwrap_or(0); + let target_qty = target_quantity.max(0) as u32; + let minimum_order_quantity = self.minimum_order_quantity(data, symbol); + let order_step_size = self.order_step_size(data, symbol); + + if current_qty > target_qty { + let raw_sell_qty = current_qty - target_qty; + let sell_qty = if target_qty == 0 { + current_qty + } else { + self.round_buy_quantity(raw_sell_qty, minimum_order_quantity, order_step_size) + .min(current_qty) + }; + if sell_qty > 0 { + self.process_sell( + date, + portfolio, + data, + symbol, + sell_qty, + self.reserve_order_id(), + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + None, + false, + true, + report, + )?; + } + } else if target_qty > current_qty { + let buy_qty = self.round_buy_quantity( + target_qty - current_qty, + minimum_order_quantity, + order_step_size, + ); + if buy_qty > 0 { + self.process_buy( + date, + portfolio, + data, + symbol, + buy_qty, + self.reserve_order_id(), + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + None, + None, + false, + true, + report, + )?; + } + } else { + report.order_events.push(OrderEvent { + date, + order_id: None, + symbol: symbol.to_string(), + side: if current_qty > 0 { + OrderSide::Sell + } else { + OrderSide::Buy + }, + requested_quantity: 0, + filled_quantity: 0, + status: OrderStatus::Filled, + reason: format!("{reason}: already at target shares"), + }); + } + + Ok(()) + } + fn process_limit_target_value( &self, date: NaiveDate, @@ -2228,6 +2359,87 @@ where Ok(()) } + fn process_limit_target_shares( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + symbol: &str, + target_quantity: i32, + limit_price: f64, + reason: &str, + intraday_turnover: &mut BTreeMap, + execution_cursors: &mut BTreeMap, + global_execution_cursor: &mut Option, + commission_state: &mut BTreeMap, + report: &mut BrokerExecutionReport, + ) -> Result<(), BacktestError> { + let current_qty = portfolio + .position(symbol) + .map(|pos| pos.quantity) + .unwrap_or(0); + let target_qty = target_quantity.max(0) as u32; + let minimum_order_quantity = self.minimum_order_quantity(data, symbol); + let order_step_size = self.order_step_size(data, symbol); + + if current_qty > target_qty { + let raw_sell_qty = current_qty - target_qty; + let sell_qty = if target_qty == 0 { + current_qty + } else { + self.round_buy_quantity(raw_sell_qty, minimum_order_quantity, order_step_size) + .min(current_qty) + }; + if sell_qty > 0 { + self.process_sell( + date, + portfolio, + data, + symbol, + sell_qty, + self.reserve_order_id(), + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + Some(limit_price), + true, + true, + report, + )?; + } + } else if target_qty > current_qty { + let buy_qty = self.round_buy_quantity( + target_qty - current_qty, + minimum_order_quantity, + order_step_size, + ); + if buy_qty > 0 { + self.process_buy( + date, + portfolio, + data, + symbol, + buy_qty, + self.reserve_order_id(), + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + None, + Some(limit_price), + true, + true, + report, + )?; + } + } + + Ok(()) + } + fn process_target_percent( &self, date: NaiveDate, diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index c19f593..9f2c6f1 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -79,6 +79,8 @@ pub enum PlatformExplicitOrderKind { LimitShares, Lots, LimitLots, + TargetShares, + LimitTargetShares, Value, LimitValue, Percent, @@ -2261,6 +2263,32 @@ impl PlatformExprStrategy { reason: reason.clone(), }); } + PlatformExplicitOrderKind::TargetShares => { + let target_quantity = + self.eval_i32(ctx, amount_expr, day, stock_state.as_ref(), None)?; + intents.push(OrderIntent::TargetShares { + symbol: symbol.clone(), + target_quantity, + reason: reason.clone(), + }); + } + PlatformExplicitOrderKind::LimitTargetShares => { + let target_quantity = + self.eval_i32(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::LimitTargetShares { + symbol: symbol.clone(), + target_quantity, + limit_price, + reason: reason.clone(), + }); + } PlatformExplicitOrderKind::Value => { let value = self.eval_float(ctx, amount_expr, day, stock_state.as_ref(), None)?; @@ -3250,6 +3278,116 @@ mod tests { ); } + #[test] + fn platform_strategy_emits_target_shares_explicit_action() { + 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: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: "000001.SZ".to_string(), + timestamp: Some("2025-02-03 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.4, + low: 9.8, + close: 10.2, + last_price: 10.2, + bid1: 10.18, + ask1: 10.22, + prev_close: 9.9, + 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: 10.89, + lower_limit: 8.91, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: "000001.SZ".to_string(), + market_cap_bn: 50.0, + free_float_cap_bn: 45.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.2), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: "000001.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_kcb: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_one_yuan: false, + }], + 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.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::TargetShares, + symbol: "000001.SZ".to_string(), + amount_expr: "2000".to_string(), + limit_price_expr: None, + when_expr: Some("allow_buy".to_string()), + reason: "platform_target_shares".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::TargetShares { + symbol, + target_quantity, + reason, + } => { + assert_eq!(symbol, "000001.SZ"); + assert_eq!(*target_quantity, 2000); + assert_eq!(reason, "platform_target_shares"); + } + other => panic!("unexpected explicit target shares 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.rs b/crates/fidc-core/src/strategy.rs index d4f3a28..224cba9 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -309,6 +309,17 @@ pub enum OrderIntent { limit_price: f64, reason: String, }, + TargetShares { + symbol: String, + target_quantity: i32, + reason: String, + }, + LimitTargetShares { + symbol: String, + target_quantity: i32, + limit_price: f64, + reason: String, + }, TargetValue { symbol: String, target_value: f64, diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 220c39d..ad97573 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.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(), + 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(), }, ManualSection { title: "when / unless / else".to_string(), diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index bc07663..29c22a9 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -219,6 +219,110 @@ fn broker_executes_order_shares_and_order_lots() { ); } +#[test] +fn broker_executes_target_shares_like_order_to() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let data = DataSet::from_components( + vec![Instrument { + symbol: "000002.SZ".to_string(), + name: "Test".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: "000002.SZ".to_string(), + timestamp: Some("2024-01-10 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.1, + low: 9.9, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + prev_close: 10.0, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 80_000, + ask1_volume: 80_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: "000002.SZ".to_string(), + market_cap_bn: 50.0, + free_float_cap_bn: 45.0, + pe_ttm: 15.0, + turnover_ratio: Some(2.0), + effective_turnover_ratio: Some(1.8), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: "000002.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_kcb: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + 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 mut portfolio = PortfolioState::new(1_000_000.0); + portfolio + .position_mut("000002.SZ") + .buy(date.pred_opt().expect("previous day"), 300, 9.0); + + let broker = BrokerSimulator::new( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + ); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![OrderIntent::TargetShares { + symbol: "000002.SZ".to_string(), + target_quantity: 150, + reason: "test_target_shares".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("broker execution"); + + assert_eq!(portfolio.position("000002.SZ").map(|pos| pos.quantity), Some(200)); + assert_eq!(report.fill_events.len(), 1); + assert_eq!(report.fill_events[0].side, fidc_core::OrderSide::Sell); + assert_eq!(report.fill_events[0].quantity, 100); +} + #[test] fn broker_executes_order_percent_and_target_percent() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); diff --git a/docs/rqalpha-gap-roadmap.md b/docs/rqalpha-gap-roadmap.md new file mode 100644 index 0000000..d336671 --- /dev/null +++ b/docs/rqalpha-gap-roadmap.md @@ -0,0 +1,60 @@ +# RQAlpha Gap Roadmap + +This document tracks the remaining RQAlpha backtest capabilities that are not +yet fully aligned in `fidc-backtest-engine`, and the implementation order we +are following. + +## Scope + +This roadmap focuses on the China A-share stock backtest path first. Multi-asset +coverage such as futures/options is tracked separately and is not part of the +current alignment pass. + +## Remaining Gaps + +### Phase 1: Strategy API parity + +- [ ] `order_to` / target-shares style explicit order primitive +- [ ] `order_target_portfolio(_smart)` style public API surface +- [ ] richer explicit order styles exposed to platform scripts + +### Phase 2: Scheduling and execution surface + +- [ ] minute-level `time_rule` semantics like `market_open`, `market_close`, + `physical_time` +- [ ] finer `1m` / `tick` strategy execution entrypoints beyond `open_auction` + and `on_day` +- [ ] scheduled actions evaluated against explicit intraday times + +### Phase 3: Universe and subscription model + +- [ ] `update_universe` +- [ ] `subscribe` +- [ ] `unsubscribe` +- [ ] tick-frequency subscription guards exposed at strategy API level + +### Phase 4: Algo order parity + +- [ ] `VWAPOrder` +- [ ] `TWAPOrder` +- [ ] `order_target_portfolio_smart(..., order_prices=AlgoOrder, valuation_prices=...)` + +### Phase 5: Position accounting parity + +- [ ] `trading_pnl` +- [ ] `position_pnl` +- [ ] `dividend_receivable` +- [ ] richer position lifecycle fields exposed to strategy runtime + +## Execution Order + +1. Close the explicit order API gap with target-shares / `order_to` parity. +2. Add public batch target-portfolio semantics. +3. Expand scheduler to intraday time rules. +4. Add dynamic universe APIs. +5. Add algo-order styles. +6. Finish position accounting parity. + +## Current Step + +Active implementation target: Phase 1, target-shares / `order_to` parity.