From 0813ce3ffbf91ad2828fd04dc7b0d0de6d37c355 Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 13 Jun 2026 21:09:38 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=9B=AE=E6=A0=87=E5=B8=82?= =?UTF-8?q?=E5=80=BC=E7=9B=98=E4=B8=AD=E4=BC=B0=E5=80=BC=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 178 ++++++++++++++++++++++++++++----- crates/fidc-core/src/data.rs | 5 +- 2 files changed, 155 insertions(+), 28 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 2e5bf2c..1fb171b 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -340,6 +340,59 @@ where data: &DataSet, symbol: &str, snapshot: &crate::data::DailyMarketSnapshot, + ) -> f64 { + self.value_order_sizing_price(date, data, symbol, snapshot, OrderSide::Buy) + } + + fn value_sell_sizing_price( + &self, + date: NaiveDate, + data: &DataSet, + symbol: &str, + snapshot: &crate::data::DailyMarketSnapshot, + ) -> f64 { + self.value_order_sizing_price(date, data, symbol, snapshot, OrderSide::Sell) + } + + fn target_value_valuation_price( + &self, + date: NaiveDate, + data: &DataSet, + symbol: &str, + snapshot: &crate::data::DailyMarketSnapshot, + ) -> f64 { + if self.execution_price_field == PriceField::Last { + let start_cursor = self + .runtime_intraday_start_time + .get() + .or(self.intraday_execution_start_time) + .map(|start_time| date.and_time(start_time)); + if let Some(price) = data + .execution_quotes_on(date, symbol) + .iter() + .filter(|quote| { + start_cursor + .map(|cursor| quote.timestamp >= cursor) + .unwrap_or(true) + }) + .find_map(|quote| { + (quote.last_price.is_finite() && quote.last_price > 0.0) + .then_some(quote.last_price) + }) + { + return price; + } + } + self.sizing_price(snapshot) + } + + fn value_order_sizing_price( + &self, + date: NaiveDate, + data: &DataSet, + symbol: &str, + snapshot: &crate::data::DailyMarketSnapshot, + side: OrderSide, ) -> f64 { let start_cursor = self .runtime_intraday_start_time @@ -353,11 +406,13 @@ where .map(|cursor| quote.timestamp >= cursor) .unwrap_or(true) }) - .next() - .and_then(|quote| match self.execution_price_field { - PriceField::Last => (quote.last_price.is_finite() && quote.last_price > 0.0) - .then_some(quote.last_price), - _ => quote.buy_price(), + .find_map(|quote| { + self.select_quote_reference_price( + snapshot, + quote, + side, + self.matching_type_for_algo_request(None), + ) }) .unwrap_or_else(|| self.sizing_price(snapshot)) } @@ -2677,9 +2732,8 @@ where commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { - let price = data + let snapshot = data .market(date, symbol) - .map(|snapshot| self.sizing_price(snapshot)) .ok_or_else(|| BacktestError::MissingPrice { date, symbol: symbol.to_string(), @@ -2689,20 +2743,27 @@ where .position(symbol) .map(|pos| pos.quantity) .unwrap_or(0); - let current_value = price * current_qty as f64; - 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 { + if target_value <= f64::EPSILON { + if current_qty == 0 { + report.order_events.push(OrderEvent { + date, + order_id: None, + symbol: symbol.to_string(), + side: OrderSide::Sell, + requested_quantity: 0, + filled_quantity: 0, + status: OrderStatus::Filled, + reason: format!("{reason}: already at target value"), + }); + return Ok(()); + } self.process_sell( date, portfolio, data, symbol, - current_qty - target_qty, + current_qty, self.reserve_order_id(), reason, intraday_turnover, @@ -2715,27 +2776,28 @@ where None, report, )?; - } else if target_qty > current_qty { - self.process_buy( + return Ok(()); + } + + let valuation_price = self.target_value_valuation_price(date, data, symbol, snapshot); + let current_value = valuation_price * current_qty as f64; + let cash_delta = target_value.max(0.0) - current_value; + + if cash_delta.abs() > f64::EPSILON { + self.process_value( date, portfolio, data, symbol, - target_qty - current_qty, - self.reserve_order_id(), + cash_delta, reason, intraday_turnover, execution_cursors, global_execution_cursor, commission_state, - None, - None, - false, - true, - None, report, )?; - } else if (current_value - target_value).abs() <= f64::EPSILON { + } else { report.order_events.push(OrderEvent { date, order_id: None, @@ -3142,7 +3204,7 @@ where report, ) } else { - let price = self.sizing_price(snapshot); + let price = self.value_sell_sizing_price(date, data, symbol, snapshot); let requested_qty = self.round_buy_quantity( ((value.abs()) / price).floor() as u32, self.minimum_order_quantity(data, symbol), @@ -4887,9 +4949,11 @@ mod tests { use super::{BrokerSimulator, MatchingType}; use crate::cost::ChinaAShareCostModel; use crate::data::{ - CandidateEligibility, DailyMarketSnapshot, IntradayExecutionQuote, PriceField, + BenchmarkSnapshot, CandidateEligibility, DailyMarketSnapshot, DataSet, + IntradayExecutionQuote, PriceField, }; use crate::events::OrderSide; + use crate::instrument::Instrument; use crate::rules::ChinaEquityRuleHooks; fn limit_test_snapshot() -> DailyMarketSnapshot { @@ -4951,6 +5015,30 @@ mod tests { } } + fn limit_test_instrument() -> Instrument { + Instrument { + symbol: "000001.SZ".to_string(), + name: "PingAn".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + } + } + + fn limit_test_benchmark() -> BenchmarkSnapshot { + let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); + BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1000.0, + prev_close: 1000.0, + volume: 1_000_000, + } + } + #[test] fn next_tick_last_without_volume_or_liquidity_limit_does_not_cap_quote_quantity() { let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) @@ -4976,6 +5064,42 @@ mod tests { assert!(liquidity_limited.quote_quantity_limited(MatchingType::NextTickLast)); } + #[test] + fn target_value_sizing_uses_intraday_tick_instead_of_daily_snapshot() { + let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Last, + ) + .with_intraday_execution_start_time(date.and_hms_opt(9, 33, 0).unwrap().time()); + let mut snapshot = limit_test_snapshot(); + snapshot.last_price = 10.0; + snapshot.close = 10.0; + let mut quote = limit_test_quote(11.0, 10.99, 11.01); + quote.timestamp = date.and_hms_opt(9, 34, 0).expect("valid timestamp"); + let data = DataSet::from_components_with_actions_and_quotes( + vec![limit_test_instrument()], + vec![snapshot.clone()], + Vec::new(), + vec![limit_test_candidate(true, true)], + vec![limit_test_benchmark()], + Vec::new(), + vec![quote], + ) + .expect("valid dataset"); + let snapshot = data.market(date, "000001.SZ").expect("market snapshot"); + + assert_eq!( + broker.target_value_valuation_price(date, &data, "000001.SZ", snapshot), + 11.0 + ); + assert_eq!( + broker.value_sell_sizing_price(date, &data, "000001.SZ", snapshot), + 11.0 + ); + } + #[test] fn instantaneous_twap_without_limits_does_not_cap_quote_quantity() { let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 2a7e43a..ecadbf1 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -503,7 +503,10 @@ impl SymbolPriceSeries { let closes = sorted.iter().map(|row| row.close).collect::>(); let prev_closes = sorted.iter().map(|row| row.prev_close).collect::>(); let last_prices = sorted.iter().map(|row| row.last_price).collect::>(); - let volumes = sorted.iter().map(|row| row.volume as f64).collect::>(); + let volumes = sorted + .iter() + .map(|row| row.volume as f64) + .collect::>(); let open_prefix = prefix_sums(&opens); let close_prefix = prefix_sums(&closes); let prev_close_prefix = prefix_sums(&prev_closes);