From 8906490a40f948454a1dbcf8ed86e4930ec9ce07 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 23 Apr 2026 03:28:14 -0700 Subject: [PATCH] Expand limit order intent coverage --- crates/fidc-core/src/broker.rs | 364 ++++++++++++++++++ crates/fidc-core/src/strategy.rs | 30 ++ crates/fidc-core/tests/explicit_order_flow.rs | 61 ++- 3 files changed, 454 insertions(+), 1 deletion(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index af8b297..5560c05 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -538,6 +538,25 @@ where commission_state, report, ), + OrderIntent::LimitLots { + symbol, + lots, + limit_price, + reason, + } => self.process_limit_lots( + date, + portfolio, + data, + symbol, + *lots, + *limit_price, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ), OrderIntent::TargetValue { symbol, target_value, @@ -555,6 +574,25 @@ where commission_state, report, ), + OrderIntent::LimitTargetValue { + symbol, + target_value, + limit_price, + reason, + } => self.process_limit_target_value( + date, + portfolio, + data, + symbol, + *target_value, + *limit_price, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ), OrderIntent::Value { symbol, value, @@ -572,6 +610,25 @@ where commission_state, report, ), + OrderIntent::LimitValue { + symbol, + value, + limit_price, + reason, + } => self.process_limit_value( + date, + portfolio, + data, + symbol, + *value, + *limit_price, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ), OrderIntent::Percent { symbol, percent, @@ -589,6 +646,25 @@ where commission_state, report, ), + OrderIntent::LimitPercent { + symbol, + percent, + limit_price, + reason, + } => self.process_limit_percent( + date, + portfolio, + data, + symbol, + *percent, + *limit_price, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ), OrderIntent::TargetPercent { symbol, target_percent, @@ -606,6 +682,25 @@ where commission_state, report, ), + OrderIntent::LimitTargetPercent { + symbol, + target_percent, + limit_price, + reason, + } => self.process_limit_target_percent( + date, + portfolio, + data, + symbol, + *target_percent, + *limit_price, + 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(()) @@ -723,6 +818,44 @@ where } } + fn process_limit_lots( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + symbol: &str, + lots: 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 round_lot = self.round_lot(data, symbol); + let requested_quantity = lots.saturating_abs() as u32 * round_lot; + let signed_quantity = if lots >= 0 { + requested_quantity as i32 + } else { + -(requested_quantity as i32) + }; + self.process_limit_shares( + date, + portfolio, + data, + symbol, + signed_quantity, + limit_price, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ) + } + fn reserve_order_id(&self) -> u64 { let order_id = self.next_order_id.get(); self.next_order_id.set(order_id.saturating_add(1)); @@ -1907,6 +2040,81 @@ where Ok(()) } + fn process_limit_target_value( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + symbol: &str, + target_value: f64, + 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 price = data + .market(date, symbol) + .map(|snapshot| self.sizing_price(snapshot)) + .ok_or_else(|| BacktestError::MissingPrice { + date, + symbol: symbol.to_string(), + field: price_field_name(self.execution_price_field), + })?; + let current_qty = portfolio + .position(symbol) + .map(|pos| pos.quantity) + .unwrap_or(0); + let target_qty = self.round_buy_quantity( + ((target_value.max(0.0)) / price).floor() as u32, + self.minimum_order_quantity(data, symbol), + self.order_step_size(data, symbol), + ); + + if current_qty > target_qty { + self.process_sell( + date, + portfolio, + data, + symbol, + current_qty - target_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 { + self.process_buy( + date, + portfolio, + data, + symbol, + target_qty - current_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, @@ -1937,6 +2145,38 @@ where ) } + fn process_limit_target_percent( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + symbol: &str, + target_percent: f64, + 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 total_equity = self.rebalance_total_equity_at(date, portfolio, data)?; + self.process_limit_target_value( + date, + portfolio, + data, + symbol, + total_equity * target_percent.max(0.0), + limit_price, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ) + } + fn process_value( &self, date: NaiveDate, @@ -2028,6 +2268,98 @@ where } } + fn process_limit_value( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + symbol: &str, + value: f64, + 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> { + if value.abs() <= f64::EPSILON { + return Ok(()); + } + let snapshot = data + .market(date, symbol) + .ok_or_else(|| BacktestError::MissingPrice { + date, + symbol: symbol.to_string(), + field: price_field_name(self.execution_price_field), + })?; + if value > 0.0 { + let round_lot = self.round_lot(data, symbol); + let minimum_order_quantity = self.minimum_order_quantity(data, symbol); + let order_step_size = self.order_step_size(data, symbol); + let price = self.sizing_price(snapshot); + let snapshot_requested_qty = self.round_buy_quantity( + ((value.abs()) / price).floor() as u32, + minimum_order_quantity, + order_step_size, + ); + let requested_qty = self.maybe_expand_periodic_value_buy_quantity( + date, + portfolio, + data, + symbol, + snapshot_requested_qty, + round_lot, + value.abs(), + reason, + execution_cursors, + *global_execution_cursor, + ); + self.process_buy( + date, + portfolio, + data, + symbol, + requested_qty, + self.reserve_order_id(), + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + Some(value.abs()), + Some(limit_price), + true, + true, + report, + ) + } else { + let price = self.sizing_price(snapshot); + let requested_qty = self.round_buy_quantity( + ((value.abs()) / price).floor() as u32, + self.minimum_order_quantity(data, symbol), + self.order_step_size(data, symbol), + ); + self.process_sell( + date, + portfolio, + data, + symbol, + requested_qty, + self.reserve_order_id(), + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + Some(limit_price), + true, + true, + report, + ) + } + } + fn process_percent( &self, date: NaiveDate, @@ -2058,6 +2390,38 @@ where ) } + fn process_limit_percent( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + symbol: &str, + percent: f64, + 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 total_equity = self.rebalance_total_equity_at(date, portfolio, data)?; + self.process_limit_value( + date, + portfolio, + data, + symbol, + total_equity * percent, + limit_price, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ) + } + fn process_shares( &self, date: NaiveDate, diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index b7afbe2..c6d05e6 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -100,26 +100,56 @@ pub enum OrderIntent { lots: i32, reason: String, }, + LimitLots { + symbol: String, + lots: i32, + limit_price: f64, + reason: String, + }, TargetValue { symbol: String, target_value: f64, reason: String, }, + LimitTargetValue { + symbol: String, + target_value: f64, + limit_price: f64, + reason: String, + }, Value { symbol: String, value: f64, reason: String, }, + LimitValue { + symbol: String, + value: f64, + limit_price: f64, + reason: String, + }, Percent { symbol: String, percent: f64, reason: String, }, + LimitPercent { + symbol: String, + percent: f64, + limit_price: f64, + reason: String, + }, TargetPercent { symbol: String, target_percent: f64, reason: String, }, + LimitTargetPercent { + symbol: String, + target_percent: f64, + limit_price: f64, + 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 75817ef..da96a0d 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -2805,7 +2805,7 @@ fn broker_keeps_limit_buy_open_until_price_becomes_marketable() { #[test] fn broker_uses_limit_price_slippage_for_limit_orders() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); - let data = two_day_limit_order_data(10.0, 10.0); + let data = two_day_limit_order_data(10.0, 10.2); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), @@ -2839,6 +2839,65 @@ fn broker_uses_limit_price_slippage_for_limit_orders() { assert!((report.fill_events[0].price - 10.1).abs() < 1e-9); } +#[test] +fn broker_executes_limit_value_and_limit_percent_intents() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let data = two_day_limit_order_data(10.0, 10.0); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + + let mut value_portfolio = PortfolioState::new(1_000_000.0); + let value_report = broker + .execute( + date, + &mut value_portfolio, + &data, + &StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![OrderIntent::LimitValue { + symbol: "000002.SZ".to_string(), + value: 20_000.0, + limit_price: 10.1, + reason: "limit_value".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("broker execution"); + assert_eq!(value_report.fill_events.len(), 1); + assert!(value_portfolio.position("000002.SZ").is_some()); + + let mut percent_portfolio = PortfolioState::new(1_000_000.0); + let percent_report = broker + .execute( + date, + &mut percent_portfolio, + &data, + &StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![OrderIntent::LimitPercent { + symbol: "000002.SZ".to_string(), + percent: 0.02, + limit_price: 10.1, + reason: "limit_percent".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("broker execution"); + assert_eq!(percent_report.fill_events.len(), 1); + assert!(percent_portfolio.position("000002.SZ").is_some()); +} + #[test] fn broker_cancels_open_order_by_order_id() { let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();