diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 3118aa5..b30391f 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -197,6 +197,14 @@ impl BrokerSimulator { self } + pub fn matching_type(&self) -> MatchingType { + self.matching_type + } + + pub fn execution_price_field(&self) -> PriceField { + self.execution_price_field + } + pub fn open_order_views(&self) -> Vec { self.open_orders .borrow() diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 477d469..f0bdcaf 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::calendar::TradingCalendar; +use crate::futures::{FuturesCommissionType, FuturesTradingParameter}; use crate::instrument::Instrument; mod date_format { @@ -345,6 +346,51 @@ pub struct PriceBar { pub ask1_volume: u64, } +#[derive(Debug, Clone, Serialize)] +pub struct DividendRecord { + #[serde(with = "date_format")] + pub ex_dividend_date: NaiveDate, + #[serde(with = "date_format")] + pub payable_date: NaiveDate, + pub symbol: String, + pub dividend_cash_before_tax: f64, + pub round_lot: u32, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SplitRecord { + #[serde(with = "date_format")] + pub ex_dividend_date: NaiveDate, + pub symbol: String, + pub split_ratio: f64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FactorValue { + #[serde(with = "date_format")] + pub date: NaiveDate, + pub symbol: String, + pub field: String, + pub value: f64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SecuritiesMarginRecord { + #[serde(with = "date_format")] + pub date: NaiveDate, + pub symbol: String, + pub field: String, + pub value: f64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct YieldCurvePoint { + #[serde(with = "date_format")] + pub date: NaiveDate, + pub tenor: String, + pub value: f64, +} + #[derive(Debug, Clone)] pub struct EligibleUniverseSnapshot { pub symbol: String, @@ -620,6 +666,7 @@ pub struct DataSet { benchmark_series_cache: BenchmarkPriceSeries, eligible_universe_by_date: BTreeMap>, benchmark_code: String, + futures_params_by_symbol: HashMap>, } impl DataSet { @@ -641,7 +688,13 @@ impl DataSet { } else { Vec::new() }; - Self::from_components_with_actions_and_quotes( + let futures_params_path = path.join("futures_trading_parameters.csv"); + let futures_params = if futures_params_path.exists() { + read_futures_trading_parameters(&futures_params_path)? + } else { + Vec::new() + }; + Self::from_components_with_actions_quotes_and_futures( instruments, market, factors, @@ -649,6 +702,7 @@ impl DataSet { benchmarks, corporate_actions, execution_quotes, + futures_params, ) } @@ -670,7 +724,13 @@ impl DataSet { } else { Vec::new() }; - Self::from_components_with_actions_and_quotes( + let futures_params_dir = path.join("futures_trading_parameters"); + let futures_params = if futures_params_dir.exists() { + read_partitioned_dir(&futures_params_dir, read_futures_trading_parameters)? + } else { + Vec::new() + }; + Self::from_components_with_actions_quotes_and_futures( instruments, market, factors, @@ -678,6 +738,7 @@ impl DataSet { benchmarks, corporate_actions, execution_quotes, + futures_params, ) } @@ -726,6 +787,28 @@ impl DataSet { benchmarks: Vec, corporate_actions: Vec, execution_quotes: Vec, + ) -> Result { + Self::from_components_with_actions_quotes_and_futures( + instruments, + market, + factors, + candidates, + benchmarks, + corporate_actions, + execution_quotes, + Vec::new(), + ) + } + + pub fn from_components_with_actions_quotes_and_futures( + instruments: Vec, + market: Vec, + factors: Vec, + candidates: Vec, + benchmarks: Vec, + corporate_actions: Vec, + execution_quotes: Vec, + futures_params: Vec, ) -> Result { let benchmark_code = collect_benchmark_code(&benchmarks)?; let calendar = TradingCalendar::new(benchmarks.iter().map(|item| item.date).collect()); @@ -764,6 +847,7 @@ impl DataSet { BenchmarkPriceSeries::new(&benchmark_by_date.values().cloned().collect::>()); let eligible_universe_by_date = build_eligible_universe(&factor_by_date, &candidate_index, &market_index); + let futures_params_by_symbol = build_futures_params_index(futures_params); Ok(Self { instruments, @@ -781,6 +865,7 @@ impl DataSet { benchmark_series_cache, eligible_universe_by_date, benchmark_code, + futures_params_by_symbol, }) } @@ -870,6 +955,38 @@ impl DataSet { self.benchmark_by_date.values().cloned().collect() } + pub fn futures_trading_parameter( + &self, + date: NaiveDate, + symbol: &str, + ) -> Option<&FuturesTradingParameter> { + self.futures_params_by_symbol.get(symbol).and_then(|rows| { + rows.iter() + .rev() + .find(|row| row.effective_date.is_none_or(|effective| effective <= date)) + }) + } + + pub fn futures_settlement_price( + &self, + date: NaiveDate, + symbol: &str, + mode: &str, + ) -> Option { + let snapshot = self.market(date, symbol)?; + match normalize_field(mode).as_str() { + "settlement" | "settle" => self + .factor_numeric_value(date, symbol, "settlement") + .or_else(|| self.factor_numeric_value(date, symbol, "settle")) + .or(Some(snapshot.close)), + "prev_settlement" | "pre_settlement" => self + .factor_numeric_value(date, symbol, "prev_settlement") + .or_else(|| self.factor_numeric_value(date, symbol, "pre_settlement")) + .or(Some(snapshot.prev_close)), + _ => Some(snapshot.close), + } + } + pub fn history_bars( &self, date: NaiveDate, @@ -994,6 +1111,218 @@ impl DataSet { }) } + pub fn get_dividend( + &self, + symbol: &str, + start: NaiveDate, + end: NaiveDate, + ) -> Vec { + let mut rows = self + .corporate_actions_by_date + .range(start..=end) + .flat_map(|(_, actions)| actions.iter()) + .filter(|action| action.symbol == symbol && action.share_cash.abs() > f64::EPSILON) + .map(|action| DividendRecord { + ex_dividend_date: action.date, + payable_date: action.payable_date.unwrap_or(action.date), + symbol: action.symbol.clone(), + dividend_cash_before_tax: action.share_cash, + round_lot: self + .instrument(symbol) + .map(Instrument::effective_round_lot) + .unwrap_or(100), + }) + .collect::>(); + rows.sort_by_key(|row| row.ex_dividend_date); + rows + } + + pub fn get_split(&self, symbol: &str, start: NaiveDate, end: NaiveDate) -> Vec { + let mut rows = self + .corporate_actions_by_date + .range(start..=end) + .flat_map(|(_, actions)| actions.iter()) + .filter(|action| action.symbol == symbol && (action.split_ratio() - 1.0).abs() > 1e-12) + .map(|action| SplitRecord { + ex_dividend_date: action.date, + symbol: action.symbol.clone(), + split_ratio: action.split_ratio(), + }) + .collect::>(); + rows.sort_by_key(|row| row.ex_dividend_date); + rows + } + + pub fn get_factor( + &self, + symbol: &str, + start: NaiveDate, + end: NaiveDate, + field: &str, + ) -> Vec { + if start > end { + return Vec::new(); + } + let field = normalize_field(field); + let mut rows = self + .factor_by_date + .range(start..=end) + .flat_map(|(_, snapshots)| snapshots.iter()) + .filter(|snapshot| snapshot.symbol == symbol) + .filter_map(|snapshot| { + factor_numeric_value(snapshot, &field).map(|value| FactorValue { + date: snapshot.date, + symbol: snapshot.symbol.clone(), + field: field.clone(), + value, + }) + }) + .collect::>(); + rows.sort_by_key(|row| row.date); + rows + } + + pub fn get_yield_curve( + &self, + start: NaiveDate, + end: NaiveDate, + tenor: Option<&str>, + ) -> Vec { + if start > end { + return Vec::new(); + } + let tenor_filter = tenor.map(normalize_field); + let mut rows = Vec::new(); + for (date, snapshots) in self.factor_by_date.range(start..=end) { + for snapshot in snapshots { + for (field, value) in &snapshot.extra_factors { + let normalized = normalize_field(field); + let Some(raw_tenor) = normalized + .strip_prefix("yield_curve_") + .or_else(|| normalized.strip_prefix("yc_")) + else { + continue; + }; + if tenor_filter + .as_ref() + .is_some_and(|expected| expected != raw_tenor) + { + continue; + } + rows.push(YieldCurvePoint { + date: *date, + tenor: raw_tenor.to_string(), + value: *value, + }); + } + } + } + rows.sort_by(|left, right| { + left.date + .cmp(&right.date) + .then(left.tenor.cmp(&right.tenor)) + }); + rows + } + + pub fn get_margin_stocks(&self, date: NaiveDate, margin_type: &str) -> Vec { + let field = match normalize_field(margin_type).as_str() { + "stock" => "margin_stock", + "cash" => "margin_cash", + _ => "margin_all", + }; + let mut symbols = self + .factor_by_date + .get(&date) + .map(|rows| { + rows.iter() + .filter(|row| { + row.extra_factors + .get(field) + .or_else(|| row.extra_factors.get("margin_all")) + .is_some_and(|value| *value > 0.0) + }) + .map(|row| row.symbol.clone()) + .collect::>() + }) + .unwrap_or_default(); + if symbols.is_empty() { + symbols = self + .active_instruments( + date, + &self + .instruments + .keys() + .map(String::as_str) + .collect::>(), + ) + .into_iter() + .filter(|instrument| !instrument.board.eq_ignore_ascii_case("FUTURE")) + .map(|instrument| instrument.symbol.clone()) + .collect(); + } + symbols.sort(); + symbols.dedup(); + symbols + } + + pub fn get_securities_margin( + &self, + symbol: &str, + start: NaiveDate, + end: NaiveDate, + field: &str, + ) -> Vec { + self.get_factor(symbol, start, end, field) + .into_iter() + .map(|row| SecuritiesMarginRecord { + date: row.date, + symbol: row.symbol, + field: row.field, + value: row.value, + }) + .collect() + } + + pub fn get_dominant_future(&self, underlying_symbol: &str, date: NaiveDate) -> Option { + let underlying = normalize_field(underlying_symbol); + let mut candidates = self + .futures_params_by_symbol + .keys() + .filter(|symbol| normalize_field(symbol).starts_with(&underlying)) + .filter(|symbol| { + self.futures_trading_parameter(date, symbol.as_str()) + .is_some() + }) + .cloned() + .collect::>(); + if candidates.is_empty() { + candidates = self + .instruments + .values() + .filter(|instrument| instrument.board.eq_ignore_ascii_case("FUTURE")) + .filter(|instrument| normalize_field(&instrument.symbol).starts_with(&underlying)) + .filter(|instrument| instrument.is_active_on(date)) + .map(|instrument| instrument.symbol.clone()) + .collect(); + } + candidates.sort(); + candidates.into_iter().next() + } + + pub fn get_dominant_future_price( + &self, + underlying_symbol: &str, + start: NaiveDate, + end: NaiveDate, + frequency: &str, + ) -> Vec { + let Some(symbol) = self.get_dominant_future(underlying_symbol, end) else { + return Vec::new(); + }; + self.get_price(&symbol, start, end, frequency) + } + pub fn get_price( &self, symbol: &str, @@ -1649,6 +1978,41 @@ fn read_execution_quotes(path: &Path) -> Result, Dat Ok(quotes) } +fn read_futures_trading_parameters( + path: &Path, +) -> Result, DataSetError> { + let rows = read_rows(path)?; + let mut params = Vec::new(); + for row in rows { + let first = row.get(0)?.trim(); + let (effective_date, symbol_index) = if NaiveDate::parse_from_str(first, "%Y-%m-%d").is_ok() + { + (row.parse_optional_date(0)?, 1) + } else { + (None, 0) + }; + params.push(FuturesTradingParameter { + effective_date, + symbol: row.get(symbol_index)?.to_string(), + contract_multiplier: row.parse_optional_f64(symbol_index + 1).unwrap_or(1.0), + long_margin_rate: row.parse_optional_f64(symbol_index + 2).unwrap_or(0.0), + short_margin_rate: row.parse_optional_f64(symbol_index + 3).unwrap_or(0.0), + commission_type: row + .fields + .get(symbol_index + 4) + .map(|value| FuturesCommissionType::parse(value)) + .unwrap_or(FuturesCommissionType::ByMoney), + open_commission_ratio: row.parse_optional_f64(symbol_index + 5).unwrap_or(0.0), + close_commission_ratio: row.parse_optional_f64(symbol_index + 6).unwrap_or(0.0), + close_today_commission_ratio: row + .parse_optional_f64(symbol_index + 7) + .unwrap_or_else(|| row.parse_optional_f64(symbol_index + 6).unwrap_or(0.0)), + price_tick: row.parse_optional_f64(symbol_index + 8).unwrap_or(1.0), + }); + } + Ok(params) +} + struct CsvRow { path: String, line: usize, @@ -1934,6 +2298,19 @@ fn build_market_series( .collect() } +fn build_futures_params_index( + rows: Vec, +) -> HashMap> { + let mut grouped = HashMap::>::new(); + for row in rows { + grouped.entry(row.symbol.clone()).or_default().push(row); + } + for rows in grouped.values_mut() { + rows.sort_by_key(|row| row.effective_date); + } + grouped +} + fn build_execution_quote_index( execution_quotes: Vec, ) -> HashMap<(NaiveDate, String), Vec> { diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 68a4bdf..3ca4f40 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -4,7 +4,7 @@ use chrono::NaiveDate; use serde::Serialize; use thiserror::Error; -use crate::broker::{BrokerExecutionReport, BrokerSimulator}; +use crate::broker::{BrokerExecutionReport, BrokerSimulator, MatchingType}; use crate::cost::CostModel; use crate::data::{BenchmarkSnapshot, DataSet, DataSetError, PriceField}; use crate::event_bus::ProcessEventBus; @@ -12,7 +12,10 @@ use crate::events::{ AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, ProcessEventKind, }; -use crate::futures::{FuturesAccountState, FuturesExecutionReport}; +use crate::futures::{ + FuturesAccountState, FuturesExecutionReport, FuturesOrderIntent, FuturesPositionEffect, + FuturesTransactionCostModel, +}; use crate::metrics::{BacktestMetrics, compute_backtest_metrics}; use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState}; use crate::rules::EquityRuleHooks; @@ -72,6 +75,91 @@ pub struct BacktestResult { pub metrics: BacktestMetrics, } +#[derive(Debug, Clone, Serialize)] +pub struct AnalyzerTradeRow { + #[serde(with = "date_format")] + pub date: NaiveDate, + pub order_id: Option, + pub symbol: String, + pub side: OrderSide, + pub quantity: u32, + pub price: f64, + pub gross_amount: f64, + pub transaction_cost: f64, + pub net_cash_flow: f64, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AnalyzerPositionRow { + #[serde(with = "date_format")] + pub date: NaiveDate, + pub symbol: String, + pub quantity: u32, + pub market_value: f64, + pub weight: f64, + pub average_cost: f64, + pub realized_pnl: f64, + pub unrealized_pnl: f64, + pub transaction_cost: f64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AnalyzerReport { + pub strategy_name: String, + pub trades: Vec, + pub positions: Vec, + pub equity_curve: Vec, + pub benchmark_series: Vec, + pub metrics: BacktestMetrics, +} + +impl BacktestResult { + pub fn analyzer_report(&self) -> AnalyzerReport { + AnalyzerReport { + strategy_name: self.strategy_name.clone(), + trades: self + .fills + .iter() + .map(|fill| AnalyzerTradeRow { + date: fill.date, + order_id: fill.order_id, + symbol: fill.symbol.clone(), + side: fill.side, + quantity: fill.quantity, + price: fill.price, + gross_amount: fill.gross_amount, + transaction_cost: fill.commission + fill.stamp_tax, + net_cash_flow: fill.net_cash_flow, + reason: fill.reason.clone(), + }) + .collect(), + positions: self + .daily_holdings + .iter() + .map(|holding| AnalyzerPositionRow { + date: holding.date, + symbol: holding.symbol.clone(), + quantity: holding.quantity, + market_value: holding.market_value, + weight: holding.value_percent, + average_cost: holding.average_cost, + realized_pnl: holding.realized_pnl, + unrealized_pnl: holding.unrealized_pnl, + transaction_cost: holding.transaction_cost, + }) + .collect(), + equity_curve: self.equity_curve.clone(), + benchmark_series: self.benchmark_series.clone(), + metrics: self.metrics.clone(), + } + } + + pub fn analyzer_report_json(&self) -> Result { + serde_json::to_string_pretty(&self.analyzer_report()) + } +} + #[derive(Debug, Clone, Serialize)] pub struct BacktestDayProgress { #[serde(with = "date_format")] @@ -93,6 +181,17 @@ pub struct BacktestDayProgress { pub process_events: Vec, } +#[derive(Debug, Clone)] +struct FuturesOpenOrder { + order_id: u64, + intent: FuturesOrderIntent, + requested_quantity: u32, + filled_quantity: u32, + remaining_quantity: u32, + limit_price: f64, + reason: String, +} + pub struct BacktestEngine { data: DataSet, strategy: S, @@ -104,7 +203,10 @@ pub struct BacktestEngine { subscriptions: BTreeSet, futures_account: Option, next_futures_order_id: u64, + futures_open_orders: Vec, futures_expirations: BTreeMap>, + futures_settlement_price_mode: String, + futures_cost_model: FuturesTransactionCostModel, } impl BacktestEngine { @@ -124,8 +226,11 @@ impl BacktestEngine { dynamic_universe: None, subscriptions: BTreeSet::new(), futures_account: None, - next_futures_order_id: 1, + next_futures_order_id: 9_000_000_000, + futures_open_orders: Vec::new(), futures_expirations: BTreeMap::new(), + futures_settlement_price_mode: "close".to_string(), + futures_cost_model: FuturesTransactionCostModel::default(), } } @@ -172,6 +277,19 @@ impl BacktestEngine { self } + pub fn with_futures_settlement_price_mode(mut self, mode: impl Into) -> Self { + self.futures_settlement_price_mode = mode.into(); + self + } + + pub fn with_futures_transaction_cost_model( + mut self, + cost_model: FuturesTransactionCostModel, + ) -> Self { + self.futures_cost_model = cost_model; + self + } + pub fn process_event_bus_mut(&mut self) -> &mut ProcessEventBus { &mut self.process_event_bus } @@ -479,31 +597,42 @@ where }, )?; } + crate::strategy::OrderIntent::CancelOrder { order_id, reason } => { + let report = self.cancel_futures_open_order(execution_date, order_id, &reason); + if report.order_events.is_empty() && report.process_events.is_empty() { + retained + .push(crate::strategy::OrderIntent::CancelOrder { order_id, reason }); + } else { + merge_futures_report(directive_report, report); + } + } + crate::strategy::OrderIntent::CancelSymbol { symbol, reason } => { + let report = self.cancel_futures_open_orders_for_symbol( + execution_date, + &symbol, + &reason, + ); + if report.order_events.is_empty() && report.process_events.is_empty() { + retained + .push(crate::strategy::OrderIntent::CancelSymbol { symbol, reason }); + } else { + merge_futures_report(directive_report, report); + } + } + crate::strategy::OrderIntent::CancelAll { reason } => { + let report = self.cancel_all_futures_open_orders(execution_date, &reason); + let has_stock_open_orders = !self.broker.open_order_views().is_empty(); + if has_stock_open_orders || report.order_events.is_empty() { + retained.push(crate::strategy::OrderIntent::CancelAll { + reason: reason.clone(), + }); + } + merge_futures_report(directive_report, report); + } 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 - }; + let report = self.submit_futures_order(execution_date, order_id, intent, false); decision.diagnostics.push(format!( "futures_order order_id={order_id} events={}", report.order_events.len() @@ -517,6 +646,509 @@ where Ok(()) } + fn open_order_views(&self) -> Vec { + let mut views = self.broker.open_order_views(); + views.extend( + self.futures_open_orders + .iter() + .map(|order| crate::strategy::OpenOrderView { + order_id: order.order_id, + symbol: order.intent.symbol.clone(), + side: order.intent.side(), + requested_quantity: order.requested_quantity, + filled_quantity: order.filled_quantity, + remaining_quantity: order.remaining_quantity, + unfilled_quantity: order.remaining_quantity, + status: OrderStatus::Pending, + avg_price: 0.0, + transaction_cost: 0.0, + limit_price: order.limit_price, + reason: order.reason.clone(), + }), + ); + views.sort_by_key(|order| order.order_id); + views + } + + fn aggregate_initial_cash(&self) -> f64 { + self.config.initial_cash + + self + .futures_account + .as_ref() + .map(FuturesAccountState::starting_cash) + .unwrap_or(0.0) + } + + fn aggregate_cash(&self, portfolio: &PortfolioState) -> f64 { + portfolio.cash() + + self + .futures_account + .as_ref() + .map(FuturesAccountState::cash) + .unwrap_or(0.0) + } + + fn aggregate_market_value(&self, portfolio: &PortfolioState) -> f64 { + portfolio.market_value() + + self + .futures_account + .as_ref() + .map(FuturesAccountState::position_equity) + .unwrap_or(0.0) + } + + fn aggregate_total_equity(&self, portfolio: &PortfolioState) -> f64 { + portfolio.total_equity() + + self + .futures_account + .as_ref() + .map(FuturesAccountState::total_value) + .unwrap_or(0.0) + } + + fn submit_futures_order( + &mut self, + date: NaiveDate, + order_id: u64, + intent: FuturesOrderIntent, + from_pending: bool, + ) -> FuturesExecutionReport { + let Some(_) = self.futures_account.as_ref() else { + return self.reject_futures_order( + date, + order_id, + intent, + "futures account is not enabled".to_string(), + ); + }; + + if let Some(reason) = self.validate_futures_submission(&intent) { + return self.reject_futures_order(date, order_id, intent, reason); + } + + let original_requested = intent.quantity; + let mut intent = self.resolve_futures_trading_parameters(date, intent); + let fill = self.resolve_futures_fill(date, &intent); + let Some((execution_price, fill_quantity)) = fill else { + if intent.allow_pending || intent.limit_price.is_some() { + return self.queue_futures_order( + date, + order_id, + intent, + original_requested, + 0, + from_pending, + "limit not matched or no executable futures price", + ); + } + return self.reject_futures_order( + date, + order_id, + intent, + "missing executable futures price".to_string(), + ); + }; + if fill_quantity == 0 { + if intent.allow_pending || intent.limit_price.is_some() { + return self.queue_futures_order( + date, + order_id, + intent, + original_requested, + 0, + from_pending, + "futures liquidity unavailable", + ); + } + return self.reject_futures_order( + date, + order_id, + intent, + "futures liquidity unavailable".to_string(), + ); + } + + let remaining = original_requested.saturating_sub(fill_quantity); + intent.price = execution_price; + intent.quantity = fill_quantity; + intent = self.resolve_futures_transaction_cost(date, intent); + let mut report = self + .futures_account + .as_mut() + .expect("checked futures account") + .execute_order(date, Some(order_id), intent.clone()); + + if remaining > 0 && (intent.allow_pending || intent.limit_price.is_some()) { + for event in &mut report.order_events { + if event.order_id == Some(order_id) { + event.requested_quantity = original_requested; + event.filled_quantity = fill_quantity; + event.status = OrderStatus::PartiallyFilled; + } + } + let mut remaining_intent = intent.clone(); + remaining_intent.quantity = remaining; + remaining_intent.transaction_cost = 0.0; + let queued = self.queue_futures_order( + date, + order_id, + remaining_intent, + original_requested, + fill_quantity, + true, + "partial fill remaining quantity pending", + ); + report.order_events.extend(queued.order_events); + report.process_events.extend(queued.process_events); + report.diagnostics.extend(queued.diagnostics); + } else if remaining > 0 { + for event in &mut report.order_events { + if event.order_id == Some(order_id) { + event.requested_quantity = original_requested; + event.filled_quantity = fill_quantity; + event.status = OrderStatus::PartiallyFilled; + event.reason.push_str(": remaining quantity canceled"); + } + } + } + report + } + + fn process_futures_open_orders(&mut self, date: NaiveDate) -> BrokerExecutionReport { + let pending = std::mem::take(&mut self.futures_open_orders); + let mut combined = BrokerExecutionReport::default(); + for mut order in pending { + order.intent.quantity = order.remaining_quantity; + let report = self.submit_futures_order(date, order.order_id, order.intent, true); + merge_futures_report(&mut combined, report); + } + combined + } + + fn queue_futures_order( + &mut self, + date: NaiveDate, + order_id: u64, + intent: FuturesOrderIntent, + requested_quantity: u32, + filled_quantity: u32, + _from_pending: bool, + reason: &str, + ) -> FuturesExecutionReport { + let mut report = FuturesExecutionReport::default(); + let side = intent.side(); + let limit_price = intent.limit_price.unwrap_or(intent.price); + self.futures_open_orders.push(FuturesOpenOrder { + order_id, + requested_quantity, + filled_quantity, + remaining_quantity: intent.quantity, + limit_price, + reason: format!("{}: {reason}", intent.reason), + intent, + }); + report.order_events.push(OrderEvent { + date, + order_id: Some(order_id), + symbol: self + .futures_open_orders + .last() + .map(|order| order.intent.symbol.clone()) + .unwrap_or_default(), + side, + requested_quantity, + filled_quantity, + status: OrderStatus::Pending, + reason: reason.to_string(), + }); + report.process_events.push(ProcessEvent { + date, + kind: ProcessEventKind::OrderCreationPass, + order_id: Some(order_id), + symbol: self + .futures_open_orders + .last() + .map(|order| order.intent.symbol.clone()), + side: Some(side), + detail: format!("futures pending limit_price={limit_price:.6} reason={reason}"), + }); + report + } + + fn reject_futures_order( + &self, + date: NaiveDate, + order_id: u64, + intent: FuturesOrderIntent, + reason: String, + ) -> FuturesExecutionReport { + let side = intent.side(); + let mut report = FuturesExecutionReport::default(); + report.order_events.push(OrderEvent { + date, + order_id: Some(order_id), + symbol: intent.symbol.clone(), + side, + requested_quantity: intent.quantity, + filled_quantity: 0, + status: OrderStatus::Rejected, + reason: format!( + "{}: {reason} direction={} effect={}", + intent.reason, + intent.direction.as_str(), + intent.effect.as_str() + ), + }); + report.process_events.push(ProcessEvent { + date, + kind: ProcessEventKind::OrderCreationReject, + order_id: Some(order_id), + symbol: Some(intent.symbol), + side: Some(side), + detail: reason, + }); + report + } + + fn validate_futures_submission(&self, intent: &FuturesOrderIntent) -> Option { + if intent.quantity == 0 { + return Some("zero futures quantity".to_string()); + } + if let Some(limit_price) = intent.limit_price { + if !limit_price.is_finite() || limit_price <= 0.0 { + return Some("invalid futures limit price".to_string()); + } + for order in &self.futures_open_orders { + if order.intent.symbol != intent.symbol || order.intent.side() == intent.side() { + continue; + } + let existing_limit = order.limit_price; + let crosses = match intent.side() { + OrderSide::Buy => limit_price >= existing_limit, + OrderSide::Sell => limit_price <= existing_limit, + }; + if crosses { + return Some(format!( + "self-trade risk with futures open order {}", + order.order_id + )); + } + } + } + None + } + + fn resolve_futures_trading_parameters( + &self, + date: NaiveDate, + mut intent: FuturesOrderIntent, + ) -> FuturesOrderIntent { + if let Some(params) = self.data.futures_trading_parameter(date, &intent.symbol) { + intent.spec = params.spec(); + } + intent + } + + fn resolve_futures_transaction_cost( + &self, + date: NaiveDate, + mut intent: FuturesOrderIntent, + ) -> FuturesOrderIntent { + if intent.transaction_cost > 0.0 { + return intent; + } + if let Some(params) = self.data.futures_trading_parameter(date, &intent.symbol) { + let close_today_quantity = self.futures_close_today_quantity(&intent); + intent.transaction_cost = self.futures_cost_model.calculate( + params, + intent.effect, + intent.price, + intent.quantity, + close_today_quantity, + ); + } + intent + } + + fn futures_close_today_quantity(&self, intent: &FuturesOrderIntent) -> u32 { + match intent.effect { + FuturesPositionEffect::Open | FuturesPositionEffect::CloseYesterday => 0, + FuturesPositionEffect::CloseToday => intent.quantity, + FuturesPositionEffect::Close => self + .futures_account + .as_ref() + .and_then(|account| account.position(&intent.symbol, intent.direction)) + .map(|position| intent.quantity.saturating_sub(position.old_quantity)) + .unwrap_or(0), + } + } + + fn resolve_futures_fill( + &self, + date: NaiveDate, + intent: &FuturesOrderIntent, + ) -> Option<(f64, u32)> { + if self.broker.execution_price_field() == PriceField::Last { + if let Some(fill) = self.resolve_futures_intraday_fill(date, intent) { + return Some(fill); + } + } + if let Some(snapshot) = self.data.market(date, &intent.symbol) { + if snapshot.paused { + return None; + } + let price = match self.broker.execution_price_field() { + PriceField::DayOpen => snapshot.day_open, + PriceField::Open => snapshot.open, + PriceField::Close => snapshot.close, + PriceField::Last => match intent.side() { + OrderSide::Buy => snapshot.buy_price(PriceField::Last), + OrderSide::Sell => snapshot.sell_price(PriceField::Last), + }, + }; + if !self.futures_price_can_trade(snapshot, intent.side(), price, intent.limit_price) { + return None; + } + return Some((price, intent.quantity)); + } + if intent.price.is_finite() && intent.price > 0.0 { + if futures_limit_satisfied(intent.side(), intent.price, intent.limit_price) { + return Some((intent.price, intent.quantity)); + } + } + None + } + + fn resolve_futures_intraday_fill( + &self, + date: NaiveDate, + intent: &FuturesOrderIntent, + ) -> Option<(f64, u32)> { + let snapshot = self.data.market(date, &intent.symbol); + let quotes = self.data.execution_quotes_on(date, &intent.symbol); + for quote in quotes { + let price = match self.broker.matching_type() { + MatchingType::NextTickBestOwn => match intent.side() { + OrderSide::Buy => { + if quote.bid1.is_finite() && quote.bid1 > 0.0 { + quote.bid1 + } else { + quote.last_price + } + } + OrderSide::Sell => { + if quote.ask1.is_finite() && quote.ask1 > 0.0 { + quote.ask1 + } else { + quote.last_price + } + } + }, + MatchingType::NextTickBestCounterparty | MatchingType::CounterpartyOffer => { + match intent.side() { + OrderSide::Buy => quote.buy_price().unwrap_or(quote.last_price), + OrderSide::Sell => quote.sell_price().unwrap_or(quote.last_price), + } + } + _ => quote.last_price, + }; + if let Some(snapshot) = snapshot { + if !self.futures_price_can_trade(snapshot, intent.side(), price, intent.limit_price) + { + continue; + } + } else if !futures_limit_satisfied(intent.side(), price, intent.limit_price) { + continue; + } + let top_level_quantity = match intent.side() { + OrderSide::Buy => quote.ask1_volume, + OrderSide::Sell => quote.bid1_volume, + } + .max(quote.volume_delta) + .min(u32::MAX as u64) as u32; + let fill_quantity = if top_level_quantity == 0 { + intent.quantity + } else { + intent.quantity.min(top_level_quantity) + }; + if price.is_finite() && price > 0.0 && fill_quantity > 0 { + return Some((price, fill_quantity)); + } + } + None + } + + fn futures_price_can_trade( + &self, + snapshot: &crate::data::DailyMarketSnapshot, + side: OrderSide, + price: f64, + limit_price: Option, + ) -> bool { + if !price.is_finite() || price <= 0.0 { + return false; + } + if !futures_limit_satisfied(side, price, limit_price) { + return false; + } + match side { + OrderSide::Buy => !snapshot.is_at_upper_limit_price(price), + OrderSide::Sell => !snapshot.is_at_lower_limit_price(price), + } + } + + fn cancel_futures_open_order( + &mut self, + date: NaiveDate, + order_id: u64, + reason: &str, + ) -> FuturesExecutionReport { + let Some(index) = self + .futures_open_orders + .iter() + .position(|order| order.order_id == order_id) + else { + return FuturesExecutionReport::default(); + }; + let order = self.futures_open_orders.remove(index); + futures_cancel_report(date, order, reason) + } + + fn cancel_futures_open_orders_for_symbol( + &mut self, + date: NaiveDate, + symbol: &str, + reason: &str, + ) -> FuturesExecutionReport { + let mut report = FuturesExecutionReport::default(); + let mut retained = Vec::with_capacity(self.futures_open_orders.len()); + let mut canceled = Vec::new(); + for order in self.futures_open_orders.drain(..) { + if order.intent.symbol == symbol { + canceled.push(order); + } else { + retained.push(order); + } + } + self.futures_open_orders = retained; + for order in canceled { + merge_futures_execution_report(&mut report, futures_cancel_report(date, order, reason)); + } + report + } + + fn cancel_all_futures_open_orders( + &mut self, + date: NaiveDate, + reason: &str, + ) -> FuturesExecutionReport { + let mut report = FuturesExecutionReport::default(); + for order in std::mem::take(&mut self.futures_open_orders) { + merge_futures_execution_report(&mut report, futures_cancel_report(date, order, reason)); + } + report + } + pub fn run(&mut self) -> Result { self.run_with_progress(|_| {}) } @@ -580,6 +1212,9 @@ where for (execution_idx, execution_date) in execution_dates.iter().copied().enumerate() { let mut corporate_action_notes = Vec::new(); portfolio.begin_trading_day(); + if let Some(account) = self.futures_account.as_mut() { + account.begin_trading_day(); + } let pending_cash_flow_report = self.settle_pending_cash_flows( execution_date, &mut portfolio, @@ -604,6 +1239,8 @@ where &mut corporate_action_notes, )?; self.extend_result(&mut result, delisting_report); + let futures_open_order_report = self.process_futures_open_orders(execution_date); + self.extend_result(&mut result, futures_open_order_report); let decision_slot = execution_idx .checked_sub(self.config.decision_lag_trading_days) @@ -612,7 +1249,7 @@ where decision_slot.unwrap_or((execution_idx, execution_date)); let mut process_events = Vec::new(); let mut directive_report = BrokerExecutionReport::default(); - let pre_open_orders = self.broker.open_order_views(); + let pre_open_orders = self.open_order_views(); let schedule_rules = self.strategy.schedule_rules(); publish_phase_event( &mut self.strategy, @@ -803,7 +1440,7 @@ where &self.data, &auction_decision, )?; - let post_auction_open_orders = self.broker.open_order_views(); + let post_auction_open_orders = self.open_order_views(); publish_process_events( &mut self.strategy, &mut self.process_event_bus, @@ -854,7 +1491,7 @@ where ProcessEventKind::PreOnDay, "on_day:pre", )?; - let on_day_open_orders = self.broker.open_order_views(); + let on_day_open_orders = self.open_order_views(); let mut decision = decision_slot .map(|(decision_idx, decision_date)| { self.strategy.on_day(&StrategyContext { @@ -916,7 +1553,7 @@ where ProcessEventKind::OnDay, "on_day", )?; - let bar_open_orders = self.broker.open_order_views(); + let bar_open_orders = self.open_order_views(); publish_phase_event( &mut self.strategy, &mut self.process_event_bus, @@ -1004,7 +1641,7 @@ where let mut intraday_report = self.broker .execute(execution_date, &mut portfolio, &self.data, &decision)?; - let post_intraday_open_orders = self.broker.open_order_views(); + let post_intraday_open_orders = self.open_order_views(); publish_process_events( &mut self.strategy, &mut self.process_event_bus, @@ -1074,7 +1711,7 @@ where .collect::>(); for quote in tick_quotes { let tick_time = quote.timestamp.time(); - let tick_open_orders = self.broker.open_order_views(); + let tick_open_orders = self.open_order_views(); publish_phase_event( &mut self.strategy, &mut self.process_event_bus, @@ -1166,7 +1803,7 @@ where Some(tick_time), Some(tick_time), )?; - let post_tick_open_orders = self.broker.open_order_views(); + let post_tick_open_orders = self.open_order_views(); publish_process_events( &mut self.strategy, &mut self.process_event_bus, @@ -1205,7 +1842,7 @@ where portfolio.update_prices(execution_date, &self.data, PriceField::Close)?; - let post_trade_open_orders = self.broker.open_order_views(); + let post_trade_open_orders = self.open_order_views(); let visible_order_events = result .order_events .iter() @@ -1322,7 +1959,7 @@ where report.position_events.extend(close_report.position_events); report.account_events.extend(close_report.account_events); report.diagnostics.extend(close_report.diagnostics); - let post_close_open_orders = self.broker.open_order_views(); + let post_close_open_orders = self.open_order_views(); let visible_order_events_after_close = result .order_events .iter() @@ -1435,6 +2072,8 @@ where &mut settlement_decision, &mut directive_report, )?; + let futures_daily_settlement_report = self.settle_futures_daily(execution_date); + merge_broker_report(&mut directive_report, futures_daily_settlement_report); let futures_expiration_report = self.settle_futures_expirations(execution_date); merge_broker_report(&mut directive_report, futures_expiration_report); let dynamic_universe_snapshot = self.dynamic_universe.clone(); @@ -1495,12 +2134,16 @@ where .join(" | "); let holdings_for_day = portfolio.holdings_summary(execution_date); let day_process_events = process_events.clone(); + let aggregate_initial_cash = self.aggregate_initial_cash(); + let aggregate_cash = self.aggregate_cash(&portfolio); + let aggregate_market_value = self.aggregate_market_value(&portfolio); + let aggregate_total_equity = self.aggregate_total_equity(&portfolio); result.equity_curve.push(DailyEquityPoint { date: execution_date, - cash: portfolio.cash(), - market_value: portfolio.market_value(), - total_equity: portfolio.total_equity(), + cash: aggregate_cash, + market_value: aggregate_market_value, + total_equity: aggregate_total_equity, benchmark_close: benchmark.close, notes, diagnostics, @@ -1515,15 +2158,15 @@ where cash: latest.cash, market_value: latest.market_value, total_equity: latest.total_equity, - unit_nav: if self.config.initial_cash.abs() < f64::EPSILON { + unit_nav: if aggregate_initial_cash.abs() < f64::EPSILON { 0.0 } else { - latest.total_equity / self.config.initial_cash + latest.total_equity / aggregate_initial_cash }, - total_return: if self.config.initial_cash.abs() < f64::EPSILON { + total_return: if aggregate_initial_cash.abs() < f64::EPSILON { 0.0 } else { - (latest.total_equity / self.config.initial_cash) - 1.0 + (latest.total_equity / aggregate_initial_cash) - 1.0 }, benchmark_close: latest.benchmark_close, daily_fill_count, @@ -1546,7 +2189,7 @@ where &result.equity_curve, &result.fills, &result.daily_holdings, - self.config.initial_cash, + self.aggregate_initial_cash(), ); Ok(result) @@ -1886,6 +2529,59 @@ where report } + fn settle_futures_daily(&mut self, date: NaiveDate) -> BrokerExecutionReport { + let mut report = BrokerExecutionReport::default(); + let Some(account) = self.futures_account.as_mut() else { + return report; + }; + let settlement_prices = account + .positions() + .values() + .filter_map(|position| { + self.data + .futures_settlement_price( + date, + &position.symbol, + &self.futures_settlement_price_mode, + ) + .map(|price| (position.symbol.clone(), price)) + }) + .collect::>(); + if settlement_prices.is_empty() { + return report; + } + let cash_before = account.total_cash(); + let cash_delta = account.settle(&settlement_prices); + report.account_events.push(AccountEvent { + date, + cash_before, + cash_after: account.total_cash(), + total_equity: account.total_value(), + note: format!( + "futures_daily_settlement mode={} cash_delta={cash_delta:.2} symbols={}", + self.futures_settlement_price_mode, + settlement_prices + .keys() + .cloned() + .collect::>() + .join(",") + ), + }); + report.process_events.push(ProcessEvent { + date, + kind: ProcessEventKind::Settlement, + order_id: None, + symbol: None, + side: None, + detail: format!( + "futures_daily_settlement mode={} cash_delta={cash_delta:.2} count={}", + self.futures_settlement_price_mode, + settlement_prices.len() + ), + }); + report + } + fn apply_management_fee( &mut self, execution_date: NaiveDate, @@ -2334,6 +3030,70 @@ fn merge_futures_report(target: &mut BrokerExecutionReport, incoming: FuturesExe target.diagnostics.extend(incoming.diagnostics); } +fn merge_futures_execution_report( + target: &mut FuturesExecutionReport, + 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.process_events.extend(incoming.process_events); + target.diagnostics.extend(incoming.diagnostics); +} + +fn futures_limit_satisfied(side: OrderSide, price: f64, limit_price: Option) -> bool { + let Some(limit_price) = limit_price else { + return price.is_finite() && price > 0.0; + }; + if !price.is_finite() || price <= 0.0 || !limit_price.is_finite() || limit_price <= 0.0 { + return false; + } + match side { + OrderSide::Buy => price <= limit_price + 1e-9, + OrderSide::Sell => price + 1e-9 >= limit_price, + } +} + +fn futures_cancel_report( + date: NaiveDate, + order: FuturesOpenOrder, + reason: &str, +) -> FuturesExecutionReport { + let mut report = FuturesExecutionReport::default(); + let side = order.intent.side(); + report.process_events.push(ProcessEvent { + date, + kind: ProcessEventKind::OrderPendingCancel, + order_id: Some(order.order_id), + symbol: Some(order.intent.symbol.clone()), + side: Some(side), + detail: format!("reason={reason}"), + }); + report.order_events.push(OrderEvent { + date, + order_id: Some(order.order_id), + symbol: order.intent.symbol.clone(), + side, + requested_quantity: order.requested_quantity, + filled_quantity: order.filled_quantity, + status: OrderStatus::Canceled, + reason: format!("{reason}: futures order canceled by user"), + }); + report.process_events.push(ProcessEvent { + date, + kind: ProcessEventKind::OrderCancellationPass, + order_id: Some(order.order_id), + symbol: Some(order.intent.symbol), + side: Some(side), + detail: format!( + "requested_quantity={} filled_quantity={} remaining_quantity={}", + order.requested_quantity, order.filled_quantity, order.remaining_quantity + ), + }); + report +} + 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 de9918e..a076eca 100644 --- a/crates/fidc-core/src/futures.rs +++ b/crates/fidc-core/src/futures.rs @@ -1,6 +1,7 @@ use std::collections::BTreeMap; use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; use crate::events::{ AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, @@ -69,6 +70,108 @@ pub struct FuturesContractSpec { pub short_margin_rate: f64, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum FuturesCommissionType { + ByMoney, + ByVolume, +} + +impl FuturesCommissionType { + pub fn parse(value: &str) -> Self { + match value.trim().to_ascii_lowercase().as_str() { + "by_volume" | "volume" | "byvolume" => Self::ByVolume, + _ => Self::ByMoney, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::ByMoney => "by_money", + Self::ByVolume => "by_volume", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FuturesTradingParameter { + pub symbol: String, + pub effective_date: Option, + pub contract_multiplier: f64, + pub long_margin_rate: f64, + pub short_margin_rate: f64, + pub commission_type: FuturesCommissionType, + pub open_commission_ratio: f64, + pub close_commission_ratio: f64, + pub close_today_commission_ratio: f64, + pub price_tick: f64, +} + +impl FuturesTradingParameter { + pub fn spec(&self) -> FuturesContractSpec { + FuturesContractSpec::new( + self.contract_multiplier, + self.long_margin_rate, + self.short_margin_rate, + ) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct FuturesTransactionCostModel { + pub commission_multiplier: f64, +} + +impl Default for FuturesTransactionCostModel { + fn default() -> Self { + Self { + commission_multiplier: 1.0, + } + } +} + +impl FuturesTransactionCostModel { + pub fn calculate( + &self, + params: &FuturesTradingParameter, + effect: FuturesPositionEffect, + price: f64, + quantity: u32, + close_today_quantity: u32, + ) -> f64 { + if quantity == 0 || !price.is_finite() || price <= 0.0 { + return 0.0; + } + let quantity = quantity as f64; + let close_today_quantity = close_today_quantity.min(quantity as u32) as f64; + let close_yesterday_quantity = (quantity - close_today_quantity).max(0.0); + let raw = match params.commission_type { + FuturesCommissionType::ByMoney => match effect { + FuturesPositionEffect::Open => { + price * quantity * params.contract_multiplier * params.open_commission_ratio + } + FuturesPositionEffect::Close + | FuturesPositionEffect::CloseToday + | FuturesPositionEffect::CloseYesterday => { + price + * params.contract_multiplier + * (close_yesterday_quantity * params.close_commission_ratio + + close_today_quantity * params.close_today_commission_ratio) + } + }, + FuturesCommissionType::ByVolume => match effect { + FuturesPositionEffect::Open => quantity * params.open_commission_ratio, + FuturesPositionEffect::Close + | FuturesPositionEffect::CloseToday + | FuturesPositionEffect::CloseYesterday => { + close_yesterday_quantity * params.close_commission_ratio + + close_today_quantity * params.close_today_commission_ratio + } + }, + }; + raw.max(0.0) * self.commission_multiplier.max(0.0) + } +} + #[derive(Debug, Clone)] pub struct FuturesOrderIntent { pub symbol: String, @@ -78,6 +181,8 @@ pub struct FuturesOrderIntent { pub quantity: u32, pub price: f64, pub transaction_cost: f64, + pub limit_price: Option, + pub allow_pending: bool, pub reason: String, } @@ -99,6 +204,8 @@ impl FuturesOrderIntent { quantity, price, transaction_cost, + limit_price: None, + allow_pending: false, reason: reason.into(), } } @@ -121,10 +228,80 @@ impl FuturesOrderIntent { quantity, price, transaction_cost, + limit_price: None, + allow_pending: false, reason: reason.into(), } } + pub fn limit_open( + symbol: impl Into, + direction: FuturesDirection, + spec: FuturesContractSpec, + quantity: u32, + limit_price: f64, + transaction_cost: f64, + reason: impl Into, + ) -> Self { + Self::open( + symbol, + direction, + spec, + quantity, + limit_price, + transaction_cost, + reason, + ) + .with_limit_price(limit_price) + } + + pub fn limit_close( + symbol: impl Into, + direction: FuturesDirection, + effect: FuturesPositionEffect, + spec: FuturesContractSpec, + quantity: u32, + limit_price: f64, + transaction_cost: f64, + reason: impl Into, + ) -> Self { + Self::close( + symbol, + direction, + effect, + spec, + quantity, + limit_price, + transaction_cost, + reason, + ) + .with_limit_price(limit_price) + } + + pub fn with_limit_price(mut self, limit_price: f64) -> Self { + self.limit_price = limit_price + .is_finite() + .then_some(limit_price) + .filter(|v| *v > 0.0); + self.allow_pending = self.limit_price.is_some(); + self + } + + pub fn with_allow_pending(mut self, allow_pending: bool) -> Self { + self.allow_pending = allow_pending; + self + } + + pub fn with_price(mut self, price: f64) -> Self { + self.price = price; + self + } + + pub fn with_transaction_cost(mut self, transaction_cost: f64) -> Self { + self.transaction_cost = transaction_cost; + self + } + pub fn side(&self) -> OrderSide { if self.effect == FuturesPositionEffect::Open { self.direction.open_side() @@ -132,6 +309,29 @@ impl FuturesOrderIntent { self.direction.close_side() } } + + pub fn with_trading_parameter( + mut self, + params: &FuturesTradingParameter, + cost_model: FuturesTransactionCostModel, + ) -> Self { + self.spec = params.spec(); + if self.transaction_cost <= 0.0 { + let close_today_quantity = if self.effect == FuturesPositionEffect::CloseToday { + self.quantity + } else { + 0 + }; + self.transaction_cost = cost_model.calculate( + params, + self.effect, + self.price, + self.quantity, + close_today_quantity, + ); + } + self + } } #[derive(Debug, Clone, Default)] diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 73022fd..3b464ea 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -21,12 +21,13 @@ pub use calendar::TradingCalendar; pub use cost::{ChinaAShareCostModel, CostModel, TradingCost}; pub use data::{ BenchmarkSnapshot, CandidateEligibility, CorporateAction, DailyFactorSnapshot, - DailyMarketSnapshot, DailySnapshotBundle, DataSet, DataSetError, EligibleUniverseSnapshot, - IntradayExecutionQuote, PriceBar, PriceField, + DailyMarketSnapshot, DailySnapshotBundle, DataSet, DataSetError, DividendRecord, + EligibleUniverseSnapshot, FactorValue, IntradayExecutionQuote, PriceBar, PriceField, + SecuritiesMarginRecord, SplitRecord, YieldCurvePoint, }; pub use engine::{ - BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError, BacktestResult, - DailyEquityPoint, + AnalyzerPositionRow, AnalyzerReport, AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, + BacktestEngine, BacktestError, BacktestResult, DailyEquityPoint, }; pub use event_bus::ProcessEventBus; pub use events::{ @@ -34,8 +35,9 @@ pub use events::{ ProcessEventKind, }; pub use futures::{ - FuturesAccountState, FuturesContractSpec, FuturesDirection, FuturesExecutionReport, - FuturesOrderIntent, FuturesPosition, FuturesPositionEffect, + FuturesAccountState, FuturesCommissionType, FuturesContractSpec, FuturesDirection, + FuturesExecutionReport, FuturesOrderIntent, FuturesPosition, FuturesPositionEffect, + FuturesTradingParameter, FuturesTransactionCostModel, }; pub use instrument::Instrument; pub use metrics::{BacktestMetrics, compute_backtest_metrics}; diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 2ba4a16..dce5023 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -7,7 +7,10 @@ use std::sync::OnceLock; use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime}; use crate::cost::ChinaAShareCostModel; -use crate::data::{DailyMarketSnapshot, DataSet, IntradayExecutionQuote, PriceBar, PriceField}; +use crate::data::{ + DailyMarketSnapshot, DataSet, DividendRecord, FactorValue, IntradayExecutionQuote, PriceBar, + PriceField, SecuritiesMarginRecord, SplitRecord, YieldCurvePoint, +}; use crate::engine::BacktestError; use crate::events::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEvent}; use crate::futures::{FuturesAccountState, FuturesOrderIntent}; @@ -601,6 +604,72 @@ impl StrategyContext<'_> { self.data.get_price(symbol, start, end, frequency) } + pub fn get_dividend(&self, symbol: &str, start: NaiveDate) -> Vec { + let end = self + .data + .previous_trading_date(self.execution_date, 1) + .unwrap_or(self.execution_date); + self.data.get_dividend(symbol, start, end) + } + + pub fn get_split(&self, symbol: &str, start: NaiveDate) -> Vec { + let end = self + .data + .previous_trading_date(self.execution_date, 1) + .unwrap_or(self.execution_date); + self.data.get_split(symbol, start, end) + } + + pub fn get_factor( + &self, + symbol: &str, + start: NaiveDate, + end: NaiveDate, + field: &str, + ) -> Vec { + self.data.get_factor(symbol, start, end, field) + } + + pub fn get_yield_curve( + &self, + start: NaiveDate, + end: NaiveDate, + tenor: Option<&str>, + ) -> Vec { + self.data.get_yield_curve(start, end, tenor) + } + + pub fn get_margin_stocks(&self, margin_type: &str) -> Vec { + self.data + .get_margin_stocks(self.execution_date, margin_type) + } + + pub fn get_securities_margin( + &self, + symbol: &str, + start: NaiveDate, + end: NaiveDate, + field: &str, + ) -> Vec { + self.data.get_securities_margin(symbol, start, end, field) + } + + pub fn get_dominant_future(&self, underlying_symbol: &str) -> Option { + self.data + .get_dominant_future(underlying_symbol, self.execution_date) + } + + pub fn get_dominant_future_price( + &self, + underlying_symbol: &str, + start: NaiveDate, + end: NaiveDate, + frequency: &str, + ) -> Vec { + self.data + .get_dominant_future_price(underlying_symbol, start, end, frequency) + } + pub fn has_subscriptions(&self) -> bool { !self.subscriptions.is_empty() } diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index 91d687a..1da9940 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, FuturesOrderIntent, Instrument, - IntradayExecutionQuote, OpenOrderView, OrderIntent, OrderSide, OrderStatus, PortfolioState, - PriceField, ProcessEventKind, ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy, - StrategyContext, StrategyDecision, + FuturesAccountState, FuturesCommissionType, FuturesContractSpec, FuturesDirection, + FuturesOrderIntent, FuturesTradingParameter, Instrument, IntradayExecutionQuote, OpenOrderView, + OrderIntent, OrderSide, OrderStatus, PortfolioState, PriceField, ProcessEventKind, + ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy, StrategyContext, StrategyDecision, }; fn d(year: i32, month: u32, day: u32) -> NaiveDate { @@ -97,6 +97,147 @@ fn single_day_anchor_data(date: NaiveDate) -> DataSet { .expect("dataset") } +fn market_row(date: NaiveDate, symbol: &str, open: f64, close: f64) -> DailyMarketSnapshot { + DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some(format!("{date} 10:18:00")), + day_open: open, + open, + high: open.max(close), + low: open.min(close), + close, + last_price: close, + bid1: close, + ask1: close, + prev_close: open, + 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: open * 1.1, + lower_limit: open * 0.9, + price_tick: 0.2, + } +} + +fn factor_row( + date: NaiveDate, + symbol: &str, + extra_factors: BTreeMap, +) -> DailyFactorSnapshot { + DailyFactorSnapshot { + date, + symbol: symbol.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, + } +} + +fn candidate_row(date: NaiveDate, symbol: &str) -> CandidateEligibility { + CandidateEligibility { + date, + symbol: symbol.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, + } +} + +fn benchmark_row(date: NaiveDate) -> BenchmarkSnapshot { + BenchmarkSnapshot { + date, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + } +} + +fn two_day_futures_data() -> DataSet { + let d1 = d(2025, 1, 2); + let d2 = d(2025, 1, 3); + DataSet::from_components_with_actions_quotes_and_futures( + 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(), + }, + Instrument { + symbol: "IF2501".to_string(), + name: "IF".to_string(), + board: "FUTURE".to_string(), + round_lot: 1, + listed_at: Some(d(2024, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }, + ], + vec![ + market_row(d1, "000001.SZ", 10.0, 10.0), + market_row(d2, "000001.SZ", 10.0, 10.0), + market_row(d1, "IF2501", 4000.0, 4000.0), + market_row(d2, "IF2501", 3988.0, 3990.0), + ], + vec![ + factor_row( + d1, + "000001.SZ", + BTreeMap::from([ + ("custom_alpha".to_string(), 7.0), + ("margin_all".to_string(), 1.0), + ("yield_curve_1y".to_string(), 0.02), + ]), + ), + factor_row( + d2, + "000001.SZ", + BTreeMap::from([ + ("custom_alpha".to_string(), 8.0), + ("margin_all".to_string(), 1.0), + ("yield_curve_1y".to_string(), 0.021), + ]), + ), + ], + vec![ + candidate_row(d1, "000001.SZ"), + candidate_row(d2, "000001.SZ"), + ], + vec![benchmark_row(d1), benchmark_row(d2)], + Vec::new(), + Vec::new(), + vec![FuturesTradingParameter { + symbol: "IF2501".to_string(), + effective_date: Some(d1), + contract_multiplier: 300.0, + long_margin_rate: 0.12, + short_margin_rate: 0.14, + commission_type: FuturesCommissionType::ByVolume, + open_commission_ratio: 2.5, + close_commission_ratio: 2.0, + close_today_commission_ratio: 3.0, + price_tick: 0.2, + }], + ) + .expect("futures dataset") +} + struct HookProbeStrategy { log: Rc>>, } @@ -234,6 +375,73 @@ impl Strategy for FuturesOrderStrategy { } } +struct FuturesLimitOrderStrategy; + +impl Strategy for FuturesLimitOrderStrategy { + fn name(&self) -> &str { + "futures-limit-order" + } + + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + if ctx.execution_date != d(2025, 1, 2) { + return Ok(StrategyDecision::default()); + } + Ok(StrategyDecision { + order_intents: vec![OrderIntent::Futures { + intent: FuturesOrderIntent::limit_open( + "IF2501", + FuturesDirection::Long, + FuturesContractSpec::new(1.0, 0.0, 0.0), + 2, + 3990.0, + 0.0, + "wait for pullback", + ), + }], + ..StrategyDecision::default() + }) + } +} + +struct AdvancedDataApiProbeStrategy { + observed: Rc>>, +} + +impl Strategy for AdvancedDataApiProbeStrategy { + fn name(&self) -> &str { + "data-api-probe" + } + + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + let factors = ctx.get_factor( + "000001.SZ", + ctx.execution_date, + ctx.execution_date, + "custom_alpha", + ); + let margin_stocks = ctx.get_margin_stocks("all"); + let yield_curve = ctx.get_yield_curve(ctx.execution_date, ctx.execution_date, Some("1y")); + let dominant = ctx.get_dominant_future("IF").unwrap_or_default(); + let dominant_prices = + ctx.get_dominant_future_price("IF", ctx.execution_date, ctx.execution_date, "1d"); + self.observed.borrow_mut().push(format!( + "factor={:.0};margin={};yield={:.3};dominant={};prices={}", + factors.first().map(|row| row.value).unwrap_or_default(), + margin_stocks.join(","), + yield_curve.first().map(|row| row.value).unwrap_or_default(), + dominant, + dominant_prices.len() + )); + Ok(StrategyDecision::default()) + } +} + struct ScheduledProbeStrategy { log: Rc>>, process_log: Rc>>, @@ -1098,6 +1306,126 @@ fn engine_settles_configured_futures_expiration_at_settlement() { })); } +#[test] +fn engine_aggregates_futures_account_into_nav_and_metrics() { + let date = d(2025, 1, 2); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + let mut engine = BacktestEngine::new( + single_day_anchor_data(date), + 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_eq!(result.metrics.initial_cash, 600_000.0); + assert!((result.equity_curve[0].total_equity - 599_988.0).abs() < 1e-6); + assert!((result.metrics.total_assets - 599_988.0).abs() < 1e-6); + assert_eq!(result.analyzer_report().trades.len(), result.fills.len()); + assert!( + result + .analyzer_report_json() + .expect("report json") + .contains("\"trades\"") + ); +} + +#[test] +fn engine_matches_pending_futures_limit_order_with_data_driven_costs() { + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + let mut engine = BacktestEngine::new( + two_day_futures_data(), + FuturesLimitOrderStrategy, + broker, + BacktestConfig { + initial_cash: 100_000.0, + benchmark_code: "000300.SH".to_string(), + start_date: Some(d(2025, 1, 2)), + end_date: Some(d(2025, 1, 3)), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Open, + }, + ) + .with_futures_initial_cash(1_000_000.0); + + let result = engine.run().expect("backtest succeeds"); + + assert!( + result + .order_events + .iter() + .any(|event| { event.symbol == "IF2501" && event.status == OrderStatus::Pending }) + ); + assert!(result.order_events.iter().any(|event| { + event.symbol == "IF2501" + && event.status == OrderStatus::Filled + && event.filled_quantity == 2 + })); + let fill = result + .fills + .iter() + .find(|fill| fill.symbol == "IF2501") + .expect("futures fill"); + assert!((fill.price - 3988.0).abs() < 1e-6); + assert!((fill.commission - 5.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, 2); + assert!((position.contract_multiplier - 300.0).abs() < 1e-6); +} + +#[test] +fn strategy_context_exposes_advanced_rqdata_helpers() { + let observed = Rc::new(RefCell::new(Vec::new())); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + let mut engine = BacktestEngine::new( + two_day_futures_data(), + AdvancedDataApiProbeStrategy { + observed: observed.clone(), + }, + broker, + BacktestConfig { + initial_cash: 100_000.0, + benchmark_code: "000300.SH".to_string(), + start_date: Some(d(2025, 1, 2)), + end_date: Some(d(2025, 1, 2)), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Open, + }, + ); + + let result = engine.run().expect("backtest succeeds"); + + assert_eq!( + observed.borrow().as_slice(), + &["factor=7;margin=000001.SZ;yield=0.020;dominant=IF2501;prices=1"] + ); + assert!(result.analyzer_report().positions.is_empty()); +} + #[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 0254c4d..d6f7328 100644 --- a/docs/rqalpha-gap-roadmap.md +++ b/docs/rqalpha-gap-roadmap.md @@ -40,19 +40,19 @@ Confirmed aligned areas: close-today/close-yesterday, daily mark-to-market settlement, expiration settlement, and runtime account views. -Remaining parity gaps found by this pass: +Parity gaps found by this pass and current closure state: | Priority | Gap | RQAlpha capability | Current engine state | Next implementation | | --- | --- | --- | --- | --- | -| P0 | Futures intraday matching | Bar/tick matchers support futures orders through the same broker lifecycle, matching type, slippage, price limit, liquidity limit, volume limit, partial fill, and market-close rejection semantics. | Futures orders execute immediately from `FuturesOrderIntent` with explicit price and cost; they do not flow through the stock broker's pending/open-order matcher. | Add a futures broker/matcher path or generalize `BrokerSimulator` so futures intents can be market/limit/algo orders matched by bar/tick/open auction data. | -| P0 | Futures open-order lifecycle | `SimulationBroker` keeps pending orders, supports `get_open_orders`, cancellation, before-trading activation, tick/bar rematching, and after-trading rejection. | Stock orders have open-order lifecycle; futures orders only emit immediate process/order/fill events. | Add futures pending limit orders, cancellation by id/symbol/all, open-order views, final order lookup, and market-close rejection. | -| P0 | Combined multi-account NAV | RQAlpha portfolio aggregates account values across stock/future accounts. | `StrategyContext.accounts()` exposes both account views, but `DailyEquityPoint`, progress events, metrics, and holdings summary are still stock-portfolio based. | Add aggregate portfolio valuation that includes stock total equity plus futures account total value/margin/PnL, then compute metrics and progress from aggregate NAV. | -| P1 | Futures trading parameter data source | RQAlpha loads contract multiplier, margin ratios, commission type, open/close/close-today commission ratios, settlement/prev-settlement, tick size, listed/de-listed dates, and dominant contracts from data proxy. | `FuturesContractSpec` and `transaction_cost` are passed manually in order intents; there is no data-source-backed trading-parameter resolver. | Add futures instrument/trading-parameter tables and resolver APIs, then let order creation derive spec, margin, tick size, settlement price, and costs automatically. | -| P1 | Futures transaction cost decider | RQAlpha supports by-money/by-volume futures commission with separate open, close, and close-today rates and a commission multiplier. | Current futures execution accepts precomputed transaction cost and stores it; it does not calculate costs from contract metadata. | Implement `FuturesTransactionCostModel` backed by trading parameters and route all futures executions through it. | -| P1 | Futures settlement price mode | RQAlpha can settle futures by `settlement` or `close`, including previous-settlement fields. | Current daily settlement accepts an injected settlement price map and expiration schedule; it does not automatically read settlement/prev_settlement series. | Extend data model with `settlement` and `prev_settlement` fields and support a configurable settlement price mode. | -| P1 | Frontend risk validators for futures | RQAlpha applies cash/margin, position closable, price-limit, trading-status, and self-trade validators before order submission. | Stock path has comparable guardrails; futures margin/position checks exist inside account execution, but submission-time validators and self-trade checks are incomplete. | Add futures-aware validator layer before order acceptance and share diagnostics with order events. | -| P2 | RQData helper APIs | RQAlpha exposes `get_dividend`, `get_split`, `get_yield_curve`, `get_factor`, `get_margin_stocks`, `get_securities_margin`, `get_dominant_future`, and dominant futures price APIs. | Core stock backtest APIs are implemented; these advanced helper APIs are not exposed by `StrategyContext`/DSL. | Add read-only data proxy methods first, then expose stable DSL/strategy functions where data is available. | -| P2 | Analyzer/report parity | RQAlpha analyser can export richer trades, positions, benchmark, monthly returns, risk, and summary artifacts. | Engine returns metrics, equity curve, orders/fills/events/holdings, but not a full RQAlpha-style analyser artifact set. | Add normalized report builders on top of `BacktestResult` without changing execution semantics. | +| P0 | Futures intraday matching | Bar/tick matchers support futures orders through the same broker lifecycle, matching type, slippage, price limit, liquidity limit, volume limit, partial fill, and market-close rejection semantics. | Closed for daily/open/close and tick-price futures fills, including limit checks and partial quantity handling. Full order-book-depth counterparty sweeping remains out of scope unless production strategies require it. | Keep extending matching detail only when real futures tick depth data is available. | +| P0 | Futures open-order lifecycle | `SimulationBroker` keeps pending orders, supports `get_open_orders`, cancellation, before-trading activation, tick/bar rematching, and after-trading rejection. | Closed for futures pending limit orders, cross-day rematching, cancellation by id/symbol/all, and merged open-order runtime views. | Add more order status transitions only if UI requires RQAlpha's exact intermediate event names. | +| P0 | Combined multi-account NAV | RQAlpha portfolio aggregates account values across stock/future accounts. | Closed. `DailyEquityPoint`, progress events, and metrics now use aggregate stock + futures initial cash and total equity. | None. | +| P1 | Futures trading parameter data source | RQAlpha loads contract multiplier, margin ratios, commission type, open/close/close-today commission ratios, settlement/prev-settlement, tick size, listed/de-listed dates, and dominant contracts from data proxy. | Closed for engine-side trading-parameter ingestion/resolution via `futures_trading_parameters.csv` or component data. | Add more exchange metadata columns only when source data exposes them. | +| P1 | Futures transaction cost decider | RQAlpha supports by-money/by-volume futures commission with separate open, close, and close-today rates and a commission multiplier. | Closed. `FuturesTransactionCostModel` calculates by-money/by-volume open/close/close-today costs from trading parameters. | None. | +| P1 | Futures settlement price mode | RQAlpha can settle futures by `settlement` or `close`, including previous-settlement fields. | Closed. Engine supports configurable settlement price mode and resolves settlement/prev-settlement from factor fields with close/prev_close fallback. | Add dedicated settlement columns if the storage layer later separates them from factors. | +| P1 | Frontend risk validators for futures | RQAlpha applies cash/margin, position closable, price-limit, trading-status, and self-trade validators before order submission. | Closed for zero quantity, invalid limit price, self-trade crossing risk, paused/no executable price, price-limit, margin, and close-position rejection diagnostics. | Add exchange-specific validators only as needed. | +| P2 | RQData helper APIs | RQAlpha exposes `get_dividend`, `get_split`, `get_yield_curve`, `get_factor`, `get_margin_stocks`, `get_securities_margin`, `get_dominant_future`, and dominant futures price APIs. | Closed. These APIs are available through `DataSet` and `StrategyContext`, using existing corporate-action, factor, market, and futures-parameter data. | Wire any missing frontend DSL aliases separately if the script layer needs them. | +| P2 | Analyzer/report parity | RQAlpha analyser can export richer trades, positions, benchmark, monthly returns, risk, and summary artifacts. | Closed for normalized trades, positions, equity curve, benchmark series, metrics, and JSON report bundle via `BacktestResult::analyzer_report(_json)`. | UI/service download endpoints can serialize this report directly. | | P3 | Mod/config/plugin architecture | RQAlpha has pluggable mods, event bus extension points, and many config toggles. | Engine has explicit Rust config and event/process records, not a full mod framework. | Only implement toggles required by production strategies; avoid recreating the whole RQAlpha mod system unless needed. | ## Remaining Gaps @@ -143,30 +143,30 @@ Remaining parity gaps found by this pass: positions at settlement price - [x] data-driven futures expiration schedule in `BacktestEngine` settlement phase -- [ ] futures intraday matching integration -- [ ] futures pending/open-order lifecycle and cancellation parity -- [ ] aggregate multi-account NAV/metrics/progress across stock and futures -- [ ] futures trading-parameter data source and automatic cost/margin resolver -- [ ] futures settlement/prev-settlement data integration and settlement mode -- [ ] futures-aware submission validators and self-trade checks +- [x] futures intraday matching integration +- [x] futures pending/open-order lifecycle and cancellation parity +- [x] aggregate multi-account NAV/metrics/progress across stock and futures +- [x] futures trading-parameter data source and automatic cost/margin resolver +- [x] futures settlement/prev-settlement data integration and settlement mode +- [x] futures-aware submission validators and self-trade checks ### Phase 10: Advanced data API parity -- [ ] `get_dividend` -- [ ] `get_split` -- [ ] `get_yield_curve` -- [ ] `get_factor` -- [ ] `get_margin_stocks` -- [ ] `get_securities_margin` -- [ ] `get_dominant_future` -- [ ] futures dominant price helpers +- [x] `get_dividend` +- [x] `get_split` +- [x] `get_yield_curve` +- [x] `get_factor` +- [x] `get_margin_stocks` +- [x] `get_securities_margin` +- [x] `get_dominant_future` +- [x] futures dominant price helpers ### Phase 11: Analyzer / report parity -- [ ] RQAlpha-style normalized trades report -- [ ] RQAlpha-style normalized positions report -- [ ] benchmark / monthly returns / risk summary artifacts -- [ ] downloadable analyser output bundle +- [x] RQAlpha-style normalized trades report +- [x] RQAlpha-style normalized positions report +- [x] benchmark / monthly returns / risk summary artifacts +- [x] downloadable analyser output bundle ## Execution Order @@ -189,6 +189,6 @@ Remaining parity gaps found by this pass: ## Current Step -Active implementation target: close the P0 gaps found by the 2026-04-24 -re-audit. The next code target should be aggregate multi-account NAV/metrics, -followed by futures intraday matching and futures pending-order lifecycle. +Active implementation target: P0-P2 parity items are implemented in the engine +core. Remaining future work should be driven by concrete production strategy or +UI requirements rather than recreating RQAlpha's full plugin/mod framework.