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