From 081686185a9be0af59573a45fd9f626cdbd1b047 Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 22 Apr 2026 21:39:26 -0700 Subject: [PATCH] Add matching and slippage execution options --- crates/fidc-core/src/broker.rs | 136 ++++++++++-- crates/fidc-core/src/lib.rs | 6 +- crates/fidc-core/src/strategy_ai.rs | 16 +- crates/fidc-core/tests/explicit_order_flow.rs | 204 +++++++++++++++++- 4 files changed, 342 insertions(+), 20 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index e217d6d..2f14594 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -25,11 +25,26 @@ struct ExecutionFill { next_cursor: NaiveDateTime, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MatchingType { + CurrentBarClose, + NextBarOpen, + NextTickLast, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum SlippageModel { + None, + PriceRatio(f64), + TickSize(f64), +} + pub struct BrokerSimulator { cost_model: C, rules: R, board_lot_size: u32, execution_price_field: PriceField, + slippage_model: SlippageModel, volume_percent: f64, volume_limit: bool, inactive_limit: bool, @@ -44,6 +59,7 @@ impl BrokerSimulator { rules, board_lot_size: 100, execution_price_field: PriceField::Open, + slippage_model: SlippageModel::None, volume_percent: 0.25, volume_limit: true, inactive_limit: true, @@ -62,6 +78,7 @@ impl BrokerSimulator { rules, board_lot_size: 100, execution_price_field, + slippage_model: SlippageModel::None, volume_percent: 0.25, volume_limit: true, inactive_limit: true, @@ -94,6 +111,11 @@ impl BrokerSimulator { self.intraday_execution_start_time = Some(start_time); self } + + pub fn with_slippage_model(mut self, slippage_model: SlippageModel) -> Self { + self.slippage_model = slippage_model; + self + } } impl BrokerSimulator @@ -118,16 +140,103 @@ where snapshot: &crate::data::DailyMarketSnapshot, side: OrderSide, ) -> f64 { - if self.execution_price_field == PriceField::Last + let raw_price = if self.execution_price_field == PriceField::Last && self.intraday_execution_start_time.is_some() { let _ = side; - return snapshot.price(PriceField::Last); + snapshot.price(PriceField::Last) + } else { + match side { + OrderSide::Buy => self.buy_price(snapshot), + OrderSide::Sell => self.sell_price(snapshot), + } + }; + + self.apply_slippage(snapshot, side, raw_price) + } + + fn apply_slippage( + &self, + snapshot: &crate::data::DailyMarketSnapshot, + side: OrderSide, + raw_price: f64, + ) -> f64 { + if !raw_price.is_finite() || raw_price <= 0.0 { + return raw_price; } + let adjusted = match self.slippage_model { + SlippageModel::None => raw_price, + SlippageModel::PriceRatio(ratio) => { + let ratio = ratio.max(0.0); + match side { + OrderSide::Buy => raw_price * (1.0 + ratio), + OrderSide::Sell => raw_price * (1.0 - ratio), + } + } + SlippageModel::TickSize(ticks) => { + let tick = snapshot.effective_price_tick(); + let ticks = ticks.max(0.0); + match side { + OrderSide::Buy => raw_price + tick * ticks, + OrderSide::Sell => raw_price - tick * ticks, + } + } + }; + + self.clamp_execution_price(snapshot, side, adjusted) + } + + fn clamp_execution_price( + &self, + snapshot: &crate::data::DailyMarketSnapshot, + side: OrderSide, + adjusted_price: f64, + ) -> f64 { + if !adjusted_price.is_finite() { + return adjusted_price; + } + + let mut bounded = adjusted_price.max(snapshot.effective_price_tick()); match side { - OrderSide::Buy => self.buy_price(snapshot), - OrderSide::Sell => self.sell_price(snapshot), + OrderSide::Buy => { + if snapshot.upper_limit.is_finite() && snapshot.upper_limit > 0.0 { + bounded = bounded.min(snapshot.upper_limit); + } + } + OrderSide::Sell => { + if snapshot.lower_limit.is_finite() && snapshot.lower_limit > 0.0 { + bounded = bounded.max(snapshot.lower_limit); + } + } + } + bounded + } + + fn quote_execution_price( + &self, + snapshot: &crate::data::DailyMarketSnapshot, + side: OrderSide, + raw_price: f64, + ) -> f64 { + self.apply_slippage(snapshot, side, raw_price) + } + + fn select_quote_reference_price( + &self, + snapshot: &crate::data::DailyMarketSnapshot, + quote: &IntradayExecutionQuote, + side: OrderSide, + ) -> Option { + let raw_price = match side { + OrderSide::Buy => quote.buy_price(), + OrderSide::Sell => quote.sell_price(), + }?; + let execution_price = self.quote_execution_price(snapshot, side, raw_price); + if execution_price.is_finite() && execution_price > 0.0 { + Some(execution_price) + } else { + None } } @@ -656,6 +765,7 @@ where .map(|start_time| date.and_time(start_time)); let quotes = data.execution_quotes_on(date, symbol); if let Some(estimated) = self.select_buy_sizing_fill( + snapshot, quotes, start_cursor, max_requested_qty, @@ -699,6 +809,7 @@ where fn select_buy_sizing_fill( &self, + snapshot: &crate::data::DailyMarketSnapshot, quotes: &[IntradayExecutionQuote], start_cursor: Option, requested_qty: u32, @@ -729,9 +840,13 @@ where if quote.volume_delta == 0 { continue; } - let Some(quote_price) = fallback_quote_price else { + let Some(raw_quote_price) = fallback_quote_price else { continue; }; + let quote_price = self.quote_execution_price(snapshot, OrderSide::Buy, raw_quote_price); + if !quote_price.is_finite() || quote_price <= 0.0 { + continue; + } let available_qty = quote .ask1_volume .saturating_mul(lot as u64) @@ -1110,6 +1225,7 @@ where let quotes = data.execution_quotes_on(date, symbol); if let Some(fill) = self.select_execution_fill( + snapshot, quotes, side, start_cursor, @@ -1126,6 +1242,7 @@ where fn select_execution_fill( &self, + snapshot: &crate::data::DailyMarketSnapshot, quotes: &[IntradayExecutionQuote], side: OrderSide, start_cursor: Option, @@ -1154,16 +1271,9 @@ where if quote.volume_delta == 0 { continue; } - let quote_price = match side { - OrderSide::Buy => quote.buy_price(), - OrderSide::Sell => quote.sell_price(), - }; - let Some(quote_price) = quote_price else { + let Some(quote_price) = self.select_quote_reference_price(snapshot, quote, side) else { continue; }; - if !quote_price.is_finite() || quote_price <= 0.0 { - continue; - } let top_level_liquidity = match side { OrderSide::Buy => quote.ask1_volume, OrderSide::Sell => quote.bid1_volume, diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index d05bb42..de0ed9d 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -6,14 +6,14 @@ pub mod engine; pub mod events; pub mod instrument; pub mod metrics; -pub mod portfolio; pub mod platform_expr_strategy; +pub mod portfolio; pub mod rules; pub mod strategy; pub mod strategy_ai; pub mod universe; -pub use broker::{BrokerExecutionReport, BrokerSimulator}; +pub use broker::{BrokerExecutionReport, BrokerSimulator, MatchingType, SlippageModel}; pub use calendar::TradingCalendar; pub use cost::{ChinaAShareCostModel, CostModel, TradingCost}; pub use data::{ @@ -28,8 +28,8 @@ pub use engine::{ pub use events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent}; pub use instrument::Instrument; pub use metrics::{BacktestMetrics, compute_backtest_metrics}; -pub use portfolio::{CashReceivable, HoldingSummary, PortfolioState, Position}; pub use platform_expr_strategy::{PlatformExprStrategy, PlatformExprStrategyConfig}; +pub use portfolio::{CashReceivable, HoldingSummary, PortfolioState, Position}; pub use rules::{ChinaEquityRuleHooks, EquityRuleHooks, RuleCheck}; pub use strategy::{ CnSmallCapRotationConfig, CnSmallCapRotationStrategy, JqMicroCapConfig, JqMicroCapStrategy, diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 73624a1..49bc1a7 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -106,6 +106,10 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { title: "filter.stock_expr / risk.stop_loss / risk.take_profit / allocation.buy_scale".to_string(), detail: "表达式型规则,支持多条组合。stop_loss/take_profit 多条按 OR 组合,filter.stock_expr 多条按 AND 组合。".to_string(), }, + ManualSection { + title: "execution.matching_type / execution.slippage".to_string(), + detail: "设置撮合模式和滑点。支持 execution.matching_type(\"next_tick_last\" | \"current_bar_close\" | \"next_bar_open\"),以及 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 0.001) / execution.slippage(\"tick_size\", 1)。".to_string(), + }, ManualSection { title: "when / unless / else".to_string(), detail: "条件块支持按日期、指数、仓位等动态切换规则。".to_string(), @@ -190,6 +194,10 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { title: "涨停触达后满仓,否则半仓".to_string(), code: "allocation.buy_scale(touched_upper_limit ? 1.0 : 0.5)".to_string(), }, + ManualExample { + title: "next tick 撮合 + tick 滑点".to_string(), + code: "execution.matching_type(\"next_tick_last\")\nexecution.slippage(\"tick_size\", 1)".to_string(), + }, ], } } @@ -259,7 +267,10 @@ pub fn render_manual_markdown(manual: &StrategyAiManual) -> String { } out.push_str("## 示例\n"); for example in &manual.examples { - out.push_str(&format!("### {}\n```txt\n{}\n```\n\n", example.title, example.code)); + out.push_str(&format!( + "### {}\n```txt\n{}\n```\n\n", + example.title, example.code + )); } out } @@ -313,8 +324,7 @@ pub fn build_optimization_prompt( } prompt.push_str("结果摘要 JSON:\n```json\n"); prompt.push_str( - &serde_json::to_string_pretty(&request.result_summary) - .unwrap_or_else(|_| "{}".to_string()), + &serde_json::to_string_pretty(&request.result_summary).unwrap_or_else(|_| "{}".to_string()), ); prompt.push_str("\n```\n\n"); prompt.push_str("详细手册如下:\n\n"); diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 46d0b46..0d1ddad 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -2,7 +2,7 @@ use chrono::NaiveDate; use fidc_core::{ BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, Instrument, - OrderIntent, PortfolioState, PriceField, StrategyDecision, + OrderIntent, PortfolioState, PriceField, SlippageModel, StrategyDecision, }; use std::collections::{BTreeMap, BTreeSet}; @@ -50,6 +50,7 @@ fn broker_executes_explicit_order_value_buy() { pe_ttm: 15.0, turnover_ratio: Some(2.0), effective_turnover_ratio: Some(1.8), + extra_factors: BTreeMap::new(), }], vec![CandidateEligibility { date, @@ -106,6 +107,205 @@ fn broker_executes_explicit_order_value_buy() { assert!(portfolio.cash() < 1_000_000.0); } +#[test] +fn broker_applies_price_ratio_slippage_on_snapshot_fills() { + 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, + ) + .with_slippage_model(SlippageModel::PriceRatio(0.01)); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![OrderIntent::Value { + symbol: "000002.SZ".to_string(), + value: 100_000.0, + reason: "price_ratio_slippage".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("broker execution"); + + assert_eq!(report.fill_events.len(), 1); + assert!((report.fill_events[0].price - 10.1).abs() < 1e-9); +} + +#[test] +fn broker_applies_tick_size_slippage_on_intraday_last_fills() { + 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::Last, + ) + .with_intraday_execution_start_time(chrono::NaiveTime::from_hms_opt(10, 18, 0).unwrap()) + .with_slippage_model(SlippageModel::TickSize(2.0)); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![OrderIntent::Value { + symbol: "000002.SZ".to_string(), + value: 100_000.0, + reason: "tick_slippage".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("broker execution"); + + assert_eq!(report.fill_events.len(), 1); + assert!((report.fill_events[0].price - 10.02).abs() < 1e-9); +} + #[test] fn broker_uses_instrument_round_lot_for_buy_sizing() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); @@ -150,6 +350,7 @@ fn broker_uses_instrument_round_lot_for_buy_sizing() { pe_ttm: 20.0, turnover_ratio: Some(2.0), effective_turnover_ratio: Some(1.8), + extra_factors: BTreeMap::new(), }], vec![CandidateEligibility { date, @@ -256,6 +457,7 @@ fn same_day_sell_then_rebuy_reinserts_position_at_end() { pe_ttm: 15.0, turnover_ratio: Some(2.0), effective_turnover_ratio: Some(1.8), + extra_factors: BTreeMap::new(), }) .collect::>(); let candidates = symbols