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

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) .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( fn submit_futures_order(
&mut self, &mut self,
date: NaiveDate, date: NaiveDate,
@@ -1345,6 +1364,12 @@ where
if intent.quantity == 0 { if intent.quantity == 0 {
return Some("zero futures quantity".to_string()); 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 self.futures_validation_config.enforce_active_instrument {
if let Some(instrument) = self.data.instrument(&intent.symbol) { if let Some(instrument) = self.data.instrument(&intent.symbol) {
if !instrument.is_active_on(date) { if !instrument.is_active_on(date) {
@@ -1927,7 +1952,7 @@ where
let aggregate_cash = self.aggregate_cash(&portfolio); let aggregate_cash = self.aggregate_cash(&portfolio);
let aggregate_market_value = self.aggregate_market_value(&portfolio); let aggregate_market_value = self.aggregate_market_value(&portfolio);
let aggregate_total_equity = self.aggregate_total_equity(&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 = let external_cash_flow =
portfolio.external_cash_flow_total() - previous_external_cash_flow_total; portfolio.external_cash_flow_total() - previous_external_cash_flow_total;
previous_external_cash_flow_total = portfolio.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_cash = self.aggregate_cash(&portfolio);
let aggregate_market_value = self.aggregate_market_value(&portfolio); let aggregate_market_value = self.aggregate_market_value(&portfolio);
let aggregate_total_equity = self.aggregate_total_equity(&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 = let external_cash_flow =
portfolio.external_cash_flow_total() - previous_external_cash_flow_total; portfolio.external_cash_flow_total() - previous_external_cash_flow_total;
previous_external_cash_flow_total = portfolio.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 { 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 { pub fn new(contract_multiplier: f64, long_margin_rate: f64, short_margin_rate: f64) -> Self {
Self { Self {
contract_multiplier: contract_multiplier.max(1.0), contract_multiplier: contract_multiplier.max(1.0),
@@ -377,6 +385,15 @@ impl FuturesContractSpec {
FuturesDirection::Short => self.short_margin_rate, 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)] #[derive(Debug, Clone)]
+128 -2
View File
@@ -15,6 +15,9 @@ use crate::data::{
use crate::engine::BacktestError; use crate::engine::BacktestError;
use crate::events::OrderSide; use crate::events::OrderSide;
use crate::fixed_point::FixedMoney; use crate::fixed_point::FixedMoney;
use crate::futures::{
FuturesContractSpec, FuturesDirection, FuturesOrderIntent, FuturesPositionEffect,
};
use crate::numeric_expr_vm::{ use crate::numeric_expr_vm::{
self, EvalError as NumericVmEvalError, Program as NumericVmProgram, self, EvalError as NumericVmEvalError, Program as NumericVmProgram,
Scratch as NumericVmScratch, Value as NumericVmValue, ValueType as NumericVmValueType, Scratch as NumericVmScratch, Value as NumericVmValue, ValueType as NumericVmValueType,
@@ -320,6 +323,16 @@ pub enum PlatformTradeAction {
when_expr: Option<String>, when_expr: Option<String>,
reason: 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 { Cancel {
kind: PlatformExplicitCancelKind, kind: PlatformExplicitCancelKind,
symbol: Option<String>, 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 { PlatformTradeAction::Cancel {
order_id_expr, order_id_expr,
when_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 { PlatformTradeAction::Cancel {
kind, kind,
symbol, symbol,
@@ -9340,7 +9456,7 @@ impl PlatformExprStrategy {
config: &PlatformExprStrategyConfig, config: &PlatformExprStrategyConfig,
prelude_declared_identifiers: &BTreeSet<String>, prelude_declared_identifiers: &BTreeSet<String>,
) -> bool { ) -> bool {
if !config.explicit_actions.is_empty() { if Self::has_stock_explicit_actions(config) {
return true; return true;
} }
if Self::stock_field_may_use_extra_factors(&config.market_cap_field) if Self::stock_field_may_use_extra_factors(&config.market_cap_field)
@@ -9419,7 +9535,7 @@ impl PlatformExprStrategy {
normalized_stock_filter_expr: &str, normalized_stock_filter_expr: &str,
prelude_declared_identifiers: &BTreeSet<String>, prelude_declared_identifiers: &BTreeSet<String>,
) -> bool { ) -> bool {
if !config.explicit_actions.is_empty() { if Self::has_stock_explicit_actions(config) {
return true; return true;
} }
[ [
@@ -9436,6 +9552,16 @@ impl PlatformExprStrategy {
.any(|expr| Self::expr_may_use_stock_text_factors(expr, prelude_declared_identifiers)) .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( fn expr_may_use_stock_text_factors(
expr: &str, expr: &str,
prelude_declared_identifiers: &BTreeSet<String>, prelude_declared_identifiers: &BTreeSet<String>,
+161 -6
View File
@@ -9,7 +9,8 @@ use crate::{
PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategyConfig, PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategyConfig,
PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency,
PlatformStopTakeReferencePriceMode, PlatformTradeAction, PlatformUniverseActionKind, PlatformStopTakeReferencePriceMode, PlatformTradeAction, PlatformUniverseActionKind,
RebalanceCashMode, ScheduleTimeRule, SlippageModel, RebalanceCashMode, ScheduleTimeRule, SlippageModel, futures::FuturesDirection,
futures::FuturesPositionEffect,
}; };
#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
@@ -721,6 +722,10 @@ pub struct StrategyExpressionTradingConfig {
pub schedule: Option<StrategyExpressionScheduleConfig>, pub schedule: Option<StrategyExpressionScheduleConfig>,
#[serde(default)] #[serde(default)]
pub rotation_enabled: Option<bool>, 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)] #[serde(default)]
pub daily_top_up: Option<bool>, pub daily_top_up: Option<bool>,
#[serde(default)] #[serde(default)]
@@ -773,6 +778,14 @@ pub struct StrategyExpressionActionConfig {
#[serde(default)] #[serde(default)]
pub amount_expr: Option<String>, pub amount_expr: Option<String>,
#[serde(default)] #[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>, pub start_time_expr: Option<String>,
#[serde(default)] #[serde(default)]
pub end_time_expr: Option<String>, pub end_time_expr: Option<String>,
@@ -1884,11 +1897,17 @@ pub fn platform_expr_config_from_spec(
{ {
cfg.intraday_execution_time = Some(time); cfg.intraday_execution_time = Some(time);
} }
cfg.explicit_actions = trading let mut explicit_actions = Vec::with_capacity(trading.actions.len());
.actions for (index, action) in trading.actions.iter().enumerate() {
.iter() let parsed = parse_platform_trade_action(action).ok_or_else(|| {
.filter_map(parse_platform_trade_action) format!(
.collect(); "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() { } else if let Some(engine) = spec.engine_config.as_ref() {
if let Some(dynamic_range) = engine.dynamic_range.as_ref() { if let Some(dynamic_range) = engine.dynamic_range.as_ref() {
@@ -2180,6 +2199,71 @@ fn parse_platform_trade_action(
when_expr, when_expr,
reason, 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" "shares"
| "limit_shares" | "limit_shares"
| "lots" | "lots"
@@ -2557,6 +2641,77 @@ mod tests {
assert_eq!(cfg.explicit_actions.len(), 1); 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] #[test]
fn runtime_expression_parser_does_not_inherit_microcap_template_defaults() { fn runtime_expression_parser_does_not_inherit_microcap_template_defaults() {
let spec = serde_json::json!({ let spec = serde_json::json!({
+75 -5
View File
@@ -9,11 +9,12 @@ use fidc_core::{
BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel,
ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, ExecutionQuoteRequest, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, ExecutionQuoteRequest,
FuturesAccountState, FuturesCommissionType, FuturesContractSpec, FuturesDirection, FuturesAccountState, FuturesCommissionType, FuturesContractSpec, FuturesDirection,
FuturesOrderIntent, FuturesTradingParameter, FuturesValidationConfig, Instrument, FuturesOrderIntent, FuturesPositionEffect, FuturesTradingParameter, FuturesValidationConfig,
IntradayExecutionQuote, IntradayOrderBookDepthLevel, MatchingType, OpenOrderView, OrderIntent, Instrument, IntradayExecutionQuote, IntradayOrderBookDepthLevel, MatchingType, OpenOrderView,
OrderSide, OrderStatus, PlatformExprStrategy, PlatformExprStrategyConfig, PortfolioState, OrderIntent, OrderSide, OrderStatus, PlatformExprStrategy, PlatformExprStrategyConfig,
PriceField, ProcessEvent, ProcessEventBus, ProcessEventKind, ScheduleRule, ScheduleStage, PlatformTradeAction, PortfolioState, PriceField, ProcessEvent, ProcessEventBus,
ScheduleTimeRule, Strategy, StrategyContext, StrategyDecision, ProcessEventKind, ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy, StrategyContext,
StrategyDecision,
}; };
fn d(year: i32, month: u32, day: u32) -> NaiveDate { 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); 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] #[test]
fn engine_settles_configured_futures_expiration_at_settlement() { fn engine_settles_configured_futures_expiration_at_settlement() {
let date = d(2025, 1, 2); 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_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].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_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().trades.len(), result.fills.len());
assert_eq!(result.analyzer_report().monthly_returns.len(), 1); assert_eq!(result.analyzer_report().monthly_returns.len(), 1);
assert_eq!( assert_eq!(