增加通用期货策略动作并修正组合净值

This commit is contained in:
boris
2026-08-25 17:10:17 +08:00
parent 90da7f8a21
commit c284cc191e
5 changed files with 408 additions and 15 deletions
+27 -2
View File
@@ -1127,6 +1127,25 @@ where
.unwrap_or(0.0)
}
fn aggregate_unit_net_value(&self, portfolio: &PortfolioState) -> Result<f64, BacktestError> {
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();
+17
View File
@@ -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)]
+128 -2
View File
@@ -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<String>,
reason: String,
},
Futures {
symbol: String,
direction: FuturesDirection,
effect: FuturesPositionEffect,
quantity_expr: String,
limit_price_expr: Option<String>,
transaction_cost_expr: Option<String>,
when_expr: Option<String>,
reason: String,
},
Cancel {
kind: PlatformExplicitCancelKind,
symbol: Option<String>,
@@ -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<String>,
) -> 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<String>,
) -> 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<String>,
+161 -6
View File
@@ -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<StrategyExpressionScheduleConfig>,
#[serde(default)]
pub rotation_enabled: Option<bool>,
#[serde(default, alias = "stock_initial_cash")]
pub stock_initial_cash: Option<f64>,
#[serde(default, alias = "futures_initial_cash")]
pub futures_initial_cash: Option<f64>,
#[serde(default)]
pub daily_top_up: Option<bool>,
#[serde(default)]
@@ -773,6 +778,14 @@ pub struct StrategyExpressionActionConfig {
#[serde(default)]
pub amount_expr: Option<String>,
#[serde(default)]
pub direction: Option<String>,
#[serde(default)]
pub effect: Option<String>,
#[serde(default, alias = "quantity_expr")]
pub quantity_expr: Option<String>,
#[serde(default, alias = "transaction_cost_expr")]
pub transaction_cost_expr: Option<String>,
#[serde(default)]
pub start_time_expr: Option<String>,
#[serde(default)]
pub end_time_expr: Option<String>,
@@ -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!({
+75 -5
View File
@@ -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::<Vec<_>>();
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!(