diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 24ec5d3..be7bf87 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -1127,6 +1127,25 @@ where .unwrap_or(0.0) } + fn aggregate_unit_net_value(&self, portfolio: &PortfolioState) -> Result { + if self.futures_account.is_none() { + return Ok(portfolio.unit_net_value()); + } + if portfolio.external_cash_flow_total().abs() > 1e-9 { + return Err(BacktestError::Execution( + "mixed stock/futures external cash flows require an aggregate unit ledger" + .to_string(), + )); + } + let initial_cash = self.aggregate_initial_cash(); + if !initial_cash.is_finite() || initial_cash <= 0.0 { + return Err(BacktestError::Execution( + "aggregate initial cash must be positive for stock/futures NAV".to_string(), + )); + } + Ok(self.aggregate_total_equity(portfolio) / initial_cash) + } + fn submit_futures_order( &mut self, date: NaiveDate, @@ -1345,6 +1364,12 @@ where if intent.quantity == 0 { return Some("zero futures quantity".to_string()); } + if !intent.spec.is_resolved() { + return Some(format!( + "missing futures trading parameters symbol={} date={date}", + intent.symbol + )); + } if self.futures_validation_config.enforce_active_instrument { if let Some(instrument) = self.data.instrument(&intent.symbol) { if !instrument.is_active_on(date) { @@ -1927,7 +1952,7 @@ where 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); - let unit_nav = portfolio.unit_net_value(); + let unit_nav = self.aggregate_unit_net_value(&portfolio)?; let external_cash_flow = portfolio.external_cash_flow_total() - previous_external_cash_flow_total; previous_external_cash_flow_total = portfolio.external_cash_flow_total(); @@ -3000,7 +3025,7 @@ where 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); - let unit_nav = portfolio.unit_net_value(); + let unit_nav = self.aggregate_unit_net_value(&portfolio)?; let external_cash_flow = portfolio.external_cash_flow_total() - previous_external_cash_flow_total; previous_external_cash_flow_total = portfolio.external_cash_flow_total(); diff --git a/crates/fidc-core/src/futures.rs b/crates/fidc-core/src/futures.rs index de5c858..5b412b7 100644 --- a/crates/fidc-core/src/futures.rs +++ b/crates/fidc-core/src/futures.rs @@ -363,6 +363,14 @@ pub struct FuturesExecutionReport { } impl FuturesContractSpec { + pub fn unresolved() -> Self { + Self { + contract_multiplier: f64::NAN, + long_margin_rate: f64::NAN, + short_margin_rate: f64::NAN, + } + } + pub fn new(contract_multiplier: f64, long_margin_rate: f64, short_margin_rate: f64) -> Self { Self { contract_multiplier: contract_multiplier.max(1.0), @@ -377,6 +385,15 @@ impl FuturesContractSpec { FuturesDirection::Short => self.short_margin_rate, } } + + pub fn is_resolved(&self) -> bool { + self.contract_multiplier.is_finite() + && self.contract_multiplier > 0.0 + && self.long_margin_rate.is_finite() + && self.long_margin_rate >= 0.0 + && self.short_margin_rate.is_finite() + && self.short_margin_rate >= 0.0 + } } #[derive(Debug, Clone)] diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index ac823e2..0ff1c64 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -15,6 +15,9 @@ use crate::data::{ use crate::engine::BacktestError; use crate::events::OrderSide; use crate::fixed_point::FixedMoney; +use crate::futures::{ + FuturesContractSpec, FuturesDirection, FuturesOrderIntent, FuturesPositionEffect, +}; use crate::numeric_expr_vm::{ self, EvalError as NumericVmEvalError, Program as NumericVmProgram, Scratch as NumericVmScratch, Value as NumericVmValue, ValueType as NumericVmValueType, @@ -320,6 +323,16 @@ pub enum PlatformTradeAction { when_expr: Option, reason: String, }, + Futures { + symbol: String, + direction: FuturesDirection, + effect: FuturesPositionEffect, + quantity_expr: String, + limit_price_expr: Option, + transaction_cost_expr: Option, + when_expr: Option, + reason: String, + }, Cancel { kind: PlatformExplicitCancelKind, symbol: Option, @@ -1423,6 +1436,28 @@ impl PlatformExprStrategy { } } } + PlatformTradeAction::Futures { + quantity_expr, + limit_price_expr, + transaction_cost_expr, + when_expr, + .. + } => { + expressions.push(( + format!("explicit_actions[{index}].quantity_expr"), + quantity_expr, + )); + for (name, expression) in [ + ("limit_price_expr", limit_price_expr.as_deref()), + ("transaction_cost_expr", transaction_cost_expr.as_deref()), + ("when_expr", when_expr.as_deref()), + ] { + if let Some(expression) = expression { + expressions + .push((format!("explicit_actions[{index}].{name}"), expression)); + } + } + } PlatformTradeAction::Cancel { order_id_expr, when_expr, @@ -8203,6 +8238,87 @@ impl PlatformExprStrategy { } } } + PlatformTradeAction::Futures { + symbol, + direction, + effect, + quantity_expr, + limit_price_expr, + transaction_cost_expr, + when_expr, + reason, + } => { + if !self.action_when_matches(ctx, day, None, when_expr.as_deref())? { + continue; + } + if self.config.subscription_guard_required && !ctx.is_subscribed(symbol) { + diagnostics.push(format!( + "subscription_guard_denied symbol={} action=futures effect={}", + symbol, + effect.as_str() + )); + continue; + } + let quantity = self.eval_i32(ctx, quantity_expr, day, None, None)?; + if quantity == 0 { + continue; + } + if quantity < 0 { + return Err(BacktestError::Execution(format!( + "futures quantity must be non-negative symbol={symbol} quantity={quantity}" + ))); + } + let limit_price = limit_price_expr + .as_deref() + .map(|expr| self.eval_float(ctx, expr, day, None, None)) + .transpose()?; + if limit_price.is_some_and(|value| !value.is_finite() || value <= 0.0) { + return Err(BacktestError::Execution(format!( + "futures limit price must be positive symbol={symbol}" + ))); + } + let transaction_cost = transaction_cost_expr + .as_deref() + .map(|expr| self.eval_float(ctx, expr, day, None, None)) + .transpose()? + .unwrap_or(0.0); + if !transaction_cost.is_finite() || transaction_cost < 0.0 { + return Err(BacktestError::Execution(format!( + "futures transaction cost must be non-negative symbol={symbol}" + ))); + } + let price = limit_price.unwrap_or(0.0); + let intent = match effect { + FuturesPositionEffect::Open => FuturesOrderIntent::open( + symbol.clone(), + *direction, + FuturesContractSpec::unresolved(), + quantity as u32, + price, + transaction_cost, + reason.clone(), + ), + FuturesPositionEffect::Close + | FuturesPositionEffect::CloseToday + | FuturesPositionEffect::CloseYesterday => FuturesOrderIntent::close( + symbol.clone(), + *direction, + *effect, + FuturesContractSpec::unresolved(), + quantity as u32, + price, + transaction_cost, + reason.clone(), + ), + }; + intents.push(OrderIntent::Futures { + intent: if let Some(limit_price) = limit_price { + intent.with_limit_price(limit_price) + } else { + intent + }, + }); + } PlatformTradeAction::Cancel { kind, symbol, @@ -9340,7 +9456,7 @@ impl PlatformExprStrategy { config: &PlatformExprStrategyConfig, prelude_declared_identifiers: &BTreeSet, ) -> bool { - if !config.explicit_actions.is_empty() { + if Self::has_stock_explicit_actions(config) { return true; } if Self::stock_field_may_use_extra_factors(&config.market_cap_field) @@ -9419,7 +9535,7 @@ impl PlatformExprStrategy { normalized_stock_filter_expr: &str, prelude_declared_identifiers: &BTreeSet, ) -> bool { - if !config.explicit_actions.is_empty() { + if Self::has_stock_explicit_actions(config) { return true; } [ @@ -9436,6 +9552,16 @@ impl PlatformExprStrategy { .any(|expr| Self::expr_may_use_stock_text_factors(expr, prelude_declared_identifiers)) } + fn has_stock_explicit_actions(config: &PlatformExprStrategyConfig) -> bool { + config.explicit_actions.iter().any(|action| { + matches!( + action, + PlatformTradeAction::Order { .. } + | PlatformTradeAction::TargetPortfolioSmart { .. } + ) + }) + } + fn expr_may_use_stock_text_factors( expr: &str, prelude_declared_identifiers: &BTreeSet, diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 40eded8..63207e7 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -9,7 +9,8 @@ use crate::{ PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategyConfig, PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode, PlatformTradeAction, PlatformUniverseActionKind, - RebalanceCashMode, ScheduleTimeRule, SlippageModel, + RebalanceCashMode, ScheduleTimeRule, SlippageModel, futures::FuturesDirection, + futures::FuturesPositionEffect, }; #[derive(Debug, Clone, Default, Deserialize, Serialize)] @@ -721,6 +722,10 @@ pub struct StrategyExpressionTradingConfig { pub schedule: Option, #[serde(default)] pub rotation_enabled: Option, + #[serde(default, alias = "stock_initial_cash")] + pub stock_initial_cash: Option, + #[serde(default, alias = "futures_initial_cash")] + pub futures_initial_cash: Option, #[serde(default)] pub daily_top_up: Option, #[serde(default)] @@ -773,6 +778,14 @@ pub struct StrategyExpressionActionConfig { #[serde(default)] pub amount_expr: Option, #[serde(default)] + pub direction: Option, + #[serde(default)] + pub effect: Option, + #[serde(default, alias = "quantity_expr")] + pub quantity_expr: Option, + #[serde(default, alias = "transaction_cost_expr")] + pub transaction_cost_expr: Option, + #[serde(default)] pub start_time_expr: Option, #[serde(default)] pub end_time_expr: Option, @@ -1884,11 +1897,17 @@ pub fn platform_expr_config_from_spec( { cfg.intraday_execution_time = Some(time); } - cfg.explicit_actions = trading - .actions - .iter() - .filter_map(parse_platform_trade_action) - .collect(); + let mut explicit_actions = Vec::with_capacity(trading.actions.len()); + for (index, action) in trading.actions.iter().enumerate() { + let parsed = parse_platform_trade_action(action).ok_or_else(|| { + format!( + "runtimeExpressions.trading.actions[{index}] is invalid or unsupported kind={}", + action.kind.as_deref().unwrap_or("") + ) + })?; + explicit_actions.push(parsed); + } + cfg.explicit_actions = explicit_actions; } } else if let Some(engine) = spec.engine_config.as_ref() { if let Some(dynamic_range) = engine.dynamic_range.as_ref() { @@ -2180,6 +2199,71 @@ fn parse_platform_trade_action( when_expr, reason, }), + "futures_order" + | "futures_open" + | "futures_close" + | "futures_close_today" + | "futures_close_yesterday" => { + let symbol = action + .symbol + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty())? + .to_ascii_uppercase(); + let direction = match action + .direction + .as_deref() + .map(str::trim) + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "long" => FuturesDirection::Long, + "short" => FuturesDirection::Short, + _ => return None, + }; + let effect_name = match kind.as_str() { + "futures_open" => "open", + "futures_close" => "close", + "futures_close_today" => "close_today", + "futures_close_yesterday" => "close_yesterday", + _ => action.effect.as_deref()?.trim(), + }; + let effect = match effect_name.to_ascii_lowercase().as_str() { + "open" => FuturesPositionEffect::Open, + "close" => FuturesPositionEffect::Close, + "close_today" | "close-today" => FuturesPositionEffect::CloseToday, + "close_yesterday" | "close-yesterday" => FuturesPositionEffect::CloseYesterday, + _ => return None, + }; + let quantity_expr = action + .quantity_expr + .as_deref() + .or(action.amount_expr.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty())? + .to_string(); + Some(PlatformTradeAction::Futures { + symbol, + direction, + effect, + quantity_expr, + limit_price_expr: action + .limit_price_expr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string), + transaction_cost_expr: action + .transaction_cost_expr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string), + when_expr, + reason, + }) + } "shares" | "limit_shares" | "lots" @@ -2557,6 +2641,77 @@ mod tests { assert_eq!(cfg.explicit_actions.len(), 1); } + #[test] + fn parses_generic_futures_actions_and_rejects_incomplete_contracts() { + let spec = serde_json::json!({ + "runtimeExpressions": { + "trading": { + "rotationEnabled": false, + "actions": [ + { + "kind": "futures_order", + "symbol": "if2509.ccfx", + "direction": "long", + "effect": "open", + "quantityExpr": "2", + "limitPriceExpr": "4010.2", + "transactionCostExpr": "0", + "whenExpr": "year >= 2025", + "reason": "open index hedge" + }, + { + "kind": "futures_close_today", + "symbol": "IF2509.CCFX", + "direction": "short", + "amountExpr": "1", + "reason": "close intraday hedge" + } + ] + } + } + }); + + let cfg = platform_expr_config_from_value("futures", "000300.SH", &spec).expect("config"); + + assert_eq!(cfg.explicit_actions.len(), 2); + assert!(matches!( + &cfg.explicit_actions[0], + PlatformTradeAction::Futures { + symbol, + direction: FuturesDirection::Long, + effect: FuturesPositionEffect::Open, + quantity_expr, + limit_price_expr: Some(limit_price), + .. + } if symbol == "IF2509.CCFX" && quantity_expr == "2" && limit_price == "4010.2" + )); + assert!(matches!( + &cfg.explicit_actions[1], + PlatformTradeAction::Futures { + direction: FuturesDirection::Short, + effect: FuturesPositionEffect::CloseToday, + quantity_expr, + .. + } if quantity_expr == "1" + )); + + let invalid = serde_json::json!({ + "runtimeExpressions": { + "trading": { + "rotationEnabled": false, + "actions": [{ + "kind": "futures_open", + "symbol": "IF2509.CCFX", + "quantityExpr": "1" + }] + } + } + }); + let error = platform_expr_config_from_value("futures", "000300.SH", &invalid) + .expect_err("missing direction must fail"); + assert!(error.to_string().contains("actions[0] is invalid")); + } + #[test] fn runtime_expression_parser_does_not_inherit_microcap_template_defaults() { let spec = serde_json::json!({ diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index 7a9db43..863010c 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -9,11 +9,12 @@ use fidc_core::{ BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, ExecutionQuoteRequest, FuturesAccountState, FuturesCommissionType, FuturesContractSpec, FuturesDirection, - FuturesOrderIntent, FuturesTradingParameter, FuturesValidationConfig, Instrument, - IntradayExecutionQuote, IntradayOrderBookDepthLevel, MatchingType, OpenOrderView, OrderIntent, - OrderSide, OrderStatus, PlatformExprStrategy, PlatformExprStrategyConfig, PortfolioState, - PriceField, ProcessEvent, ProcessEventBus, ProcessEventKind, ScheduleRule, ScheduleStage, - ScheduleTimeRule, Strategy, StrategyContext, StrategyDecision, + FuturesOrderIntent, FuturesPositionEffect, FuturesTradingParameter, FuturesValidationConfig, + Instrument, IntradayExecutionQuote, IntradayOrderBookDepthLevel, MatchingType, OpenOrderView, + OrderIntent, OrderSide, OrderStatus, PlatformExprStrategy, PlatformExprStrategyConfig, + PlatformTradeAction, PortfolioState, PriceField, ProcessEvent, ProcessEventBus, + ProcessEventKind, ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy, StrategyContext, + StrategyDecision, }; fn d(year: i32, month: u32, day: u32) -> NaiveDate { @@ -1475,6 +1476,73 @@ fn engine_executes_futures_order_intents_against_future_account() { assert!((futures_account.cash() - 355_988.0).abs() < 1e-6); } +#[test] +fn platform_runtime_actions_execute_generic_futures_open_and_close() { + let mut cfg = PlatformExprStrategyConfig::generic(); + cfg.signal_symbol = "000001.SZ".to_string(); + cfg.benchmark_symbol = "000300.SH".to_string(); + cfg.rotation_enabled = false; + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + cfg.explicit_actions = vec![ + PlatformTradeAction::Futures { + symbol: "IF2501".to_string(), + direction: FuturesDirection::Long, + effect: FuturesPositionEffect::Open, + quantity_expr: "1".to_string(), + limit_price_expr: None, + transaction_cost_expr: None, + when_expr: Some("decision_date == \"2025-01-02\"".to_string()), + reason: "generic futures open".to_string(), + }, + PlatformTradeAction::Futures { + symbol: "IF2501".to_string(), + direction: FuturesDirection::Long, + effect: FuturesPositionEffect::Close, + quantity_expr: "1".to_string(), + limit_price_expr: None, + transaction_cost_expr: None, + when_expr: Some("decision_date == \"2025-01-03\"".to_string()), + reason: "generic futures close".to_string(), + }, + ]; + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + let mut engine = BacktestEngine::new( + two_day_futures_data(), + PlatformExprStrategy::new(cfg), + 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(500_000.0); + + let result = engine.run().expect("generic futures actions execute"); + + let futures_fills = result + .fills + .iter() + .filter(|fill| fill.symbol == "IF2501") + .collect::>(); + assert_eq!(futures_fills.len(), 2); + assert!((futures_fills[0].price - 4000.0).abs() < 1e-12); + assert!((futures_fills[0].commission - 2.5).abs() < 1e-12); + assert!((futures_fills[1].price - 3988.0).abs() < 1e-12); + assert!((futures_fills[1].commission - 2.0).abs() < 1e-12); + let futures_account = engine.futures_account().expect("future account"); + assert!(futures_account.positions().is_empty()); + assert!((futures_account.total_cash() - 496_395.5).abs() < 1e-12); +} + #[test] fn engine_settles_configured_futures_expiration_at_settlement() { let date = d(2025, 1, 2); @@ -1550,7 +1618,9 @@ fn engine_aggregates_futures_account_into_nav_and_metrics() { assert_eq!(result.metrics.initial_cash, 600_000.0); assert!((result.equity_curve[0].total_equity - 599_988.0).abs() < 1e-6); + assert!((result.equity_curve[0].unit_nav - 0.99998).abs() < 1e-12); assert!((result.metrics.total_assets - 599_988.0).abs() < 1e-6); + assert!((result.metrics.total_return + 0.00002).abs() < 1e-12); assert_eq!(result.analyzer_report().trades.len(), result.fills.len()); assert_eq!(result.analyzer_report().monthly_returns.len(), 1); assert_eq!(