diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 25f374b..0e57ff4 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -399,6 +399,42 @@ where report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { match intent { + OrderIntent::Shares { + symbol, + quantity, + reason, + } => self.process_shares( + date, + portfolio, + data, + symbol, + *quantity, + next_order_id, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ), + OrderIntent::Lots { + symbol, + lots, + reason, + } => self.process_lots( + date, + portfolio, + data, + symbol, + *lots, + next_order_id, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ), OrderIntent::TargetValue { symbol, target_value, @@ -435,6 +471,42 @@ where commission_state, report, ), + OrderIntent::Percent { + symbol, + percent, + reason, + } => self.process_percent( + date, + portfolio, + data, + symbol, + *percent, + next_order_id, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ), + OrderIntent::TargetPercent { + symbol, + target_percent, + reason, + } => self.process_target_percent( + date, + portfolio, + data, + symbol, + *target_percent, + next_order_id, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ), } } @@ -1279,6 +1351,38 @@ where Ok(()) } + fn process_target_percent( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + symbol: &str, + target_percent: f64, + next_order_id: &mut u64, + 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_target_value( + date, + portfolio, + data, + symbol, + total_equity * target_percent.max(0.0), + next_order_id, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ) + } + fn process_value( &self, date: NaiveDate, @@ -1365,6 +1469,133 @@ where } } + fn process_percent( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + symbol: &str, + percent: f64, + next_order_id: &mut u64, + 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_value( + date, + portfolio, + data, + symbol, + total_equity * percent, + next_order_id, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ) + } + + fn process_shares( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + symbol: &str, + quantity: i32, + next_order_id: &mut u64, + 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 quantity == 0 { + return Ok(()); + } + if quantity > 0 { + let requested_qty = self.round_buy_quantity( + quantity as u32, + self.minimum_order_quantity(data, symbol), + self.order_step_size(data, symbol), + ); + self.process_buy( + date, + portfolio, + data, + symbol, + requested_qty, + Self::reserve_order_id(next_order_id), + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + None, + report, + ) + } else { + self.process_sell( + date, + portfolio, + data, + symbol, + quantity.unsigned_abs(), + Self::reserve_order_id(next_order_id), + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ) + } + } + + fn process_lots( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + symbol: &str, + lots: i32, + next_order_id: &mut u64, + 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_shares( + date, + portfolio, + data, + symbol, + signed_quantity, + next_order_id, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ) + } + fn maybe_expand_periodic_value_buy_quantity( &self, _date: NaiveDate, diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 0966355..c4252b5 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -84,6 +84,16 @@ impl StrategyDecision { #[derive(Debug, Clone)] pub enum OrderIntent { + Shares { + symbol: String, + quantity: i32, + reason: String, + }, + Lots { + symbol: String, + lots: i32, + reason: String, + }, TargetValue { symbol: String, target_value: f64, @@ -94,6 +104,16 @@ pub enum OrderIntent { value: f64, reason: String, }, + Percent { + symbol: String, + percent: f64, + reason: String, + }, + TargetPercent { + symbol: String, + target_percent: f64, + reason: String, + }, } #[derive(Debug, Clone)] diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 71dbde6..8802a39 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -108,6 +108,237 @@ fn broker_executes_explicit_order_value_buy() { assert!(portfolio.cash() < 1_000_000.0); } +#[test] +fn broker_executes_order_shares_and_order_lots() { + 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_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + let mut portfolio = PortfolioState::new(1_000_000.0); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![ + OrderIntent::Shares { + symbol: "000002.SZ".to_string(), + quantity: 250, + reason: "shares_buy".to_string(), + }, + OrderIntent::Lots { + symbol: "000002.SZ".to_string(), + lots: 3, + reason: "lots_buy".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].quantity, 200); + assert_eq!(report.fill_events[1].quantity, 300); + assert_eq!( + portfolio.position("000002.SZ").expect("position").quantity, + 500 + ); +} + +#[test] +fn broker_executes_order_percent_and_target_percent() { + 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_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + + 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::Percent { + symbol: "000002.SZ".to_string(), + percent: 0.10, + reason: "percent_buy".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("percent execution"); + assert_eq!(percent_report.fill_events.len(), 1); + assert_eq!(percent_report.fill_events[0].quantity, 10_000); + + let mut target_percent_portfolio = PortfolioState::new(1_000_000.0); + let target_percent_report = broker + .execute( + date, + &mut target_percent_portfolio, + &data, + &StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![OrderIntent::TargetPercent { + symbol: "000002.SZ".to_string(), + target_percent: 0.0505, + reason: "target_percent_buy".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("target percent execution"); + assert_eq!(target_percent_report.fill_events.len(), 1); + assert_eq!(target_percent_report.fill_events[0].quantity, 5_000); +} + #[test] fn broker_uses_day_open_price_for_open_auction_matching() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();