From ac308c8d688a33897f99aef52bf6caadc88daf92 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 23 Apr 2026 07:36:20 -0700 Subject: [PATCH] Add algo-order platform actions --- crates/fidc-core/src/broker.rs | 300 ++++++++++++++-- crates/fidc-core/src/lib.rs | 2 +- .../fidc-core/src/platform_expr_strategy.rs | 265 ++++++++++++++- crates/fidc-core/src/strategy.rs | 22 ++ crates/fidc-core/src/strategy_ai.rs | 2 +- crates/fidc-core/tests/explicit_order_flow.rs | 320 +++++++++++++++++- docs/rqalpha-gap-roadmap.md | 4 +- 7 files changed, 883 insertions(+), 32 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index cdd203c..42e7122 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -12,7 +12,7 @@ use crate::events::{ }; use crate::portfolio::PortfolioState; use crate::rules::EquityRuleHooks; -use crate::strategy::{OpenOrderView, OrderIntent, StrategyDecision}; +use crate::strategy::{AlgoOrderStyle, OpenOrderView, OrderIntent, StrategyDecision}; #[derive(Debug, Default)] pub struct BrokerExecutionReport { @@ -73,6 +73,7 @@ pub enum MatchingType { NextTickBestCounterparty, CounterpartyOffer, Vwap, + Twap, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -83,6 +84,19 @@ pub enum SlippageModel { LimitPrice, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AlgoExecutionStyle { + Vwap, + Twap, +} + +#[derive(Debug, Clone, Copy)] +struct AlgoExecutionRequest { + style: AlgoExecutionStyle, + start_time: Option, + end_time: Option, +} + pub struct BrokerSimulator { cost_model: C, rules: R, @@ -306,13 +320,25 @@ where self.apply_slippage(snapshot, side, raw_price) } + fn matching_type_for_algo_request( + &self, + algo_request: Option<&AlgoExecutionRequest>, + ) -> MatchingType { + match algo_request.map(|request| request.style) { + Some(AlgoExecutionStyle::Vwap) => MatchingType::Vwap, + Some(AlgoExecutionStyle::Twap) => MatchingType::Twap, + None => self.matching_type, + } + } + fn select_quote_reference_price( &self, snapshot: &crate::data::DailyMarketSnapshot, quote: &IntradayExecutionQuote, side: OrderSide, + matching_type: MatchingType, ) -> Option { - let raw_price = match self.matching_type { + let raw_price = match matching_type { MatchingType::NextTickBestOwn => match side { OrderSide::Buy => { if quote.bid1.is_finite() && quote.bid1 > 0.0 { @@ -343,7 +369,7 @@ where OrderSide::Sell => quote.sell_price(), } } - MatchingType::NextTickLast | MatchingType::Vwap => { + MatchingType::NextTickLast | MatchingType::Vwap | MatchingType::Twap => { if quote.last_price.is_finite() && quote.last_price > 0.0 { Some(quote.last_price) } else { @@ -452,6 +478,7 @@ where None, false, true, + None, &mut report, )?; } @@ -481,6 +508,7 @@ where None, false, true, + None, &mut report, )?; } @@ -756,6 +784,52 @@ where commission_state, report, ), + OrderIntent::AlgoValue { + symbol, + value, + style, + start_time, + end_time, + reason, + } => self.process_algo_value( + date, + portfolio, + data, + symbol, + *value, + *style, + *start_time, + *end_time, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ), + OrderIntent::AlgoPercent { + symbol, + percent, + style, + start_time, + end_time, + reason, + } => self.process_algo_percent( + date, + portfolio, + data, + symbol, + *percent, + *style, + *start_time, + *end_time, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ), OrderIntent::TargetPortfolioSmart { target_weights, order_prices, @@ -893,6 +967,7 @@ where Some(limit_price), true, emit_creation_events, + None, report, ) } else { @@ -911,6 +986,7 @@ where Some(limit_price), true, emit_creation_events, + None, report, ) } @@ -1817,6 +1893,7 @@ where limit_price: Option, allow_pending_limit: bool, emit_creation_events: bool, + algo_request: Option<&AlgoExecutionRequest>, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { let snapshot = data.require_market(date, symbol)?; @@ -2046,6 +2123,7 @@ where None, None, None, + algo_request, limit_price, ); let (filled_qty, execution_legs) = if let Some(fill) = fill { @@ -2348,6 +2426,7 @@ where None, false, true, + None, report, )?; } else if target_qty > current_qty { @@ -2367,6 +2446,7 @@ where None, false, true, + None, report, )?; } else if (current_value - target_value).abs() <= f64::EPSILON { @@ -2435,6 +2515,7 @@ where None, false, true, + None, report, )?; } @@ -2461,6 +2542,7 @@ where None, false, true, + None, report, )?; } @@ -2533,6 +2615,7 @@ where Some(limit_price), true, true, + None, report, )?; } else if target_qty > current_qty { @@ -2552,6 +2635,7 @@ where Some(limit_price), true, true, + None, report, )?; } @@ -2606,6 +2690,7 @@ where Some(limit_price), true, true, + None, report, )?; } @@ -2632,6 +2717,7 @@ where Some(limit_price), true, true, + None, report, )?; } @@ -2764,6 +2850,7 @@ where None, false, true, + None, report, ) } else { @@ -2788,6 +2875,7 @@ where None, false, true, + None, report, ) } @@ -2856,6 +2944,7 @@ where Some(limit_price), true, true, + None, report, ) } else { @@ -2880,6 +2969,7 @@ where Some(limit_price), true, true, + None, report, ) } @@ -2947,6 +3037,146 @@ where ) } + fn process_algo_value( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + symbol: &str, + value: f64, + style: AlgoOrderStyle, + start_time: Option, + end_time: Option, + 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), + })?; + let algo_request = AlgoExecutionRequest { + style: match style { + AlgoOrderStyle::Vwap => AlgoExecutionStyle::Vwap, + AlgoOrderStyle::Twap => AlgoExecutionStyle::Twap, + }, + start_time, + end_time, + }; + 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()), + None, + false, + true, + Some(&algo_request), + 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, + None, + false, + true, + Some(&algo_request), + report, + ) + } + } + + fn process_algo_percent( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + symbol: &str, + percent: f64, + style: AlgoOrderStyle, + start_time: Option, + end_time: Option, + 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_algo_value( + date, + portfolio, + data, + symbol, + total_equity * percent, + style, + start_time, + end_time, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ) + } + fn process_shares( &self, date: NaiveDate, @@ -2986,6 +3216,7 @@ where None, false, true, + None, report, ) } else { @@ -3004,6 +3235,7 @@ where None, false, true, + None, report, ) } @@ -3078,6 +3310,7 @@ where limit_price: Option, allow_pending_limit: bool, emit_creation_events: bool, + algo_request: Option<&AlgoExecutionRequest>, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { let snapshot = data.require_market(date, symbol)?; @@ -3232,6 +3465,7 @@ where None, Some(portfolio.cash()), value_budget.map(|budget| budget + 400.0), + algo_request, limit_price, ); let (filled_qty, execution_legs) = if let Some(fill) = fill { @@ -3858,22 +4092,32 @@ where _global_execution_cursor: Option, cash_limit: Option, gross_limit: Option, + algo_request: Option<&AlgoExecutionRequest>, limit_price: Option, ) -> Option { - if self.execution_price_field != PriceField::Last { + let matching_type = self.matching_type_for_algo_request(algo_request); + let use_intraday_quotes = + algo_request.is_some() || self.execution_price_field == PriceField::Last; + if !use_intraday_quotes { return None; } - let start_cursor = self - .intraday_execution_start_time + let start_cursor = algo_request + .and_then(|request| request.start_time) + .or(self.intraday_execution_start_time) .map(|start_time| date.and_time(start_time)); + let end_cursor = algo_request + .and_then(|request| request.end_time) + .map(|end_time| date.and_time(end_time)); let quotes = data.execution_quotes_on(date, symbol); if let Some(fill) = self.select_execution_fill( snapshot, quotes, side, + matching_type, start_cursor, + end_cursor, requested_qty, round_lot, minimum_order_quantity, @@ -3886,7 +4130,7 @@ where return Some(fill); } - if self.intraday_execution_start_time.is_some() { + if algo_request.is_some() || self.intraday_execution_start_time.is_some() { let execution_price = self.snapshot_execution_price(snapshot, side); if !self.price_satisfies_limit( side, @@ -3913,8 +4157,9 @@ where if quantity == 0 { return None; } - let next_cursor = self - .intraday_execution_start_time + let next_cursor = algo_request + .and_then(|request| request.start_time) + .or(self.intraday_execution_start_time) .map(|start_time| date.and_time(start_time) + Duration::seconds(1)) .unwrap_or_else(|| date.and_hms_opt(0, 0, 1).expect("valid midnight")); return Some(ExecutionFill { @@ -3942,7 +4187,9 @@ where snapshot: &crate::data::DailyMarketSnapshot, quotes: &[IntradayExecutionQuote], side: OrderSide, + matching_type: MatchingType, start_cursor: Option, + end_cursor: Option, requested_qty: u32, round_lot: u32, minimum_order_quantity: u32, @@ -3957,26 +4204,28 @@ where } let lot = round_lot.max(1); + let eligible_quotes: Vec<&IntradayExecutionQuote> = quotes + .iter() + .filter(|quote| { + !start_cursor.is_some_and(|cursor| quote.timestamp < cursor) + && !end_cursor.is_some_and(|cursor| quote.timestamp > cursor) + && quote.volume_delta != 0 + }) + .collect(); let mut filled_qty = 0_u32; let mut gross_amount = 0.0_f64; let mut last_timestamp = None; let mut legs = Vec::new(); let mut budget_block_reason = None; - let mut saw_quote_after_cursor = false; - - for quote in quotes { - if start_cursor.is_some_and(|cursor| quote.timestamp < cursor) { - continue; - } - saw_quote_after_cursor = true; + let saw_quote_after_cursor = !eligible_quotes.is_empty(); + for (quote_index, quote) in eligible_quotes.iter().enumerate() { // Approximate JoinQuant market-order fills with the evolving L1 book after // the decision time instead of trade VWAP. This keeps quantities/prices // closer to the observed 10:18 execution logs. - if quote.volume_delta == 0 { - continue; - } - let Some(quote_price) = self.select_quote_reference_price(snapshot, quote, side) else { + let Some(quote_price) = + self.select_quote_reference_price(snapshot, quote, side, matching_type) + else { continue; }; if !self.price_satisfies_limit( @@ -4003,7 +4252,14 @@ where if remaining_qty == 0 { break; } - let mut take_qty = remaining_qty.min(available_qty); + let mut take_qty = if matching_type == MatchingType::Twap { + let remaining_quotes = (eligible_quotes.len() - quote_index) as u32; + let scheduled_qty = + ((remaining_qty as f64) / remaining_quotes.max(1) as f64).ceil() as u32; + remaining_qty.min(available_qty).min(scheduled_qty.max(1)) + } else { + remaining_qty.min(available_qty) + }; if !(side == OrderSide::Sell && allow_odd_lot_sell && take_qty == remaining_qty) { take_qty = self.round_buy_quantity(take_qty, minimum_order_quantity, order_step_size); @@ -4059,7 +4315,7 @@ where Some(ExecutionFill { quantity: filled_qty, next_cursor: last_timestamp.unwrap() + Duration::seconds(1), - legs: if self.matching_type == MatchingType::Vwap { + legs: if matching_type == MatchingType::Vwap { vec![ExecutionLeg { price: gross_amount / filled_qty as f64, quantity: filled_qty, diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index e15d1a3..c977bb5 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -46,7 +46,7 @@ pub use scheduler::{ }; pub use strategy::{ CnSmallCapRotationConfig, CnSmallCapRotationStrategy, JqMicroCapConfig, JqMicroCapStrategy, - OpenOrderView, OrderIntent, Strategy, StrategyContext, StrategyDecision, + AlgoOrderStyle, OpenOrderView, OrderIntent, Strategy, StrategyContext, StrategyDecision, }; pub use strategy_ai::{ ManualExample, ManualFactorSource, ManualField, ManualFieldGroup, ManualFunction, diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 81eac99..eb13fa2 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -11,7 +11,7 @@ use crate::portfolio::PortfolioState; use crate::scheduler::{ ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time, }; -use crate::strategy::{OrderIntent, Strategy, StrategyContext, StrategyDecision}; +use crate::strategy::{AlgoOrderStyle, OrderIntent, Strategy, StrategyContext, StrategyDecision}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum PlatformScheduleFrequency { @@ -84,8 +84,12 @@ pub enum PlatformExplicitOrderKind { TargetShares, LimitTargetShares, Value, + VwapValue, + TwapValue, LimitValue, Percent, + VwapPercent, + TwapPercent, LimitPercent, TargetValue, LimitTargetValue, @@ -114,6 +118,8 @@ pub enum PlatformTradeAction { symbol: String, amount_expr: String, limit_price_expr: Option, + start_time_expr: Option, + end_time_expr: Option, when_expr: Option, reason: String, }, @@ -2208,6 +2214,31 @@ impl PlatformExprStrategy { Ok(value.round().max(0.0).min(u64::MAX as f64) as u64) } + fn eval_time_expr( + &self, + ctx: &StrategyContext<'_>, + expr: &str, + day: &DayExpressionState, + stock: Option<&StockExpressionState>, + position: Option<&PositionExpressionState>, + ) -> Result { + let value = self.eval_dynamic(ctx, expr, day, stock, position)?; + let Some(raw) = value.try_cast::() else { + return Err(BacktestError::Execution(format!( + "platform expr did not produce a time string: {}", + expr + ))); + }; + NaiveTime::parse_from_str(raw.trim(), "%H:%M") + .or_else(|_| NaiveTime::parse_from_str(raw.trim(), "%H:%M:%S")) + .map_err(|_| { + BacktestError::Execution(format!( + "platform expr did not produce a valid HH:MM or HH:MM:SS time: {}", + raw + )) + }) + } + fn eval_float_map_expr( &self, ctx: &StrategyContext<'_>, @@ -2376,6 +2407,8 @@ impl PlatformExprStrategy { symbol, amount_expr, limit_price_expr, + start_time_expr, + end_time_expr, when_expr, reason, } => { @@ -2491,6 +2524,38 @@ impl PlatformExprStrategy { reason: reason.clone(), }); } + PlatformExplicitOrderKind::VwapValue + | PlatformExplicitOrderKind::TwapValue => { + let value = + self.eval_float(ctx, amount_expr, day, stock_state.as_ref(), None)?; + if value.abs() <= f64::EPSILON { + continue; + } + let start_time = start_time_expr + .as_deref() + .map(|expr| { + self.eval_time_expr(ctx, expr, day, stock_state.as_ref(), None) + }) + .transpose()?; + let end_time = end_time_expr + .as_deref() + .map(|expr| { + self.eval_time_expr(ctx, expr, day, stock_state.as_ref(), None) + }) + .transpose()?; + intents.push(OrderIntent::AlgoValue { + symbol: symbol.clone(), + value, + style: if *kind == PlatformExplicitOrderKind::VwapValue { + AlgoOrderStyle::Vwap + } else { + AlgoOrderStyle::Twap + }, + start_time, + end_time, + reason: reason.clone(), + }); + } PlatformExplicitOrderKind::LimitValue => { let value = self.eval_float(ctx, amount_expr, day, stock_state.as_ref(), None)?; @@ -2523,6 +2588,38 @@ impl PlatformExprStrategy { reason: reason.clone(), }); } + PlatformExplicitOrderKind::VwapPercent + | PlatformExplicitOrderKind::TwapPercent => { + let percent = + self.eval_float(ctx, amount_expr, day, stock_state.as_ref(), None)?; + if percent.abs() <= f64::EPSILON { + continue; + } + let start_time = start_time_expr + .as_deref() + .map(|expr| { + self.eval_time_expr(ctx, expr, day, stock_state.as_ref(), None) + }) + .transpose()?; + let end_time = end_time_expr + .as_deref() + .map(|expr| { + self.eval_time_expr(ctx, expr, day, stock_state.as_ref(), None) + }) + .transpose()?; + intents.push(OrderIntent::AlgoPercent { + symbol: symbol.clone(), + percent, + style: if *kind == PlatformExplicitOrderKind::VwapPercent { + AlgoOrderStyle::Vwap + } else { + AlgoOrderStyle::Twap + }, + start_time, + end_time, + reason: reason.clone(), + }); + } PlatformExplicitOrderKind::LimitPercent => { let percent = self.eval_float(ctx, amount_expr, day, stock_state.as_ref(), None)?; @@ -3366,9 +3463,10 @@ mod tests { PlatformScheduleFrequency, PlatformTradeAction, PlatformUniverseActionKind, }; use crate::{ - BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, - Instrument, OpenOrderView, PortfolioState, ProcessEvent, ProcessEventKind, ScheduleStage, - ScheduleTimeRule, Strategy, StrategyContext, TradingCalendar, default_stage_time, + AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, + DailyMarketSnapshot, DataSet, Instrument, OpenOrderView, PortfolioState, ProcessEvent, + ProcessEventKind, ScheduleStage, ScheduleTimeRule, Strategy, StrategyContext, + TradingCalendar, default_stage_time, }; fn d(year: i32, month: u32, day: u32) -> NaiveDate { @@ -3546,6 +3644,8 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "cash * 0.1".to_string(), limit_price_expr: None, + start_time_expr: None, + end_time_expr: None, when_expr: Some("allow_buy && !touched_upper_limit".to_string()), reason: "platform_explicit_value".to_string(), }, @@ -3680,6 +3780,8 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "2000".to_string(), limit_price_expr: None, + start_time_expr: None, + end_time_expr: None, when_expr: Some("allow_buy".to_string()), reason: "platform_target_shares".to_string(), }]; @@ -3702,6 +3804,153 @@ mod tests { } } + #[test] + fn platform_strategy_emits_algo_order_actions() { + let date = d(2025, 2, 3); + let data = DataSet::from_components( + vec![Instrument { + symbol: "000001.SZ".to_string(), + name: "Ping An Bank".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: "000001.SZ".to_string(), + timestamp: Some("2025-02-03 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.4, + low: 9.8, + close: 10.2, + last_price: 10.2, + bid1: 10.18, + ask1: 10.22, + prev_close: 9.9, + volume: 100_000, + tick_volume: 5_000, + bid1_volume: 2_500, + ask1_volume: 2_500, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 10.89, + lower_limit: 8.91, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: "000001.SZ".to_string(), + market_cap_bn: 50.0, + free_float_cap_bn: 45.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.2), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: "000001.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_kcb: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_one_yuan: false, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1001.0, + prev_close: 999.0, + volume: 100_000, + }], + ) + .expect("dataset"); + let portfolio = PortfolioState::new(1_000_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 0, + data: &data, + portfolio: &portfolio, + open_orders: &[], + dynamic_universe: None, + subscriptions: &subscriptions, + process_events: &[], + active_process_event: None, + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.signal_symbol = "000001.SZ".to_string(); + cfg.rotation_enabled = false; + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + cfg.explicit_actions = vec![ + PlatformTradeAction::Order { + kind: PlatformExplicitOrderKind::VwapValue, + symbol: "000001.SZ".to_string(), + amount_expr: "cash * 0.1".to_string(), + limit_price_expr: None, + start_time_expr: Some("\"09:31\"".to_string()), + end_time_expr: Some("\"09:40\"".to_string()), + when_expr: Some("allow_buy".to_string()), + reason: "algo_vwap_entry".to_string(), + }, + PlatformTradeAction::Order { + kind: PlatformExplicitOrderKind::TwapPercent, + symbol: "000001.SZ".to_string(), + amount_expr: "0.05".to_string(), + limit_price_expr: None, + start_time_expr: Some("\"10:00\"".to_string()), + end_time_expr: Some("\"10:30\"".to_string()), + when_expr: Some("allow_buy".to_string()), + reason: "algo_twap_entry".to_string(), + }, + ]; + let mut strategy = PlatformExprStrategy::new(cfg); + + let decision = strategy.on_day(&ctx).expect("platform decision"); + + assert_eq!(decision.order_intents.len(), 2); + match &decision.order_intents[0] { + crate::strategy::OrderIntent::AlgoValue { + style, + start_time, + end_time, + .. + } => { + assert_eq!(*style, AlgoOrderStyle::Vwap); + assert_eq!( + *start_time, + Some(NaiveTime::from_hms_opt(9, 31, 0).unwrap()) + ); + assert_eq!(*end_time, Some(NaiveTime::from_hms_opt(9, 40, 0).unwrap())); + } + other => panic!("unexpected algo value intent: {other:?}"), + } + match &decision.order_intents[1] { + crate::strategy::OrderIntent::AlgoPercent { + style, + start_time, + end_time, + .. + } => { + assert_eq!(*style, AlgoOrderStyle::Twap); + assert_eq!( + *start_time, + Some(NaiveTime::from_hms_opt(10, 0, 0).unwrap()) + ); + assert_eq!(*end_time, Some(NaiveTime::from_hms_opt(10, 30, 0).unwrap())); + } + other => panic!("unexpected algo percent intent: {other:?}"), + } + } + #[test] fn platform_strategy_emits_target_portfolio_smart_explicit_action() { let date = d(2025, 2, 3); @@ -3904,6 +4153,8 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "0.25".to_string(), limit_price_expr: None, + start_time_expr: None, + end_time_expr: None, when_expr: Some("allow_buy".to_string()), reason: "auction_percent_entry".to_string(), }]; @@ -4021,6 +4272,8 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "0.25".to_string(), limit_price_expr: None, + start_time_expr: None, + end_time_expr: None, when_expr: Some("allow_buy".to_string()), reason: "auction_percent_entry".to_string(), }]; @@ -4131,6 +4384,8 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "cash * 0.1".to_string(), limit_price_expr: None, + start_time_expr: None, + end_time_expr: None, when_expr: Some( "has_open_orders && open_order_count == 1 && open_sell_qty == 200 && symbol_open_sell_qty == 200 && symbol_open_order_count == 1".to_string(), ), @@ -4491,6 +4746,8 @@ mod tests { symbol: "000001.SZ".to_string(), amount_expr: "cash * 0.1".to_string(), limit_price_expr: None, + start_time_expr: None, + end_time_expr: None, when_expr: Some( "has_process_events && process_event_count == 1 && latest_process_kind == \"order_creation_reject\" && latest_process_order_id == 55 && latest_process_symbol == \"000001.SZ\" && latest_process_side == \"buy\" && process_event_counts[\"order_creation_reject\"] == 1".to_string(), ), diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index daabb12..c0447de 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -328,6 +328,12 @@ impl StrategyDecision { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AlgoOrderStyle { + Vwap, + Twap, +} + #[derive(Debug, Clone)] pub enum OrderIntent { Shares { @@ -407,6 +413,22 @@ pub enum OrderIntent { limit_price: f64, reason: String, }, + AlgoValue { + symbol: String, + value: f64, + style: AlgoOrderStyle, + start_time: Option, + end_time: Option, + reason: String, + }, + AlgoPercent { + symbol: String, + percent: f64, + style: AlgoOrderStyle, + start_time: Option, + end_time: Option, + reason: String, + }, TargetPortfolioSmart { target_weights: BTreeMap, order_prices: Option>, diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 280d7a3..47e43e3 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -120,7 +120,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { }, ManualSection { title: "trading.rotation / order.* / cancel.* / update_universe / subscribe".to_string(), - detail: "支持显式下单、撤单和动态 universe 管理。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段,用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99)、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices={\"600000.SH\": open * 0.99}, valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])。其中 order.target_shares(...) 对应 rqalpha 的 order_to,order.target_portfolio_smart(...) 对应 rqalpha 的 order_target_portfolio_smart 批量目标权重语义,而 update_universe/subscribe/unsubscribe 对应 rqalpha 的动态 universe 与订阅接口。symbol 使用标准证券代码;数量、金额、仓位、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(), + detail: "支持显式下单、撤单、AlgoOrder 和动态 universe 管理。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段,用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99)、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices={\"600000.SH\": open * 0.99}, valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])。其中 order.target_shares(...) 对应 rqalpha 的 order_to,order.target_portfolio_smart(...) 对应 rqalpha 的 order_target_portfolio_smart 批量目标权重语义,order.vwap_* / order.twap_* 对应 rqalpha 的 AlgoOrder 时间窗订单风格,而 update_universe/subscribe/unsubscribe 对应 rqalpha 的动态 universe 与订阅接口。symbol 使用标准证券代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".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 0d597e2..476d24d 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -1,6 +1,6 @@ -use chrono::NaiveDate; +use chrono::{NaiveDate, NaiveTime}; use fidc_core::{ - BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, + AlgoOrderStyle, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, Instrument, IntradayExecutionQuote, MatchingType, OrderIntent, OrderStatus, PortfolioState, PriceField, ProcessEventKind, SlippageModel, StrategyDecision, @@ -1684,6 +1684,322 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() { ); } +#[test] +fn broker_executes_algo_vwap_value_with_time_window() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let data = DataSet::from_components_with_actions_and_quotes( + 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.2, + low: 9.8, + 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, + }], + Vec::new(), + vec![ + IntradayExecutionQuote { + date, + symbol: "000002.SZ".to_string(), + timestamp: date.and_hms_opt(10, 17, 59).unwrap(), + last_price: 9.98, + bid1: 9.97, + ask1: 9.99, + bid1_volume: 1, + ask1_volume: 1, + volume_delta: 1, + amount_delta: 0.0, + trading_phase: Some("continuous".to_string()), + }, + IntradayExecutionQuote { + date, + symbol: "000002.SZ".to_string(), + timestamp: date.and_hms_opt(10, 18, 3).unwrap(), + last_price: 10.01, + bid1: 10.0, + ask1: 10.02, + bid1_volume: 1, + ask1_volume: 1, + volume_delta: 1, + amount_delta: 0.0, + trading_phase: Some("continuous".to_string()), + }, + IntradayExecutionQuote { + date, + symbol: "000002.SZ".to_string(), + timestamp: date.and_hms_opt(10, 18, 6).unwrap(), + last_price: 10.03, + bid1: 10.02, + ask1: 10.04, + bid1_volume: 1, + ask1_volume: 1, + volume_delta: 1, + amount_delta: 0.0, + trading_phase: Some("continuous".to_string()), + }, + IntradayExecutionQuote { + date, + symbol: "000002.SZ".to_string(), + timestamp: date.and_hms_opt(10, 18, 40).unwrap(), + last_price: 10.10, + bid1: 10.09, + ask1: 10.11, + bid1_volume: 1, + ask1_volume: 1, + volume_delta: 1, + amount_delta: 0.0, + trading_phase: Some("continuous".to_string()), + }, + ], + ) + .expect("dataset"); + let mut portfolio = PortfolioState::new(1_000_000.0); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Last, + ); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![OrderIntent::AlgoValue { + symbol: "000002.SZ".to_string(), + value: 2_500.0, + style: AlgoOrderStyle::Vwap, + start_time: Some(NaiveTime::from_hms_opt(10, 18, 0).unwrap()), + end_time: Some(NaiveTime::from_hms_opt(10, 18, 10).unwrap()), + reason: "algo_vwap_window".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("broker execution"); + + assert_eq!(report.fill_events.len(), 1); + assert_eq!(report.fill_events[0].quantity, 200); + assert!((report.fill_events[0].price - 10.02).abs() < 1e-9); +} + +#[test] +fn broker_executes_algo_twap_percent_across_window_quotes() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let data = DataSet::from_components_with_actions_and_quotes( + 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: 12.0, + open: 12.0, + high: 12.2, + low: 11.8, + close: 12.0, + last_price: 12.0, + bid1: 11.99, + ask1: 12.01, + prev_close: 12.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: 13.2, + lower_limit: 10.8, + 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, + }], + Vec::new(), + vec![ + IntradayExecutionQuote { + date, + symbol: "000002.SZ".to_string(), + timestamp: date.and_hms_opt(10, 0, 0).unwrap(), + last_price: 12.00, + bid1: 11.99, + ask1: 12.01, + bid1_volume: 1, + ask1_volume: 1, + volume_delta: 1, + amount_delta: 0.0, + trading_phase: Some("continuous".to_string()), + }, + IntradayExecutionQuote { + date, + symbol: "000002.SZ".to_string(), + timestamp: date.and_hms_opt(10, 15, 0).unwrap(), + last_price: 12.03, + bid1: 12.02, + ask1: 12.04, + bid1_volume: 1, + ask1_volume: 1, + volume_delta: 1, + amount_delta: 0.0, + trading_phase: Some("continuous".to_string()), + }, + IntradayExecutionQuote { + date, + symbol: "000002.SZ".to_string(), + timestamp: date.and_hms_opt(10, 30, 0).unwrap(), + last_price: 12.06, + bid1: 12.05, + ask1: 12.07, + bid1_volume: 1, + ask1_volume: 1, + volume_delta: 1, + amount_delta: 0.0, + trading_phase: Some("continuous".to_string()), + }, + ], + ) + .expect("dataset"); + let mut portfolio = PortfolioState::new(1_000_000.0); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Last, + ); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![OrderIntent::AlgoPercent { + symbol: "000002.SZ".to_string(), + percent: 0.0036, + style: AlgoOrderStyle::Twap, + start_time: Some(NaiveTime::from_hms_opt(10, 0, 0).unwrap()), + end_time: Some(NaiveTime::from_hms_opt(10, 30, 0).unwrap()), + reason: "algo_twap_window".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("broker execution"); + + assert_eq!(report.fill_events.len(), 3); + assert_eq!( + report + .fill_events + .iter() + .map(|fill| fill.quantity) + .sum::(), + 300 + ); + assert!(report.fill_events.iter().all(|fill| fill.quantity == 100)); + assert_eq!( + report + .process_events + .iter() + .filter(|event| event.kind == ProcessEventKind::Trade) + .count(), + 3 + ); +} + #[test] fn broker_uses_best_own_price_for_intraday_matching() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); diff --git a/docs/rqalpha-gap-roadmap.md b/docs/rqalpha-gap-roadmap.md index 8a6a26c..9663aec 100644 --- a/docs/rqalpha-gap-roadmap.md +++ b/docs/rqalpha-gap-roadmap.md @@ -35,8 +35,8 @@ current alignment pass. ### Phase 4: Algo order parity -- [ ] `VWAPOrder` -- [ ] `TWAPOrder` +- [x] `VWAPOrder` first-class explicit action parity (`order.vwap_value/percent`) +- [x] `TWAPOrder` first-class explicit action parity (`order.twap_value/percent`) - [ ] `order_target_portfolio_smart(..., order_prices=AlgoOrder, valuation_prices=...)` ### Phase 5: Position accounting parity