From 48f8486e1ae65f91014f2d4c7619827fd10a4351 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 23 Apr 2026 05:57:29 -0700 Subject: [PATCH] Add smart target-portfolio order intent --- crates/fidc-core/src/broker.rs | 232 ++++++++++++++++-- crates/fidc-core/src/strategy.rs | 6 + crates/fidc-core/tests/explicit_order_flow.rs | 184 ++++++++++++++ docs/rqalpha-gap-roadmap.md | 4 +- 4 files changed, 406 insertions(+), 20 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index ddc3f0d..fd9bfeb 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -756,6 +756,25 @@ where commission_state, report, ), + OrderIntent::TargetPortfolioSmart { + target_weights, + order_prices, + valuation_prices, + reason, + } => self.process_target_portfolio_smart( + date, + portfolio, + data, + target_weights, + order_prices.as_ref(), + valuation_prices.as_ref(), + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ), OrderIntent::CancelOrder { order_id, reason } => { self.cancel_open_order(date, *order_id, reason, report); Ok(()) @@ -929,6 +948,15 @@ where .retain(|existing| existing.order_id != order_id); } + fn extend_report(into: &mut BrokerExecutionReport, mut other: BrokerExecutionReport) { + into.order_events.append(&mut other.order_events); + into.fill_events.append(&mut other.fill_events); + into.position_events.append(&mut other.position_events); + into.account_events.append(&mut other.account_events); + into.process_events.append(&mut other.process_events); + into.diagnostics.append(&mut other.diagnostics); + } + fn reserved_open_sell_quantity(&self, symbol: &str, exclude_order_id: Option) -> u32 { self.open_orders .borrow() @@ -1187,12 +1215,25 @@ where data: &DataSet, target_weights: &BTreeMap, ) -> Result<(BTreeMap, Vec), BacktestError> { - let equity = self.rebalance_total_equity_at(date, portfolio, data)?; + self.target_quantities_with_valuation_prices(date, portfolio, data, target_weights, None) + } + + fn target_quantities_with_valuation_prices( + &self, + date: NaiveDate, + portfolio: &PortfolioState, + data: &DataSet, + target_weights: &BTreeMap, + valuation_prices: Option<&BTreeMap>, + ) -> Result<(BTreeMap, Vec), BacktestError> { + let equity = + self.rebalance_total_equity_at_with_overrides(date, portfolio, data, valuation_prices)?; let target_weight_sum = target_weights.values().copied().sum::(); let mut desired_targets = BTreeMap::new(); let mut diagnostics = Vec::new(); for (symbol, weight) in target_weights { - let price = self.rebalance_valuation_price(date, symbol, data)?; + let price = + self.rebalance_valuation_price_with_overrides(date, symbol, data, valuation_prices)?; let raw_qty = ((equity * weight) / price).floor() as u32; desired_targets.insert( symbol.clone(), @@ -1216,7 +1257,12 @@ where .map(|pos| pos.quantity) .unwrap_or(0); let desired_qty = *desired_targets.get(&symbol).unwrap_or(&0); - let price = self.rebalance_valuation_price(date, &symbol, data)?; + let price = self.rebalance_valuation_price_with_overrides( + date, + &symbol, + data, + valuation_prices, + )?; let minimum_order_quantity = self.minimum_order_quantity(data, &symbol); let order_step_size = self.order_step_size(data, &symbol); let min_target_qty = self.minimum_target_quantity( @@ -1411,6 +1457,123 @@ where Ok((best_targets, diagnostics)) } + fn process_target_portfolio_smart( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + target_weights: &BTreeMap, + order_prices: Option<&BTreeMap>, + valuation_prices: Option<&BTreeMap>, + 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 (target_quantities, diagnostics) = self.target_quantities_with_valuation_prices( + date, + portfolio, + data, + target_weights, + valuation_prices, + )?; + report.diagnostics.extend(diagnostics); + + let mut symbols = BTreeSet::new(); + symbols.extend(portfolio.positions().keys().cloned()); + symbols.extend(target_quantities.keys().cloned()); + + for symbol in &symbols { + let current_qty = portfolio.position(symbol).map(|pos| pos.quantity).unwrap_or(0); + let target_qty = target_quantities.get(symbol).copied().unwrap_or(0); + if current_qty <= target_qty { + continue; + } + let sell_qty = current_qty - target_qty; + let mut local_report = BrokerExecutionReport::default(); + if let Some(limit_price) = + self.required_custom_order_price(date, symbol, order_prices)? + { + self.process_limit_shares( + date, + portfolio, + data, + symbol, + -(sell_qty as i32), + limit_price, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + &mut local_report, + )?; + } else { + self.process_shares( + date, + portfolio, + data, + symbol, + -(sell_qty as i32), + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + &mut local_report, + )?; + } + Self::extend_report(report, local_report); + } + + for symbol in &symbols { + let current_qty = portfolio.position(symbol).map(|pos| pos.quantity).unwrap_or(0); + let target_qty = target_quantities.get(symbol).copied().unwrap_or(0); + if target_qty <= current_qty { + continue; + } + let buy_qty = target_qty - current_qty; + let mut local_report = BrokerExecutionReport::default(); + if let Some(limit_price) = + self.required_custom_order_price(date, symbol, order_prices)? + { + self.process_limit_shares( + date, + portfolio, + data, + symbol, + buy_qty as i32, + limit_price, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + &mut local_report, + )?; + } else { + self.process_shares( + date, + portfolio, + data, + symbol, + buy_qty as i32, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + &mut local_report, + )?; + } + Self::extend_report(report, local_report); + } + + Ok(()) + } + fn minimum_target_quantity( &self, date: NaiveDate, @@ -3380,12 +3543,23 @@ where } } - fn rebalance_valuation_price( + fn rebalance_valuation_price_with_overrides( &self, date: NaiveDate, symbol: &str, data: &DataSet, + valuation_prices: Option<&BTreeMap>, ) -> Result { + if let Some(prices) = valuation_prices { + if let Some(price) = prices.get(symbol).copied().filter(|price| *price > 0.0) { + return Ok(price); + } + return Err(BacktestError::MissingPrice { + date, + symbol: symbol.to_string(), + field: "custom valuation", + }); + } let snapshot = data .market(date, symbol) .ok_or_else(|| BacktestError::MissingPrice { @@ -3406,28 +3580,50 @@ where date: NaiveDate, portfolio: &PortfolioState, data: &DataSet, + ) -> Result { + self.rebalance_total_equity_at_with_overrides(date, portfolio, data, None) + } + + fn rebalance_total_equity_at_with_overrides( + &self, + date: NaiveDate, + portfolio: &PortfolioState, + data: &DataSet, + valuation_prices: Option<&BTreeMap>, ) -> Result { let mut market_value = 0.0; for position in portfolio.positions().values() { - let snapshot = - data.market(date, &position.symbol) - .ok_or_else(|| BacktestError::MissingPrice { - date, - symbol: position.symbol.clone(), - field: self.rebalance_valuation_price_field_name(), - })?; - let price = self - .rebalance_valuation_price_for_snapshot(snapshot) - .ok_or_else(|| BacktestError::MissingPrice { - date, - symbol: position.symbol.clone(), - field: self.rebalance_valuation_price_field_name(), - })?; + let price = self.rebalance_valuation_price_with_overrides( + date, + &position.symbol, + data, + valuation_prices, + )?; market_value += price * position.quantity as f64; } Ok(portfolio.cash() + market_value) } + fn required_custom_order_price( + &self, + date: NaiveDate, + symbol: &str, + order_prices: Option<&BTreeMap>, + ) -> Result, BacktestError> { + let Some(prices) = order_prices else { + return Ok(None); + }; + if let Some(price) = prices.get(symbol).copied().filter(|price| *price > 0.0) { + Ok(Some(price)) + } else { + Err(BacktestError::MissingPrice { + date, + symbol: symbol.to_string(), + field: "custom order", + }) + } + } + fn round_buy_quantity( &self, quantity: u32, diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 224cba9..edcb4f7 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -364,6 +364,12 @@ pub enum OrderIntent { limit_price: f64, reason: String, }, + TargetPortfolioSmart { + target_weights: BTreeMap, + order_prices: Option>, + valuation_prices: Option>, + reason: String, + }, CancelOrder { order_id: u64, reason: String, diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 29c22a9..547f580 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -323,6 +323,190 @@ fn broker_executes_target_shares_like_order_to() { assert_eq!(report.fill_events[0].quantity, 100); } +#[test] +fn broker_executes_target_portfolio_smart_with_custom_prices() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let data = DataSet::from_components( + vec![ + Instrument { + symbol: "000001.SZ".to_string(), + name: "Old".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + }, + Instrument { + symbol: "000002.SZ".to_string(), + name: "New".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + }, + ], + vec![ + DailyMarketSnapshot { + date, + symbol: "000001.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, + }, + 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: "000001.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(), + }, + DailyFactorSnapshot { + date, + symbol: "000002.SZ".to_string(), + market_cap_bn: 45.0, + free_float_cap_bn: 40.0, + pe_ttm: 14.0, + turnover_ratio: Some(2.2), + effective_turnover_ratio: Some(2.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, + }, + CandidateEligibility { + date, + symbol: "000002.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 mut portfolio = PortfolioState::new(1_000.0); + portfolio + .position_mut("000001.SZ") + .buy(date.pred_opt().expect("previous day"), 300, 10.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::TargetPortfolioSmart { + target_weights: BTreeMap::from([ + ("000001.SZ".to_string(), 0.0), + ("000002.SZ".to_string(), 0.5), + ]), + order_prices: Some(BTreeMap::from([ + ("000001.SZ".to_string(), 9.8), + ("000002.SZ".to_string(), 10.2), + ])), + valuation_prices: Some(BTreeMap::from([ + ("000001.SZ".to_string(), 10.0), + ("000002.SZ".to_string(), 20.0), + ])), + reason: "test_target_portfolio_smart".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("broker execution"); + + assert_eq!(report.order_events.len(), 2); + assert_eq!(report.fill_events.len(), 2); + assert_eq!(report.fill_events[0].symbol, "000001.SZ"); + assert_eq!(report.fill_events[0].side, fidc_core::OrderSide::Sell); + assert_eq!(report.fill_events[0].quantity, 300); + assert_eq!(report.fill_events[1].symbol, "000002.SZ"); + assert_eq!(report.fill_events[1].side, fidc_core::OrderSide::Buy); + assert_eq!(report.fill_events[1].quantity, 100); + assert_eq!(portfolio.position("000001.SZ").map(|pos| pos.quantity).unwrap_or(0), 0); + assert_eq!( + portfolio.position("000002.SZ").map(|pos| pos.quantity), + Some(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 index d336671..12607bf 100644 --- a/docs/rqalpha-gap-roadmap.md +++ b/docs/rqalpha-gap-roadmap.md @@ -14,7 +14,7 @@ current alignment pass. ### Phase 1: Strategy API parity -- [ ] `order_to` / target-shares style explicit order primitive +- [x] `order_to` / target-shares style explicit order primitive - [ ] `order_target_portfolio(_smart)` style public API surface - [ ] richer explicit order styles exposed to platform scripts @@ -57,4 +57,4 @@ current alignment pass. ## Current Step -Active implementation target: Phase 1, target-shares / `order_to` parity. +Active implementation target: Phase 1, batch target-portfolio smart semantics.