diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 078194e..146fff2 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -40,6 +40,7 @@ struct TargetConstraint { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MatchingType { + OpenAuction, CurrentBarClose, NextBarOpen, NextTickLast, @@ -1270,6 +1271,7 @@ where date, symbol: position.symbol.clone(), field: match field { + PriceField::DayOpen => "day_open", PriceField::Open => "open", PriceField::Close => "close", PriceField::Last => "last", @@ -1611,6 +1613,7 @@ where fn price_field_name(field: PriceField) -> &'static str { match field { + PriceField::DayOpen => "day_open", PriceField::Open => "open", PriceField::Close => "close", PriceField::Last => "last", diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 056a0e4..b323b64 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -81,6 +81,7 @@ pub enum DataSetError { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PriceField { + DayOpen, Open, Close, Last, @@ -115,6 +116,7 @@ pub struct DailyMarketSnapshot { impl DailyMarketSnapshot { pub fn price(&self, field: PriceField) -> f64 { match field { + PriceField::DayOpen => self.day_open, PriceField::Open => self.open, PriceField::Close => self.close, PriceField::Last => self.last_price, @@ -488,6 +490,7 @@ impl SymbolPriceSeries { fn values_for(&self, field: PriceField) -> &[f64] { match field { + PriceField::DayOpen => &self.opens, PriceField::Open => &self.opens, PriceField::Close => &self.closes, PriceField::Last => &self.last_prices, @@ -504,6 +507,7 @@ impl SymbolPriceSeries { fn prefix_for(&self, field: PriceField) -> &[f64] { match field { + PriceField::DayOpen => &self.open_prefix, PriceField::Open => &self.open_prefix, PriceField::Close => &self.close_prefix, PriceField::Last => &self.last_prefix, @@ -561,7 +565,7 @@ impl BenchmarkPriceSeries { } let start = end - lookback; let prefix = match field { - PriceField::Open => &self.open_prefix, + PriceField::DayOpen | PriceField::Open => &self.open_prefix, PriceField::Close | PriceField::Last => &self.close_prefix, }; let sum = prefix[end] - prefix[start]; @@ -959,6 +963,9 @@ impl DataSet { .market_series_by_symbol .get(symbol) .and_then(|series| series.decision_volume_rolling_average(date, lookback)), + "day_open" | "dayopen" => { + self.market_moving_average(date, symbol, lookback, PriceField::DayOpen) + } "open" => self.market_moving_average(date, symbol, lookback, PriceField::Open), "last" | "last_price" => { self.market_moving_average(date, symbol, lookback, PriceField::Last) diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index 750a396..98b2937 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -264,6 +264,7 @@ impl PortfolioState { let price = data.price(date, &position.symbol, field).ok_or_else(|| { DataSetError::MissingSnapshot { kind: match field { + PriceField::DayOpen => "day open price", PriceField::Open => "open price", PriceField::Close => "close price", PriceField::Last => "last price", diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 443463e..a373040 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -108,7 +108,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { }, 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(), + detail: "设置撮合模式和滑点。支持 execution.matching_type(\"next_tick_last\" | \"current_bar_close\" | \"next_bar_open\" | \"open_auction\"),其中 open_auction 使用当日集合竞价开盘价 day_open 进行撮合;滑点支持 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 0.001) / execution.slippage(\"tick_size\", 1)。".to_string(), }, ManualSection { title: "when / unless / else".to_string(), diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index c726b2c..deb5f49 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -107,6 +107,104 @@ fn broker_executes_explicit_order_value_buy() { assert!(portfolio.cash() < 1_000_000.0); } +#[test] +fn broker_uses_day_open_price_for_open_auction_matching() { + 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 09:25:00".to_string()), + day_open: 9.8, + open: 10.0, + high: 10.1, + low: 9.7, + 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("open_auction".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::DayOpen, + ); + + 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: 98_000.0, + reason: "open_auction_match".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("broker execution"); + + assert_eq!(report.fill_events.len(), 1); + assert!((report.fill_events[0].price - 9.8).abs() < 1e-9); +} + #[test] fn broker_applies_price_ratio_slippage_on_snapshot_fills() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();