增加通用期货策略动作并修正组合净值
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user