From 3439b5d8d03ade3ef922a3187be308d5abedb44a Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 23 Apr 2026 20:38:36 -0700 Subject: [PATCH] Wire futures order intents into engine --- crates/fidc-core/src/broker.rs | 10 ++ crates/fidc-core/src/engine.rs | 43 ++++++- crates/fidc-core/src/futures.rs | 14 ++- crates/fidc-core/src/strategy.rs | 5 +- crates/fidc-core/tests/engine_hooks.rs | 149 ++++++++++++++++++++++++- docs/rqalpha-gap-roadmap.md | 7 +- 6 files changed, 214 insertions(+), 14 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 0fea2b0..3118aa5 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -939,6 +939,16 @@ where )); Ok(()) } + OrderIntent::Futures { intent } => { + report.diagnostics.push(format!( + "engine_futures_intent_skipped symbol={} direction={} effect={} reason={}", + intent.symbol, + intent.direction.as_str(), + intent.effect.as_str(), + intent.reason + )); + Ok(()) + } } } diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 5d628ad..5857d32 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -12,7 +12,7 @@ use crate::events::{ AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, ProcessEventKind, }; -use crate::futures::FuturesAccountState; +use crate::futures::{FuturesAccountState, FuturesExecutionReport}; use crate::metrics::{BacktestMetrics, compute_backtest_metrics}; use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState}; use crate::rules::EquityRuleHooks; @@ -103,6 +103,7 @@ pub struct BacktestEngine { dynamic_universe: Option>, subscriptions: BTreeSet, futures_account: Option, + next_futures_order_id: u64, } impl BacktestEngine { @@ -122,6 +123,7 @@ impl BacktestEngine { dynamic_universe: None, subscriptions: BTreeSet::new(), futures_account: None, + next_futures_order_id: 1, } } @@ -454,6 +456,37 @@ where }, )?; } + crate::strategy::OrderIntent::Futures { intent } => { + let order_id = self.next_futures_order_id; + self.next_futures_order_id += 1; + let report = if let Some(account) = self.futures_account.as_mut() { + account.execute_order(execution_date, Some(order_id), intent) + } else { + let mut report = FuturesExecutionReport::default(); + let side = intent.side(); + report.order_events.push(OrderEvent { + date: execution_date, + order_id: Some(order_id), + symbol: intent.symbol, + side, + requested_quantity: intent.quantity, + filled_quantity: 0, + status: OrderStatus::Rejected, + reason: format!( + "{}: futures account is not enabled direction={} effect={}", + intent.reason, + intent.direction.as_str(), + intent.effect.as_str() + ), + }); + report + }; + decision.diagnostics.push(format!( + "futures_order order_id={order_id} events={}", + report.order_events.len() + )); + merge_futures_report(directive_report, report); + } other => retained.push(other), } } @@ -2246,6 +2279,14 @@ fn merge_broker_report(target: &mut BrokerExecutionReport, incoming: BrokerExecu target.diagnostics.extend(incoming.diagnostics); } +fn merge_futures_report(target: &mut BrokerExecutionReport, incoming: FuturesExecutionReport) { + target.order_events.extend(incoming.order_events); + target.fill_events.extend(incoming.fill_events); + target.position_events.extend(incoming.position_events); + target.account_events.extend(incoming.account_events); + target.diagnostics.extend(incoming.diagnostics); +} + mod date_format { use chrono::NaiveDate; use serde::Serializer; diff --git a/crates/fidc-core/src/futures.rs b/crates/fidc-core/src/futures.rs index 066a6b0..d49ade6 100644 --- a/crates/fidc-core/src/futures.rs +++ b/crates/fidc-core/src/futures.rs @@ -121,6 +121,14 @@ impl FuturesOrderIntent { reason: reason.into(), } } + + pub fn side(&self) -> OrderSide { + if self.effect == FuturesPositionEffect::Open { + self.direction.open_side() + } else { + self.direction.close_side() + } + } } #[derive(Debug, Clone, Default)] @@ -505,11 +513,7 @@ impl FuturesAccountState { intent: FuturesOrderIntent, ) -> FuturesExecutionReport { let mut report = FuturesExecutionReport::default(); - let side = if intent.effect == FuturesPositionEffect::Open { - intent.direction.open_side() - } else { - intent.direction.close_side() - }; + let side = intent.side(); if intent.quantity == 0 || !intent.price.is_finite() || intent.price <= 0.0 { report.order_events.push(OrderEvent { diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index ff3a5ae..2ba4a16 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -10,7 +10,7 @@ use crate::cost::ChinaAShareCostModel; use crate::data::{DailyMarketSnapshot, DataSet, IntradayExecutionQuote, PriceBar, PriceField}; use crate::engine::BacktestError; use crate::events::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEvent}; -use crate::futures::FuturesAccountState; +use crate::futures::{FuturesAccountState, FuturesOrderIntent}; use crate::instrument::Instrument; use crate::portfolio::PortfolioState; use crate::scheduler::ScheduleRule; @@ -902,6 +902,9 @@ pub enum OrderIntent { rate: f64, reason: String, }, + Futures { + intent: FuturesOrderIntent, + }, } #[derive(Debug, Clone)] diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index 4153995..52dea4c 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -6,10 +6,10 @@ use chrono::{NaiveDate, NaiveDateTime}; use fidc_core::{ BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, - FuturesAccountState, FuturesContractSpec, FuturesDirection, Instrument, IntradayExecutionQuote, - OpenOrderView, OrderIntent, OrderSide, OrderStatus, PortfolioState, PriceField, - ProcessEventKind, ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy, StrategyContext, - StrategyDecision, + FuturesAccountState, FuturesContractSpec, FuturesDirection, FuturesOrderIntent, Instrument, + IntradayExecutionQuote, OpenOrderView, OrderIntent, OrderSide, OrderStatus, PortfolioState, + PriceField, ProcessEventKind, ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy, + StrategyContext, StrategyDecision, }; fn d(year: i32, month: u32, day: u32) -> NaiveDate { @@ -132,6 +132,41 @@ impl Strategy for AuctionOrderStrategy { } } +struct FuturesOrderStrategy; + +impl Strategy for FuturesOrderStrategy { + fn name(&self) -> &str { + "futures-order" + } + + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + if ctx.execution_date != d(2025, 1, 2) { + return Ok(StrategyDecision::default()); + } + Ok(StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![OrderIntent::Futures { + intent: FuturesOrderIntent::open( + "IF2501", + FuturesDirection::Long, + FuturesContractSpec::new(300.0, 0.12, 0.14), + 1, + 4000.0, + 12.0, + "open index future", + ), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }) + } +} + struct ScheduledProbeStrategy { log: Rc>>, process_log: Rc>>, @@ -839,6 +874,112 @@ fn engine_executes_open_auction_decisions_before_on_day() { assert_eq!(result.fills[0].quantity, 100); } +#[test] +fn engine_executes_futures_order_intents_against_future_account() { + let date = d(2025, 1, 2); + let data = DataSet::from_components( + vec![Instrument { + symbol: "000001.SZ".to_string(), + name: "Anchor".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-01-02 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.0, + low: 10.0, + close: 10.0, + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + prev_close: 9.9, + volume: 1_000_000, + tick_volume: 1_000_000, + bid1_volume: 1_000_000, + ask1_volume: 1_000_000, + 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: 100.0, + free_float_cap_bn: 80.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.0), + 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_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 engine = BacktestEngine::new( + data, + FuturesOrderStrategy, + broker, + BacktestConfig { + initial_cash: 100_000.0, + benchmark_code: "000300.SH".to_string(), + start_date: Some(date), + end_date: Some(date), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Open, + }, + ) + .with_futures_initial_cash(500_000.0); + + let result = engine.run().expect("backtest succeeds"); + + assert!(result.order_events.iter().any(|event| { + event.symbol == "IF2501" + && event.status == OrderStatus::Filled + && event.filled_quantity == 1 + })); + assert!(result.fills.iter().any(|fill| { + fill.symbol == "IF2501" && fill.quantity == 1 && (fill.commission - 12.0).abs() < 1e-6 + })); + let futures_account = engine.futures_account().expect("future account"); + let position = futures_account + .position("IF2501", FuturesDirection::Long) + .expect("long futures position"); + assert_eq!(position.quantity, 1); + assert!((futures_account.total_cash() - 499_988.0).abs() < 1e-6); + assert!((futures_account.cash() - 355_988.0).abs() < 1e-6); +} + #[test] fn engine_runs_subscribed_tick_hooks_and_executes_tick_orders() { let date = d(2025, 1, 2); diff --git a/docs/rqalpha-gap-roadmap.md b/docs/rqalpha-gap-roadmap.md index d503187..9cede8a 100644 --- a/docs/rqalpha-gap-roadmap.md +++ b/docs/rqalpha-gap-roadmap.md @@ -92,7 +92,8 @@ current alignment pass. - [x] wire futures account runtime view into `BacktestEngine` and `StrategyContext` (`future_account`, `account_by_type("FUTURE")`, `accounts`) -- [ ] wire futures order intents into the generic `BacktestEngine` execution loop +- [x] wire futures order intents into the generic `BacktestEngine` execution + loop for account-level open/close execution - [ ] futures intraday matching integration and expiration settlement ## Execution Order @@ -113,5 +114,5 @@ Active implementation target: continue account parity after exposing the stock account runtime view, core Portfolio fields, deposit/withdraw, financing liability APIs, management-fee callbacks, stock account accessors, and the standalone futures account/order execution model plus generic engine runtime -account visibility; next gap is wiring futures order intents into the generic -engine execution loop and adding futures intraday/expiration semantics. +account visibility and account-level futures order intents; next gap is adding +futures intraday matching and expiration settlement semantics.