Add futures exchange validators
This commit is contained in:
@@ -48,6 +48,25 @@ pub struct BacktestConfig {
|
||||
pub execution_price_field: PriceField,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FuturesValidationConfig {
|
||||
pub enforce_active_instrument: bool,
|
||||
pub enforce_trading_phase: bool,
|
||||
pub enforce_limit_price_tick: bool,
|
||||
pub enforce_price_limits: bool,
|
||||
}
|
||||
|
||||
impl Default for FuturesValidationConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enforce_active_instrument: true,
|
||||
enforce_trading_phase: true,
|
||||
enforce_limit_price_tick: true,
|
||||
enforce_price_limits: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DailyEquityPoint {
|
||||
#[serde(with = "date_format")]
|
||||
@@ -294,6 +313,7 @@ pub struct BacktestEngine<S, C, R> {
|
||||
futures_expirations: BTreeMap<NaiveDate, BTreeMap<String, f64>>,
|
||||
futures_settlement_price_mode: String,
|
||||
futures_cost_model: FuturesTransactionCostModel,
|
||||
futures_validation_config: FuturesValidationConfig,
|
||||
}
|
||||
|
||||
impl<S, C, R> BacktestEngine<S, C, R> {
|
||||
@@ -318,6 +338,7 @@ impl<S, C, R> BacktestEngine<S, C, R> {
|
||||
futures_expirations: BTreeMap::new(),
|
||||
futures_settlement_price_mode: "close".to_string(),
|
||||
futures_cost_model: FuturesTransactionCostModel::default(),
|
||||
futures_validation_config: FuturesValidationConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,6 +398,11 @@ impl<S, C, R> BacktestEngine<S, C, R> {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_futures_validation_config(mut self, config: FuturesValidationConfig) -> Self {
|
||||
self.futures_validation_config = config;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn process_event_bus_mut(&mut self) -> &mut ProcessEventBus {
|
||||
&mut self.process_event_bus
|
||||
}
|
||||
@@ -832,12 +858,12 @@ where
|
||||
);
|
||||
};
|
||||
|
||||
if let Some(reason) = self.validate_futures_submission(&intent) {
|
||||
let original_requested = intent.quantity;
|
||||
let mut intent = self.resolve_futures_trading_parameters(date, intent);
|
||||
if let Some(reason) = self.validate_futures_submission(date, &intent) {
|
||||
return self.reject_futures_order(date, order_id, intent, reason);
|
||||
}
|
||||
|
||||
let original_requested = intent.quantity;
|
||||
let mut intent = self.resolve_futures_trading_parameters(date, intent);
|
||||
let fill = self.resolve_futures_fill(date, &intent);
|
||||
let Some((execution_price, fill_quantity)) = fill else {
|
||||
if intent.allow_pending || intent.limit_price.is_some() {
|
||||
@@ -1020,14 +1046,76 @@ where
|
||||
report
|
||||
}
|
||||
|
||||
fn validate_futures_submission(&self, intent: &FuturesOrderIntent) -> Option<String> {
|
||||
fn validate_futures_submission(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
intent: &FuturesOrderIntent,
|
||||
) -> Option<String> {
|
||||
if intent.quantity == 0 {
|
||||
return Some("zero futures quantity".to_string());
|
||||
}
|
||||
if self.futures_validation_config.enforce_active_instrument {
|
||||
if let Some(instrument) = self.data.instrument(&intent.symbol) {
|
||||
if !instrument.is_active_on(date) {
|
||||
return Some(format!(
|
||||
"inactive futures instrument symbol={} date={date}",
|
||||
intent.symbol
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.futures_validation_config.enforce_trading_phase {
|
||||
if let Some(snapshot) = self.data.market(date, &intent.symbol) {
|
||||
if snapshot.paused {
|
||||
return Some(format!(
|
||||
"paused futures instrument symbol={}",
|
||||
intent.symbol
|
||||
));
|
||||
}
|
||||
if !futures_trading_phase_allows_orders(snapshot.trading_phase.as_deref()) {
|
||||
return Some(format!(
|
||||
"futures trading phase does not allow orders symbol={} phase={}",
|
||||
intent.symbol,
|
||||
snapshot.trading_phase.as_deref().unwrap_or("")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(limit_price) = intent.limit_price {
|
||||
if !limit_price.is_finite() || limit_price <= 0.0 {
|
||||
return Some("invalid futures limit price".to_string());
|
||||
}
|
||||
if self.futures_validation_config.enforce_limit_price_tick {
|
||||
let tick = self.futures_price_tick(date, &intent.symbol);
|
||||
if !price_is_tick_aligned(limit_price, tick) {
|
||||
return Some(format!(
|
||||
"futures limit price not aligned to tick symbol={} price={limit_price:.6} tick={tick:.6}",
|
||||
intent.symbol
|
||||
));
|
||||
}
|
||||
}
|
||||
if self.futures_validation_config.enforce_price_limits {
|
||||
if let Some(snapshot) = self.data.market(date, &intent.symbol) {
|
||||
if snapshot.upper_limit.is_finite()
|
||||
&& snapshot.upper_limit > 0.0
|
||||
&& limit_price > snapshot.upper_limit + 1e-9
|
||||
{
|
||||
return Some(format!(
|
||||
"futures limit price above upper limit symbol={} price={limit_price:.6} upper={:.6}",
|
||||
intent.symbol, snapshot.upper_limit
|
||||
));
|
||||
}
|
||||
if snapshot.lower_limit.is_finite()
|
||||
&& snapshot.lower_limit > 0.0
|
||||
&& limit_price < snapshot.lower_limit - 1e-9
|
||||
{
|
||||
return Some(format!(
|
||||
"futures limit price below lower limit symbol={} price={limit_price:.6} lower={:.6}",
|
||||
intent.symbol, snapshot.lower_limit
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for order in &self.futures_open_orders {
|
||||
if order.intent.symbol != intent.symbol || order.intent.side() == intent.side() {
|
||||
continue;
|
||||
@@ -1048,6 +1136,20 @@ where
|
||||
None
|
||||
}
|
||||
|
||||
fn futures_price_tick(&self, date: NaiveDate, symbol: &str) -> f64 {
|
||||
self.data
|
||||
.futures_trading_parameter(date, symbol)
|
||||
.map(|params| params.price_tick)
|
||||
.filter(|tick| tick.is_finite() && *tick > 0.0)
|
||||
.or_else(|| {
|
||||
self.data
|
||||
.market(date, symbol)
|
||||
.map(|snapshot| snapshot.effective_price_tick())
|
||||
})
|
||||
.unwrap_or(1.0)
|
||||
.max(1e-9)
|
||||
}
|
||||
|
||||
fn resolve_futures_trading_parameters(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
@@ -3226,6 +3328,30 @@ fn analyzer_ratio_change(start: f64, end: f64) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
fn price_is_tick_aligned(price: f64, tick: f64) -> bool {
|
||||
if !price.is_finite() || !tick.is_finite() || tick <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let ratio = price / tick;
|
||||
(ratio - ratio.round()).abs() <= 1e-6
|
||||
}
|
||||
|
||||
fn futures_trading_phase_allows_orders(phase: Option<&str>) -> bool {
|
||||
let Some(phase) = phase.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return true;
|
||||
};
|
||||
matches!(
|
||||
phase.to_ascii_lowercase().as_str(),
|
||||
"continuous"
|
||||
| "trading"
|
||||
| "trade"
|
||||
| "open_auction"
|
||||
| "auction"
|
||||
| "call_auction"
|
||||
| "opening_auction"
|
||||
)
|
||||
}
|
||||
|
||||
fn futures_limit_satisfied(side: OrderSide, price: f64, limit_price: Option<f64>) -> bool {
|
||||
let Some(limit_price) = limit_price else {
|
||||
return price.is_finite() && price > 0.0;
|
||||
|
||||
@@ -28,7 +28,7 @@ pub use data::{
|
||||
pub use engine::{
|
||||
AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary,
|
||||
AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError,
|
||||
BacktestResult, DailyEquityPoint,
|
||||
BacktestResult, DailyEquityPoint, FuturesValidationConfig,
|
||||
};
|
||||
pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus};
|
||||
pub use events::{
|
||||
|
||||
@@ -122,6 +122,10 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
title: "execution.matching_type / execution.slippage".to_string(),
|
||||
detail: "设置撮合模式和滑点。支持 execution.matching_type(\"next_tick_last\" | \"next_tick_best_own\" | \"next_tick_best_counterparty\" | \"counterparty_offer\" | \"vwap\" | \"current_bar_close\" | \"next_bar_open\" | \"open_auction\")。其中 next_tick_last 使用 tick 的 last_price;next_tick_best_own / next_tick_best_counterparty 会按 L1 买一卖一近似 rqalpha 的 tick 最优价语义;counterparty_offer 在存在 order_book_depth 多档盘口数据时会按真实档位逐档扫单并计算加权成交价,不存在 depth 时回退 L1 对手方报价;vwap 会在盘中执行价链路上聚合多笔成交为单条 VWAP 成交;open_auction 使用当日集合竞价开盘价 day_open 进行撮合,且不额外施加滑点,并按竞价成交量而不是盘口一档流动性限制成交;滑点支持 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 0.001) / execution.slippage(\"tick_size\", 1) / execution.slippage(\"limit_price\"),其中 limit_price 会在限价单成交时按挂单价模拟 rqalpha 的最坏成交价。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "期货提交校验".to_string(),
|
||||
detail: "期货订单进入撮合前会先执行账户与交易规则校验:合约必须在上市/退市日期范围内,日行情不能停牌,trading_phase 需处于 continuous/trading/open_auction/auction/call_auction/opening_auction 等可交易阶段,限价必须为正且按 futures_trading_parameters.price_tick 或日行情 price_tick 对齐,并且不能越过 upper_limit/lower_limit;随后继续检查反向挂单自成交风险、保证金和可平数量。服务层可通过 FuturesValidationConfig 分别关闭 active instrument、trading phase、limit price tick、price limit 校验,用于兼容特殊数据源,但默认全部开启。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "trading.rotation / order.* / cancel.* / update_universe / subscribe".to_string(),
|
||||
detail: "支持显式下单、撤单、AlgoOrder、动态 universe 和账户资金动作。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段;需要模拟 rqalpha 的 tick 订阅保护时,可写 trading.subscription_guard(true),未订阅 symbol 的显式订单会被拦截,TargetPortfolioSmart + AlgoOrder 会过滤未订阅标的。用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99)、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices=VWAPOrder(930, 940), valuation_prices={\"600000.SH\": prev_close})、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices={\"600000.SH\": open * 0.99}, valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])、account.deposit_withdraw(100000, receiving_days=0)、account.finance_repay(50000)、account.set_management_fee_rate(0.001)。其中 order.target_shares(...) 对应 rqalpha 的 order_to,order.target_portfolio_smart(...) 对应 rqalpha 的 order_target_portfolio_smart 批量目标权重语义;account.deposit_withdraw(...) 和 account.finance_repay(...) 对应 RQAlpha 账户出入金与融资/还款语义;order_prices 既可以是逐标的限价映射,也可以是 VWAPOrder/TWAPOrder 这类全局 AlgoOrder;order.vwap_* / order.twap_* 对应 rqalpha 的 AlgoOrder 时间窗订单风格,而 update_universe/subscribe/unsubscribe 对应 rqalpha 的动态 universe 与订阅接口。symbol 使用标准证券代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(),
|
||||
@@ -243,6 +247,11 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
detail: "可选多档盘口数据源,字段为 date,symbol,timestamp,level,bid_price,bid_volume,ask_price,ask_volume。存在该数据时,期货 counterparty_offer / next_tick_best_counterparty 可按真实多档盘口逐档扫单;不存在时不会伪造 depth。".to_string(),
|
||||
fields: vec![],
|
||||
},
|
||||
ManualFactorSource {
|
||||
table: "futures_trading_parameters.csv / futures_trading_parameters/".to_string(),
|
||||
detail: "期货交易参数数据源,字段包括 symbol,effective_date,contract_multiplier,long_margin_rate,short_margin_rate,commission_type,open_commission_ratio,close_commission_ratio,close_today_commission_ratio,price_tick。回测会按交易日自动选择不晚于当前日期的最新参数,用于保证金、手续费和限价 tick 校验。".to_string(),
|
||||
fields: vec![],
|
||||
},
|
||||
],
|
||||
examples: vec![
|
||||
ManualExample {
|
||||
|
||||
@@ -8,10 +8,10 @@ use fidc_core::{
|
||||
BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel,
|
||||
ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, FuturesAccountState,
|
||||
FuturesCommissionType, FuturesContractSpec, FuturesDirection, FuturesOrderIntent,
|
||||
FuturesTradingParameter, Instrument, IntradayExecutionQuote, IntradayOrderBookDepthLevel,
|
||||
MatchingType, OpenOrderView, OrderIntent, OrderSide, OrderStatus, PortfolioState, PriceField,
|
||||
ProcessEvent, ProcessEventBus, ProcessEventKind, ScheduleRule, ScheduleStage, ScheduleTimeRule,
|
||||
Strategy, StrategyContext, StrategyDecision,
|
||||
FuturesTradingParameter, FuturesValidationConfig, Instrument, IntradayExecutionQuote,
|
||||
IntradayOrderBookDepthLevel, MatchingType, OpenOrderView, OrderIntent, OrderSide, OrderStatus,
|
||||
PortfolioState, PriceField, ProcessEvent, ProcessEventBus, ProcessEventKind, ScheduleRule,
|
||||
ScheduleStage, ScheduleTimeRule, Strategy, StrategyContext, StrategyDecision,
|
||||
};
|
||||
|
||||
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||
@@ -408,6 +408,99 @@ impl Strategy for FuturesLimitOrderStrategy {
|
||||
}
|
||||
}
|
||||
|
||||
struct FuturesInvalidTickLimitStrategy;
|
||||
|
||||
impl Strategy for FuturesInvalidTickLimitStrategy {
|
||||
fn name(&self) -> &str {
|
||||
"futures-invalid-tick-limit"
|
||||
}
|
||||
|
||||
fn on_day(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
if ctx.execution_date != d(2025, 1, 2) {
|
||||
return Ok(StrategyDecision::default());
|
||||
}
|
||||
Ok(StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Futures {
|
||||
intent: FuturesOrderIntent::limit_open(
|
||||
"IF2501",
|
||||
FuturesDirection::Long,
|
||||
FuturesContractSpec::new(1.0, 0.0, 0.0),
|
||||
1,
|
||||
3988.13,
|
||||
0.0,
|
||||
"bad tick limit",
|
||||
),
|
||||
}],
|
||||
..StrategyDecision::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct FuturesClosedPhaseOrderStrategy;
|
||||
|
||||
impl Strategy for FuturesClosedPhaseOrderStrategy {
|
||||
fn name(&self) -> &str {
|
||||
"futures-closed-phase-order"
|
||||
}
|
||||
|
||||
fn on_day(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
if ctx.execution_date != d(2025, 1, 2) {
|
||||
return Ok(StrategyDecision::default());
|
||||
}
|
||||
Ok(StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Futures {
|
||||
intent: FuturesOrderIntent::open(
|
||||
"IF2501",
|
||||
FuturesDirection::Long,
|
||||
FuturesContractSpec::new(1.0, 0.0, 0.0),
|
||||
1,
|
||||
4000.0,
|
||||
0.0,
|
||||
"closed phase order",
|
||||
),
|
||||
}],
|
||||
..StrategyDecision::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct FuturesAboveUpperLimitStrategy;
|
||||
|
||||
impl Strategy for FuturesAboveUpperLimitStrategy {
|
||||
fn name(&self) -> &str {
|
||||
"futures-above-upper-limit"
|
||||
}
|
||||
|
||||
fn on_day(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
if ctx.execution_date != d(2025, 1, 2) {
|
||||
return Ok(StrategyDecision::default());
|
||||
}
|
||||
Ok(StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Futures {
|
||||
intent: FuturesOrderIntent::limit_open(
|
||||
"IF2501",
|
||||
FuturesDirection::Long,
|
||||
FuturesContractSpec::new(1.0, 0.0, 0.0),
|
||||
1,
|
||||
5000.0,
|
||||
0.0,
|
||||
"outside upper limit",
|
||||
),
|
||||
}],
|
||||
..StrategyDecision::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct FuturesDepthLimitOrderStrategy;
|
||||
|
||||
impl Strategy for FuturesDepthLimitOrderStrategy {
|
||||
@@ -1431,6 +1524,190 @@ fn engine_matches_pending_futures_limit_order_with_data_driven_costs() {
|
||||
assert!((position.contract_multiplier - 300.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_rejects_futures_limit_orders_not_aligned_to_tick() {
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
let mut engine = BacktestEngine::new(
|
||||
two_day_futures_data(),
|
||||
FuturesInvalidTickLimitStrategy,
|
||||
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, 2)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Open,
|
||||
},
|
||||
)
|
||||
.with_futures_initial_cash(1_000_000.0);
|
||||
|
||||
let result = engine.run().expect("backtest succeeds");
|
||||
|
||||
assert!(result.order_events.iter().any(|event| {
|
||||
event.symbol == "IF2501"
|
||||
&& event.status == OrderStatus::Rejected
|
||||
&& event.reason.contains("not aligned to tick")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_allows_disabling_futures_limit_tick_validation() {
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
let mut engine = BacktestEngine::new(
|
||||
two_day_futures_data(),
|
||||
FuturesInvalidTickLimitStrategy,
|
||||
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(1_000_000.0)
|
||||
.with_futures_validation_config(FuturesValidationConfig {
|
||||
enforce_limit_price_tick: false,
|
||||
..FuturesValidationConfig::default()
|
||||
});
|
||||
|
||||
let result = engine.run().expect("backtest succeeds");
|
||||
|
||||
assert!(
|
||||
result
|
||||
.order_events
|
||||
.iter()
|
||||
.any(|event| event.symbol == "IF2501" && event.status == OrderStatus::Pending)
|
||||
);
|
||||
assert!(result.order_events.iter().any(|event| {
|
||||
event.symbol == "IF2501"
|
||||
&& event.status == OrderStatus::Filled
|
||||
&& event.filled_quantity == 1
|
||||
}));
|
||||
let fill = result
|
||||
.fills
|
||||
.iter()
|
||||
.find(|fill| fill.symbol == "IF2501")
|
||||
.expect("futures fill");
|
||||
assert!((fill.price - 3988.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_rejects_futures_limit_orders_outside_price_limits() {
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
let mut engine = BacktestEngine::new(
|
||||
two_day_futures_data(),
|
||||
FuturesAboveUpperLimitStrategy,
|
||||
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, 2)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Open,
|
||||
},
|
||||
)
|
||||
.with_futures_initial_cash(1_000_000.0);
|
||||
|
||||
let result = engine.run().expect("backtest succeeds");
|
||||
|
||||
assert!(result.order_events.iter().any(|event| {
|
||||
event.symbol == "IF2501"
|
||||
&& event.status == OrderStatus::Rejected
|
||||
&& event.reason.contains("above upper limit")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_rejects_futures_orders_when_trading_phase_is_closed() {
|
||||
let date = d(2025, 1, 2);
|
||||
let mut future_market = market_row(date, "IF2501", 4000.0, 4000.0);
|
||||
future_market.trading_phase = Some("closed".to_string());
|
||||
let data = DataSet::from_components_with_actions_quotes_and_futures(
|
||||
vec![
|
||||
Instrument {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
name: "Anchor".to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(d(2020, 1, 1)),
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
},
|
||||
Instrument {
|
||||
symbol: "IF2501".to_string(),
|
||||
name: "IF".to_string(),
|
||||
board: "FUTURE".to_string(),
|
||||
round_lot: 1,
|
||||
listed_at: Some(d(2024, 1, 1)),
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
},
|
||||
],
|
||||
vec![market_row(date, "000001.SZ", 10.0, 10.0), future_market],
|
||||
vec![factor_row(date, "000001.SZ", BTreeMap::new())],
|
||||
vec![candidate_row(date, "000001.SZ")],
|
||||
vec![benchmark_row(date)],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
vec![FuturesTradingParameter {
|
||||
symbol: "IF2501".to_string(),
|
||||
effective_date: Some(date),
|
||||
contract_multiplier: 300.0,
|
||||
long_margin_rate: 0.12,
|
||||
short_margin_rate: 0.14,
|
||||
commission_type: FuturesCommissionType::ByVolume,
|
||||
open_commission_ratio: 2.5,
|
||||
close_commission_ratio: 2.0,
|
||||
close_today_commission_ratio: 3.0,
|
||||
price_tick: 0.2,
|
||||
}],
|
||||
)
|
||||
.expect("futures dataset");
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks::default(),
|
||||
PriceField::Open,
|
||||
);
|
||||
let mut engine = BacktestEngine::new(
|
||||
data,
|
||||
FuturesClosedPhaseOrderStrategy,
|
||||
broker,
|
||||
BacktestConfig {
|
||||
initial_cash: 100_000.0,
|
||||
benchmark_code: "000300.SH".to_string(),
|
||||
start_date: Some(date),
|
||||
end_date: Some(date),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Open,
|
||||
},
|
||||
)
|
||||
.with_futures_initial_cash(1_000_000.0);
|
||||
|
||||
let result = engine.run().expect("backtest succeeds");
|
||||
|
||||
assert!(result.order_events.iter().any(|event| {
|
||||
event.symbol == "IF2501"
|
||||
&& event.status == OrderStatus::Rejected
|
||||
&& event.reason.contains("trading phase")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_sweeps_futures_order_book_depth_when_available() {
|
||||
let date = d(2025, 1, 2);
|
||||
|
||||
@@ -50,7 +50,7 @@ Parity gaps found by this pass and current closure state:
|
||||
| P1 | Futures trading parameter data source | RQAlpha loads contract multiplier, margin ratios, commission type, open/close/close-today commission ratios, settlement/prev-settlement, tick size, listed/de-listed dates, and dominant contracts from data proxy. | Closed for engine-side trading-parameter ingestion/resolution via `futures_trading_parameters.csv` or component data. | Add more exchange metadata columns only when source data exposes them. |
|
||||
| P1 | Futures transaction cost decider | RQAlpha supports by-money/by-volume futures commission with separate open, close, and close-today rates and a commission multiplier. | Closed. `FuturesTransactionCostModel` calculates by-money/by-volume open/close/close-today costs from trading parameters. | None. |
|
||||
| P1 | Futures settlement price mode | RQAlpha can settle futures by `settlement` or `close`, including previous-settlement fields. | Closed. Engine supports configurable settlement price mode and resolves settlement/prev-settlement from factor fields with close/prev_close fallback. | Add dedicated settlement columns if the storage layer later separates them from factors. |
|
||||
| P1 | Frontend risk validators for futures | RQAlpha applies cash/margin, position closable, price-limit, trading-status, and self-trade validators before order submission. | Closed for zero quantity, invalid limit price, self-trade crossing risk, paused/no executable price, price-limit, margin, and close-position rejection diagnostics. | Add exchange-specific validators only as needed. |
|
||||
| P1 | Frontend risk validators for futures | RQAlpha applies cash/margin, position closable, price-limit, trading-status, and self-trade validators before order submission. | Closed for zero quantity, invalid limit price, active-contract, trading-phase, tick-aligned limit price, price-limit, self-trade crossing risk, paused/no executable price, margin, and close-position rejection diagnostics. These submission validators are controlled by `FuturesValidationConfig` so service-level callers can relax individual checks for compatibility tests or vendor-specific rules. | Add more exchange metadata columns only when source data exposes them. |
|
||||
| P2 | RQData helper APIs | RQAlpha exposes `get_dividend`, `get_split`, `get_yield_curve`, `get_factor`, `get_margin_stocks`, `get_securities_margin`, `get_dominant_future`, and dominant futures price APIs. | Closed. These APIs are available through `DataSet` and `StrategyContext`; platform expressions also expose focused helpers such as `dividend_cash`, `factor_value`, `yield_curve`, `is_margin_stock`, `dominant_future`, and `dominant_future_price`. | Add more DSL aliases only when users need specific names. |
|
||||
| P2 | Analyzer/report parity | RQAlpha analyser can export richer trades, positions, benchmark, monthly returns, risk, and summary artifacts. | Closed for normalized trades, positions, monthly returns, risk summary, equity curve, benchmark series, metrics, and JSON report bundle via `BacktestResult::analyzer_report(_json)`. | UI/service download endpoints can serialize this report directly. |
|
||||
| P3 | Mod/config/plugin architecture | RQAlpha has pluggable mods, event bus extension points, and many config toggles. | Closed for a lightweight engine-native model: `BacktestProcessMod`, `BacktestProcessModLoader`, enabled-name installation, and event-bus lifecycle hooks. It intentionally avoids RQAlpha's Python global mod loader. | Add concrete production mods/toggles as requirements appear. |
|
||||
@@ -149,6 +149,8 @@ Parity gaps found by this pass and current closure state:
|
||||
- [x] futures trading-parameter data source and automatic cost/margin resolver
|
||||
- [x] futures settlement/prev-settlement data integration and settlement mode
|
||||
- [x] futures-aware submission validators and self-trade checks
|
||||
- [x] configurable futures active-contract, trading-phase, price-tick, and
|
||||
price-limit submission validators
|
||||
- [x] optional true multi-level futures order-book depth data and sweep matching
|
||||
|
||||
### Phase 10: Advanced data API parity
|
||||
@@ -204,5 +206,5 @@ Parity gaps found by this pass and current closure state:
|
||||
Active implementation target: P0-P2 parity items are implemented in the engine
|
||||
core, and P3 now has a lightweight event-driven extension loader. Remaining
|
||||
future work should be driven by concrete production strategy or UI requirements,
|
||||
especially exchange-specific validators and optional vendor-specific depth
|
||||
fields.
|
||||
especially optional vendor-specific depth fields, additional exchange metadata
|
||||
columns, or exact UI-required RQAlpha intermediate event names.
|
||||
|
||||
Reference in New Issue
Block a user