chore: 更新 fidc-backtest-engine - 2026-05-22

This commit is contained in:
boris
2026-05-22 17:22:33 +08:00
parent 7dbd66b467
commit 3499d4aa74
8 changed files with 526 additions and 125 deletions
+60 -30
View File
@@ -10,7 +10,9 @@ use crate::events::{
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
ProcessEventKind, ProcessEventKind,
}; };
use crate::instrument::Instrument;
use crate::portfolio::PortfolioState; use crate::portfolio::PortfolioState;
use crate::risk_control::ChinaAShareRiskControl;
use crate::rules::{EquityRuleHooks, RuleCheck}; use crate::rules::{EquityRuleHooks, RuleCheck};
use crate::strategy::{ use crate::strategy::{
AlgoOrderStyle, OpenOrderView, OrderIntent, StrategyDecision, TargetPortfolioOrderPricing, AlgoOrderStyle, OpenOrderView, OrderIntent, StrategyDecision, TargetPortfolioOrderPricing,
@@ -1850,19 +1852,27 @@ where
date: NaiveDate, date: NaiveDate,
snapshot: &crate::data::DailyMarketSnapshot, snapshot: &crate::data::DailyMarketSnapshot,
candidate: &crate::data::CandidateEligibility, candidate: &crate::data::CandidateEligibility,
instrument: Option<&Instrument>,
) -> RuleCheck { ) -> RuleCheck {
let check_price = if self.aiquant_rqalpha_execution_rules {
self.aiquant_limit_check_price(snapshot, OrderSide::Buy)
} else {
ChinaAShareRiskControl::buy_check_price(snapshot, self.execution_price_field)
};
if let Some(reason) = ChinaAShareRiskControl::buy_rejection_reason(
date,
candidate,
snapshot,
instrument,
check_price,
) {
return RuleCheck::reject(reason);
}
if !self.aiquant_rqalpha_execution_rules { if !self.aiquant_rqalpha_execution_rules {
return self return self
.rules .rules
.can_buy(date, snapshot, candidate, self.execution_price_field); .can_buy(date, snapshot, candidate, self.execution_price_field);
} }
if snapshot.paused || candidate.is_paused {
return RuleCheck::reject("paused");
}
let check_price = self.aiquant_limit_check_price(snapshot, OrderSide::Buy);
if snapshot.is_at_upper_limit_price(check_price) {
return RuleCheck::reject("open at or above upper limit");
}
RuleCheck::allow() RuleCheck::allow()
} }
@@ -1871,8 +1881,24 @@ where
date: NaiveDate, date: NaiveDate,
snapshot: &crate::data::DailyMarketSnapshot, snapshot: &crate::data::DailyMarketSnapshot,
candidate: &crate::data::CandidateEligibility, candidate: &crate::data::CandidateEligibility,
instrument: Option<&Instrument>,
position: &crate::portfolio::Position, position: &crate::portfolio::Position,
) -> RuleCheck { ) -> RuleCheck {
let check_price = if self.aiquant_rqalpha_execution_rules {
self.aiquant_limit_check_price(snapshot, OrderSide::Sell)
} else {
ChinaAShareRiskControl::sell_check_price(snapshot, self.execution_price_field)
};
if let Some(reason) = ChinaAShareRiskControl::sell_rejection_reason(
date,
candidate,
snapshot,
instrument,
Some(position),
check_price,
) {
return RuleCheck::reject(reason);
}
if !self.aiquant_rqalpha_execution_rules { if !self.aiquant_rqalpha_execution_rules {
return self.rules.can_sell( return self.rules.can_sell(
date, date,
@@ -1882,16 +1908,6 @@ where
self.execution_price_field, self.execution_price_field,
); );
} }
if snapshot.paused || candidate.is_paused {
return RuleCheck::reject("paused");
}
let check_price = self.aiquant_limit_check_price(snapshot, OrderSide::Sell);
if snapshot.is_at_lower_limit_price(check_price) {
return RuleCheck::reject("open at or below lower limit");
}
if position.sellable_qty(date) == 0 {
return RuleCheck::reject("t+1 sellable quantity is zero");
}
RuleCheck::allow() RuleCheck::allow()
} }
@@ -1917,7 +1933,8 @@ where
let Ok(candidate) = data.require_candidate(date, symbol) else { let Ok(candidate) = data.require_candidate(date, symbol) else {
return current_qty; return current_qty;
}; };
let rule = self.sell_rule_check(date, snapshot, candidate, position); let rule =
self.sell_rule_check(date, snapshot, candidate, data.instrument(symbol), position);
if !rule.allowed { if !rule.allowed {
return current_qty; return current_qty;
} }
@@ -1955,7 +1972,7 @@ where
let Ok(candidate) = data.require_candidate(date, symbol) else { let Ok(candidate) = data.require_candidate(date, symbol) else {
return current_qty; return current_qty;
}; };
let rule = self.buy_rule_check(date, snapshot, candidate); let rule = self.buy_rule_check(date, snapshot, candidate, data.instrument(symbol));
if !rule.allowed { if !rule.allowed {
return current_qty; return current_qty;
} }
@@ -1999,7 +2016,8 @@ where
let position = portfolio.position(symbol)?; let position = portfolio.position(symbol)?;
let snapshot = data.require_market(date, symbol).ok()?; let snapshot = data.require_market(date, symbol).ok()?;
let candidate = data.require_candidate(date, symbol).ok()?; let candidate = data.require_candidate(date, symbol).ok()?;
let rule = self.sell_rule_check(date, snapshot, candidate, position); let rule =
self.sell_rule_check(date, snapshot, candidate, data.instrument(symbol), position);
if !rule.allowed { if !rule.allowed {
return rule.reason; return rule.reason;
} }
@@ -2039,7 +2057,7 @@ where
) -> Option<String> { ) -> Option<String> {
let snapshot = data.require_market(date, symbol).ok()?; let snapshot = data.require_market(date, symbol).ok()?;
let candidate = data.require_candidate(date, symbol).ok()?; let candidate = data.require_candidate(date, symbol).ok()?;
let rule = self.buy_rule_check(date, snapshot, candidate); let rule = self.buy_rule_check(date, snapshot, candidate, data.instrument(symbol));
if !rule.allowed { if !rule.allowed {
return rule.reason; return rule.reason;
} }
@@ -2109,12 +2127,15 @@ where
); );
} }
let rule = self.sell_rule_check(date, snapshot, candidate, position); let rule =
self.sell_rule_check(date, snapshot, candidate, data.instrument(symbol), position);
if !rule.allowed { if !rule.allowed {
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string(); let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
let status = match rule.reason.as_deref() { let status = match rule.reason.as_deref() {
Some("paused") Some("paused")
| Some("sell disabled by eligibility flags") | Some("sell disabled by eligibility flags")
| Some("sell_disabled")
| Some("lower_limit")
| Some("open at or below lower limit") => OrderStatus::Canceled, | Some("open at or below lower limit") => OrderStatus::Canceled,
_ => OrderStatus::Rejected, _ => OrderStatus::Rejected,
}; };
@@ -3542,12 +3563,14 @@ where
); );
} }
let rule = self.buy_rule_check(date, snapshot, candidate); let rule = self.buy_rule_check(date, snapshot, candidate, data.instrument(symbol));
if !rule.allowed { if !rule.allowed {
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string(); let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
let status = match rule.reason.as_deref() { let status = match rule.reason.as_deref() {
Some("paused") Some("paused")
| Some("buy disabled by eligibility flags") | Some("buy disabled by eligibility flags")
| Some("buy_disabled")
| Some("upper_limit")
| Some("open at or above upper limit") => OrderStatus::Canceled, | Some("open at or above upper limit") => OrderStatus::Canceled,
_ => OrderStatus::Rejected, _ => OrderStatus::Rejected,
}; };
@@ -4721,6 +4744,8 @@ fn zero_fill_status_for_reason(reason: &str) -> OrderStatus {
| "tick volume limit" | "tick volume limit"
| "intraday quote liquidity exhausted" | "intraday quote liquidity exhausted"
| "no execution quotes after start" | "no execution quotes after start"
| "upper_limit"
| "lower_limit"
| "open at or above upper limit" | "open at or above upper limit"
| "open at or below lower limit" => OrderStatus::Canceled, | "open at or below lower limit" => OrderStatus::Canceled,
_ => OrderStatus::Rejected, _ => OrderStatus::Rejected,
@@ -4733,6 +4758,8 @@ fn final_partial_fill_status(partial_reason: Option<&str>) -> OrderStatus {
if reason.contains("market liquidity or volume limit") if reason.contains("market liquidity or volume limit")
|| reason.contains("intraday quote liquidity exhausted") || reason.contains("intraday quote liquidity exhausted")
|| reason.contains("no execution quotes after start") || reason.contains("no execution quotes after start")
|| reason.contains("upper_limit")
|| reason.contains("lower_limit")
|| reason.contains("open at or above upper limit") || reason.contains("open at or above upper limit")
|| reason.contains("open at or below lower limit") => || reason.contains("open at or below lower limit") =>
{ {
@@ -4913,7 +4940,7 @@ mod tests {
} }
#[test] #[test]
fn aiquant_rules_allow_buy_when_day_flags_block_but_last_price_is_tradable() { fn aiquant_rules_keep_structured_buy_risk_while_using_aiquant_limit_price() {
let mut snapshot = limit_test_snapshot(); let mut snapshot = limit_test_snapshot();
snapshot.open = 11.0; snapshot.open = 11.0;
snapshot.day_open = 11.0; snapshot.day_open = 11.0;
@@ -4927,12 +4954,9 @@ mod tests {
ChinaEquityRuleHooks, ChinaEquityRuleHooks,
PriceField::Last, PriceField::Last,
); );
let default_rule = default_broker.buy_rule_check(date, &snapshot, &candidate); let default_rule = default_broker.buy_rule_check(date, &snapshot, &candidate, None);
assert!(!default_rule.allowed); assert!(!default_rule.allowed);
assert_eq!( assert_eq!(default_rule.reason.as_deref(), Some("trade_disabled"));
default_rule.reason.as_deref(),
Some("buy disabled by eligibility flags")
);
let aiquant_broker = BrokerSimulator::new_with_execution_price( let aiquant_broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
@@ -4940,7 +4964,13 @@ mod tests {
PriceField::Last, PriceField::Last,
) )
.with_aiquant_rqalpha_execution_rules(true); .with_aiquant_rqalpha_execution_rules(true);
let aiquant_rule = aiquant_broker.buy_rule_check(date, &snapshot, &candidate); let aiquant_rule = aiquant_broker.buy_rule_check(date, &snapshot, &candidate, None);
assert!(!aiquant_rule.allowed);
assert_eq!(aiquant_rule.reason.as_deref(), Some("trade_disabled"));
let tradable_candidate = limit_test_candidate(true, true);
let aiquant_rule =
aiquant_broker.buy_rule_check(date, &snapshot, &tradable_candidate, None);
assert!(aiquant_rule.allowed); assert!(aiquant_rule.allowed);
} }
+64 -3
View File
@@ -9,6 +9,7 @@ use thiserror::Error;
use crate::calendar::TradingCalendar; use crate::calendar::TradingCalendar;
use crate::futures::{FuturesCommissionType, FuturesTradingParameter}; use crate::futures::{FuturesCommissionType, FuturesTradingParameter};
use crate::instrument::Instrument; use crate::instrument::Instrument;
use crate::risk_control::ChinaAShareRiskControl;
mod date_format { mod date_format {
use chrono::NaiveDate; use chrono::NaiveDate;
@@ -1080,8 +1081,12 @@ impl DataSet {
let market_series_by_symbol = build_market_series(&market_by_date); let market_series_by_symbol = build_market_series(&market_by_date);
let benchmark_series_cache = let benchmark_series_cache =
BenchmarkPriceSeries::new(&benchmark_by_date.values().cloned().collect::<Vec<_>>()); BenchmarkPriceSeries::new(&benchmark_by_date.values().cloned().collect::<Vec<_>>());
let eligible_universe_by_date = let eligible_universe_by_date = build_eligible_universe(
build_eligible_universe(&factor_by_date, &candidate_index, &market_index); &factor_by_date,
&candidate_index,
&market_index,
&instruments,
);
let futures_params_by_symbol = build_futures_params_index(futures_params); let futures_params_by_symbol = build_futures_params_index(futures_params);
Ok(Self { Ok(Self {
@@ -3287,6 +3292,7 @@ fn build_eligible_universe(
factor_by_date: &BTreeMap<NaiveDate, Vec<DailyFactorSnapshot>>, factor_by_date: &BTreeMap<NaiveDate, Vec<DailyFactorSnapshot>>,
candidate_index: &HashMap<(NaiveDate, String), CandidateEligibility>, candidate_index: &HashMap<(NaiveDate, String), CandidateEligibility>,
market_index: &HashMap<(NaiveDate, String), DailyMarketSnapshot>, market_index: &HashMap<(NaiveDate, String), DailyMarketSnapshot>,
instruments: &HashMap<String, Instrument>,
) -> BTreeMap<NaiveDate, Vec<EligibleUniverseSnapshot>> { ) -> BTreeMap<NaiveDate, Vec<EligibleUniverseSnapshot>> {
let mut per_date = BTreeMap::<NaiveDate, Vec<EligibleUniverseSnapshot>>::new(); let mut per_date = BTreeMap::<NaiveDate, Vec<EligibleUniverseSnapshot>>::new();
@@ -3303,7 +3309,14 @@ fn build_eligible_universe(
let Some(market) = market_index.get(&key) else { let Some(market) = market_index.get(&key) else {
continue; continue;
}; };
if !candidate.eligible_for_selection() || market.paused { if ChinaAShareRiskControl::selection_rejection_reason(
*date,
candidate,
market,
instruments.get(&factor.symbol),
)
.is_some()
{
continue; continue;
} }
rows.push(EligibleUniverseSnapshot { rows.push(EligibleUniverseSnapshot {
@@ -3324,6 +3337,11 @@ fn build_eligible_universe(
per_date per_date
} }
#[cfg(test)]
fn instrument_passes_baseline_selection(instrument: Option<&Instrument>, date: NaiveDate) -> bool {
ChinaAShareRiskControl::instrument_rejection_reason(instrument, date).is_none()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -3363,6 +3381,49 @@ mod tests {
} }
} }
#[test]
fn baseline_selection_uses_structured_instrument_dates_and_status_only() {
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
let instrument = |name: &str, status: &str, delisted_at: Option<NaiveDate>| Instrument {
symbol: "000001.SZ".to_string(),
name: name.to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(NaiveDate::parse_from_str("2020-01-01", "%Y-%m-%d").unwrap()),
delisted_at,
status: status.to_string(),
};
assert!(instrument_passes_baseline_selection(
Some(&instrument("Short History Stock", "active", None)),
date
));
assert!(instrument_passes_baseline_selection(
Some(&instrument("*ST测试", "active", None)),
date
));
assert!(instrument_passes_baseline_selection(
Some(&instrument("ST测试", "active", None)),
date
));
assert!(instrument_passes_baseline_selection(
Some(&instrument("退市测试", "active", None)),
date
));
assert!(!instrument_passes_baseline_selection(
Some(&instrument("正常名称", "delisted", None)),
date
));
assert!(!instrument_passes_baseline_selection(
Some(&instrument(
"正常名称",
"active",
Some(NaiveDate::parse_from_str("2025-01-01", "%Y-%m-%d").unwrap()),
)),
date
));
}
#[test] #[test]
fn decision_volume_average_uses_previous_completed_days_only() { fn decision_volume_average_uses_previous_completed_days_only() {
let series = SymbolPriceSeries::new(&[ let series = SymbolPriceSeries::new(&[
+2
View File
@@ -12,6 +12,7 @@ pub mod platform_expr_strategy;
pub mod platform_runtime_schema; pub mod platform_runtime_schema;
pub mod platform_strategy_spec; pub mod platform_strategy_spec;
pub mod portfolio; pub mod portfolio;
pub mod risk_control;
pub mod rules; pub mod rules;
pub mod scheduler; pub mod scheduler;
pub mod strategy; pub mod strategy;
@@ -66,6 +67,7 @@ pub use platform_strategy_spec::{
StrategyRuntimeSpec, platform_expr_config_from_spec, platform_expr_config_from_value, StrategyRuntimeSpec, platform_expr_config_from_spec, platform_expr_config_from_value,
}; };
pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position}; pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position};
pub use risk_control::ChinaAShareRiskControl;
pub use rules::{ChinaEquityRuleHooks, EquityRuleHooks, RuleCheck}; pub use rules::{ChinaEquityRuleHooks, EquityRuleHooks, RuleCheck};
pub use scheduler::{ pub use scheduler::{
ScheduleFrequency, ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time, ScheduleFrequency, ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time,
+233 -17
View File
@@ -9,6 +9,7 @@ use crate::data::{DailyMarketSnapshot, EligibleUniverseSnapshot, PriceField};
use crate::engine::BacktestError; use crate::engine::BacktestError;
use crate::events::OrderSide; use crate::events::OrderSide;
use crate::portfolio::PortfolioState; use crate::portfolio::PortfolioState;
use crate::risk_control::ChinaAShareRiskControl;
use crate::scheduler::{ use crate::scheduler::{
ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time, ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time,
}; };
@@ -433,6 +434,27 @@ struct PositionExpressionState {
dividend_receivable: f64, dividend_receivable: f64,
} }
fn precomputed_stock_rolling_mean(
extra_factors: &BTreeMap<String, f64>,
field: &str,
lookback: usize,
) -> Option<f64> {
let key = match (field.trim().to_ascii_lowercase().as_str(), lookback) {
("close" | "prev_close" | "stock_close" | "price", 5) => "ma5_prev_close",
("close" | "prev_close" | "stock_close" | "price", 10) => "ma10_prev_close",
("close" | "prev_close" | "stock_close" | "price", 20) => "ma20_prev_close",
("close" | "prev_close" | "stock_close" | "price", 30) => "ma30_prev_close",
("volume" | "stock_volume", 5) => "avg_volume5",
("volume" | "stock_volume", 20) => "avg_volume20",
("volume" | "stock_volume", 60) => "avg_volume60",
_ => return None,
};
extra_factors
.get(key)
.copied()
.filter(|value| value.is_finite())
}
pub struct PlatformExprStrategy { pub struct PlatformExprStrategy {
config: PlatformExprStrategyConfig, config: PlatformExprStrategyConfig,
engine: Engine, engine: Engine,
@@ -795,12 +817,20 @@ impl PlatformExprStrategy {
) )
} }
fn price_is_at_limit(price: f64, limit: f64, tick: f64) -> bool { fn price_is_at_or_above_upper_limit(price: f64, limit: f64, tick: f64) -> bool {
if !price.is_finite() || !limit.is_finite() { if !price.is_finite() || !limit.is_finite() {
return false; return false;
} }
let tolerance = tick.abs().max(1e-6); let tolerance = (tick.abs() * 0.5).max(1e-6);
(price - limit).abs() <= tolerance price >= limit - tolerance
}
fn price_is_at_or_below_lower_limit(price: f64, limit: f64, tick: f64) -> bool {
if !price.is_finite() || !limit.is_finite() {
return false;
}
let tolerance = (tick.abs() * 0.5).max(1e-6);
price <= limit + tolerance
} }
fn intraday_execution_start_time(&self) -> NaiveTime { fn intraday_execution_start_time(&self) -> NaiveTime {
@@ -1485,46 +1515,75 @@ impl PlatformExprStrategy {
let stock_ma_short = ctx let stock_ma_short = ctx
.data .data
.market_decision_close_moving_average(date, symbol, self.config.stock_short_ma_days) .market_decision_close_moving_average(date, symbol, self.config.stock_short_ma_days)
.or_else(|| {
precomputed_stock_rolling_mean(
&factor.extra_factors,
"close",
self.config.stock_short_ma_days,
)
})
.unwrap_or(f64::NAN); .unwrap_or(f64::NAN);
let stock_ma_mid = ctx let stock_ma_mid = ctx
.data .data
.market_decision_close_moving_average(date, symbol, self.config.stock_mid_ma_days) .market_decision_close_moving_average(date, symbol, self.config.stock_mid_ma_days)
.or_else(|| {
precomputed_stock_rolling_mean(
&factor.extra_factors,
"close",
self.config.stock_mid_ma_days,
)
})
.unwrap_or(f64::NAN); .unwrap_or(f64::NAN);
let stock_ma_long = ctx let stock_ma_long = ctx
.data .data
.market_decision_close_moving_average(date, symbol, self.config.stock_long_ma_days) .market_decision_close_moving_average(date, symbol, self.config.stock_long_ma_days)
.or_else(|| {
precomputed_stock_rolling_mean(
&factor.extra_factors,
"close",
self.config.stock_long_ma_days,
)
})
.unwrap_or(f64::NAN); .unwrap_or(f64::NAN);
let stock_ma5 = ctx let stock_ma5 = ctx
.data .data
.market_decision_close_moving_average(date, symbol, 5) .market_decision_close_moving_average(date, symbol, 5)
.or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "close", 5))
.unwrap_or(f64::NAN); .unwrap_or(f64::NAN);
let stock_ma10 = ctx let stock_ma10 = ctx
.data .data
.market_decision_close_moving_average(date, symbol, 10) .market_decision_close_moving_average(date, symbol, 10)
.or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "close", 10))
.unwrap_or(f64::NAN); .unwrap_or(f64::NAN);
let stock_ma20 = ctx let stock_ma20 = ctx
.data .data
.market_decision_close_moving_average(date, symbol, 20) .market_decision_close_moving_average(date, symbol, 20)
.or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "close", 20))
.unwrap_or(f64::NAN); .unwrap_or(f64::NAN);
let stock_ma30 = ctx let stock_ma30 = ctx
.data .data
.market_decision_close_moving_average(date, symbol, 30) .market_decision_close_moving_average(date, symbol, 30)
.or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "close", 30))
.unwrap_or(f64::NAN); .unwrap_or(f64::NAN);
let stock_volume_ma5 = ctx let stock_volume_ma5 = ctx
.data .data
.market_decision_volume_moving_average(date, symbol, 5) .market_decision_volume_moving_average(date, symbol, 5)
.or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "volume", 5))
.unwrap_or(f64::NAN); .unwrap_or(f64::NAN);
let stock_volume_ma10 = ctx let stock_volume_ma10 = ctx
.data .data
.market_decision_volume_moving_average(date, symbol, 10) .market_decision_volume_moving_average(date, symbol, 10)
.or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "volume", 10))
.unwrap_or(f64::NAN); .unwrap_or(f64::NAN);
let stock_volume_ma20 = ctx let stock_volume_ma20 = ctx
.data .data
.market_decision_volume_moving_average(date, symbol, 20) .market_decision_volume_moving_average(date, symbol, 20)
.or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "volume", 20))
.unwrap_or(f64::NAN); .unwrap_or(f64::NAN);
let stock_volume_ma60 = ctx let stock_volume_ma60 = ctx
.data .data
.market_decision_volume_moving_average(date, symbol, 60) .market_decision_volume_moving_average(date, symbol, 60)
.or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "volume", 60))
.unwrap_or(f64::NAN); .unwrap_or(f64::NAN);
let touched_upper_limit = !market.paused let touched_upper_limit = !market.paused
&& (market.is_at_upper_limit_price(market.close) && (market.is_at_upper_limit_price(market.close)
@@ -1903,10 +1962,16 @@ impl PlatformExprStrategy {
); );
scope.push("day_factors", day_factors); scope.push("day_factors", day_factors);
if let Some(stock) = stock { if let Some(stock) = stock {
let at_upper_limit = let at_upper_limit = Self::price_is_at_or_above_upper_limit(
Self::price_is_at_limit(stock.last, stock.upper_limit, stock.price_tick); stock.last,
let at_lower_limit = stock.upper_limit,
Self::price_is_at_limit(stock.last, stock.lower_limit, stock.price_tick); stock.price_tick,
);
let at_lower_limit = Self::price_is_at_or_below_lower_limit(
stock.last,
stock.lower_limit,
stock.price_tick,
);
scope.push("symbol", stock.symbol.clone()); scope.push("symbol", stock.symbol.clone());
scope.push("market_cap", stock.market_cap); scope.push("market_cap", stock.market_cap);
scope.push("free_float_cap", stock.free_float_cap); scope.push("free_float_cap", stock.free_float_cap);
@@ -3093,12 +3158,16 @@ impl PlatformExprStrategy {
"rolling_mean(\"{other}\", {lookback}) requires stock context" "rolling_mean(\"{other}\", {lookback}) requires stock context"
)) ))
})?; })?;
ctx.data.market_decision_numeric_moving_average( ctx.data
.market_decision_numeric_moving_average(
day.date, day.date,
&stock.symbol, &stock.symbol,
other, other,
lookback, lookback,
) )
.or_else(|| {
precomputed_stock_rolling_mean(&stock.extra_factors, other, lookback)
})
} }
}; };
value.ok_or_else(|| { value.ok_or_else(|| {
@@ -4581,10 +4650,13 @@ impl PlatformExprStrategy {
let Some(market) = ctx.data.market(date, &factor.symbol) else { let Some(market) = ctx.data.market(date, &factor.symbol) else {
continue; continue;
}; };
if market.paused && !self.config.aiquant_transaction_cost { if self
.baseline_risk_rejection_reason(ctx, date, &factor.symbol, candidate, market)
.is_some()
{
continue; continue;
} }
if !self.stock_passes_universe_exclude(candidate, market, false) { if !self.stock_passes_universe_exclude(candidate, market) {
continue; continue;
} }
rows.push(EligibleUniverseSnapshot { rows.push(EligibleUniverseSnapshot {
@@ -4602,17 +4674,32 @@ impl PlatformExprStrategy {
rows rows
} }
fn baseline_risk_rejection_reason(
&self,
ctx: &StrategyContext<'_>,
date: NaiveDate,
symbol: &str,
candidate: &crate::data::CandidateEligibility,
market: &DailyMarketSnapshot,
) -> Option<&'static str> {
ChinaAShareRiskControl::selection_rejection_reason(
date,
candidate,
market,
ctx.data.instrument(symbol),
)
}
fn stock_passes_universe_exclude( fn stock_passes_universe_exclude(
&self, &self,
candidate: &crate::data::CandidateEligibility, candidate: &crate::data::CandidateEligibility,
market: &DailyMarketSnapshot, market: &DailyMarketSnapshot,
has_special_name: bool,
) -> bool { ) -> bool {
let excludes = &self.config.universe_exclude; let excludes = &self.config.universe_exclude;
if excludes.iter().any(|item| item == "paused") && (market.paused || candidate.is_paused) { if excludes.iter().any(|item| item == "paused") && (market.paused || candidate.is_paused) {
return false; return false;
} }
if excludes.iter().any(|item| item == "st") && (candidate.is_st || has_special_name) { if excludes.iter().any(|item| item == "st") && candidate.is_st {
return false; return false;
} }
if excludes.iter().any(|item| item == "kcb") && candidate.is_kcb { if excludes.iter().any(|item| item == "kcb") && candidate.is_kcb {
@@ -4754,12 +4841,18 @@ impl PlatformExprStrategy {
let market = ctx.data.require_market(date, symbol)?; let market = ctx.data.require_market(date, symbol)?;
let candidate = ctx.data.require_candidate(date, symbol)?; let candidate = ctx.data.require_candidate(date, symbol)?;
if let Some(reason) =
self.baseline_risk_rejection_reason(ctx, date, symbol, &candidate, &market)
{
return Ok(Some(reason.to_string()));
}
let excludes = &self.config.universe_exclude; let excludes = &self.config.universe_exclude;
if excludes.iter().any(|item| item == "paused") && (market.paused || candidate.is_paused) { if excludes.iter().any(|item| item == "paused") && (market.paused || candidate.is_paused) {
return Ok(Some("paused".to_string())); return Ok(Some("paused".to_string()));
} }
if excludes.iter().any(|item| item == "st") && stock.is_st { if excludes.iter().any(|item| item == "st") && stock.is_st {
return Ok(Some("st_or_special_name".to_string())); return Ok(Some("st".to_string()));
} }
if excludes.iter().any(|item| item == "kcb") && candidate.is_kcb { if excludes.iter().any(|item| item == "kcb") && candidate.is_kcb {
return Ok(Some("kcb".to_string())); return Ok(Some("kcb".to_string()));
@@ -5609,6 +5702,22 @@ mod tests {
assert!(!rewritten.contains('?')); assert!(!rewritten.contains('?'));
} }
#[test]
fn platform_expr_limit_flags_are_directional() {
assert!(PlatformExprStrategy::price_is_at_or_above_upper_limit(
20.21, 17.60, 0.01
));
assert!(!PlatformExprStrategy::price_is_at_or_above_upper_limit(
17.58, 17.60, 0.01
));
assert!(PlatformExprStrategy::price_is_at_or_below_lower_limit(
14.39, 14.40, 0.01
));
assert!(!PlatformExprStrategy::price_is_at_or_below_lower_limit(
14.42, 14.40, 0.01
));
}
#[test] #[test]
fn platform_expr_rewrites_prelude_assignment_ternary() { fn platform_expr_rewrites_prelude_assignment_ternary() {
let prelude = "let cap_low = sig_close <= 0 ? 7 : max(5, min(cap_low_raw, 70));"; let prelude = "let cap_low = sig_close <= 0 ? 7 : max(5, min(cap_low_raw, 70));";
@@ -6858,10 +6967,10 @@ mod tests {
vec![CandidateEligibility { vec![CandidateEligibility {
date, date,
symbol: symbol.to_string(), symbol: symbol.to_string(),
is_st: true, is_st: false,
is_new_listing: false, is_new_listing: false,
is_paused: false, is_paused: false,
allow_buy: false, allow_buy: true,
allow_sell: true, allow_sell: true,
is_kcb: false, is_kcb: false,
is_one_yuan: false, is_one_yuan: false,
@@ -6922,6 +7031,109 @@ mod tests {
assert_eq!(rejection, None); assert_eq!(rejection, None);
} }
#[test]
fn platform_strategy_baseline_risk_filter_cannot_be_disabled_by_empty_exclude() {
let date = d(2024, 12, 31);
let symbol = "002494.SZ";
let data = DataSet::from_components(
vec![Instrument {
symbol: symbol.to_string(),
name: symbol.to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(d(2010, 1, 1)),
delisted_at: None,
status: "active".to_string(),
}],
vec![DailyMarketSnapshot {
date,
symbol: symbol.to_string(),
timestamp: Some("2024-12-31 09:33:00".to_string()),
day_open: 8.0,
open: 8.0,
high: 8.0,
low: 8.0,
close: 8.0,
last_price: 8.0,
bid1: 8.0,
ask1: 8.0,
prev_close: 8.0,
volume: 0,
tick_volume: 0,
bid1_volume: 0,
ask1_volume: 0,
trading_phase: Some("suspended".to_string()),
paused: true,
upper_limit: 8.8,
lower_limit: 7.2,
price_tick: 0.01,
}],
vec![DailyFactorSnapshot {
date,
symbol: symbol.to_string(),
market_cap_bn: 12.0,
free_float_cap_bn: 8.0,
pe_ttm: 10.0,
turnover_ratio: Some(0.0),
effective_turnover_ratio: Some(0.0),
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
date,
symbol: symbol.to_string(),
is_st: false,
is_new_listing: false,
is_paused: true,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
}],
vec![BenchmarkSnapshot {
date,
benchmark: "000852.SH".to_string(),
open: 1000.0,
close: 1002.0,
prev_close: 998.0,
volume: 1_000_000,
}],
)
.expect("dataset");
let portfolio = PortfolioState::new(1_000_000.0);
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date,
decision_date: date,
decision_index: 40,
data: &data,
portfolio: &portfolio,
futures_account: None,
open_orders: &[],
dynamic_universe: None,
subscriptions: &subscriptions,
process_events: &[],
active_process_event: None,
active_datetime: None,
order_events: &[],
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.universe_exclude.clear();
cfg.aiquant_transaction_cost = true;
let strategy = PlatformExprStrategy::new(cfg);
let stock = strategy
.stock_state(&ctx, date, symbol)
.expect("stock state");
let rejection = strategy
.buy_rejection_reason(&ctx, date, symbol, &stock)
.expect("rejection");
let selected = strategy.selectable_universe_on(&ctx, date, date);
assert_eq!(rejection.as_deref(), Some("paused"));
assert!(selected.is_empty());
}
fn sample_calendar() -> TradingCalendar { fn sample_calendar() -> TradingCalendar {
TradingCalendar::new(vec![ TradingCalendar::new(vec![
d(2025, 1, 30), d(2025, 1, 30),
@@ -7810,7 +8022,7 @@ mod tests {
} }
#[test] #[test]
fn platform_strategy_honors_configured_universe_excludes_for_new_listings() { fn platform_strategy_baseline_risk_excludes_new_listings_even_when_config_omits_it() {
let date = d(2025, 2, 3); let date = d(2025, 2, 3);
let symbols = ["301001.SZ", "000001.SZ"]; let symbols = ["301001.SZ", "000001.SZ"];
let data = DataSet::from_components( let data = DataSet::from_components(
@@ -7949,10 +8161,14 @@ mod tests {
let decision = strategy.on_day(&ctx).expect("platform decision"); let decision = strategy.on_day(&ctx).expect("platform decision");
assert!(decision.order_intents.iter().any(|intent| matches!( assert!(!decision.order_intents.iter().any(|intent| matches!(
intent, intent,
crate::strategy::OrderIntent::Value { symbol, .. } if symbol == "301001.SZ" crate::strategy::OrderIntent::Value { symbol, .. } if symbol == "301001.SZ"
))); )));
assert!(decision.order_intents.iter().any(|intent| matches!(
intent,
crate::strategy::OrderIntent::Value { symbol, .. } if symbol == "000001.SZ"
)));
} }
#[test] #[test]
+125
View File
@@ -0,0 +1,125 @@
use chrono::NaiveDate;
use crate::data::{CandidateEligibility, DailyMarketSnapshot, PriceField};
use crate::instrument::Instrument;
use crate::portfolio::Position;
#[derive(Debug, Clone, Copy, Default)]
pub struct ChinaAShareRiskControl;
impl ChinaAShareRiskControl {
pub fn instrument_rejection_reason(
instrument: Option<&Instrument>,
date: NaiveDate,
) -> Option<&'static str> {
let instrument = instrument?;
if instrument
.listed_at
.is_some_and(|listed_at| listed_at > date)
{
return Some("not_listed");
}
if instrument
.delisted_at
.is_some_and(|delisted_at| delisted_at <= date)
{
return Some("inactive_or_delisted");
}
let status = instrument.status.trim().to_ascii_lowercase();
if matches!(
status.as_str(),
"inactive" | "delisted" | "terminated" | "expired"
) || status.contains("delist")
{
return Some("inactive_or_delisted");
}
None
}
pub fn selection_rejection_reason(
date: NaiveDate,
candidate: &CandidateEligibility,
market: &DailyMarketSnapshot,
instrument: Option<&Instrument>,
) -> Option<&'static str> {
if let Some(reason) = Self::instrument_rejection_reason(instrument, date) {
return Some(reason);
}
if market.paused || candidate.is_paused {
return Some("paused");
}
if candidate.is_st {
return Some("st");
}
if candidate.is_new_listing {
return Some("new_listing");
}
if candidate.is_kcb {
return Some("kcb");
}
if candidate.is_one_yuan || market.day_open <= 1.0 {
return Some("one_yuan");
}
if !candidate.allow_buy || !candidate.allow_sell {
return Some("trade_disabled");
}
None
}
pub fn buy_rejection_reason(
date: NaiveDate,
candidate: &CandidateEligibility,
market: &DailyMarketSnapshot,
instrument: Option<&Instrument>,
check_price: f64,
) -> Option<&'static str> {
if let Some(reason) = Self::selection_rejection_reason(date, candidate, market, instrument)
{
return Some(reason);
}
if !candidate.allow_buy {
return Some("buy_disabled");
}
if market.is_at_upper_limit_price(check_price) {
return Some("open at or above upper limit");
}
None
}
pub fn sell_rejection_reason(
date: NaiveDate,
candidate: &CandidateEligibility,
market: &DailyMarketSnapshot,
instrument: Option<&Instrument>,
position: Option<&Position>,
check_price: f64,
) -> Option<&'static str> {
if let Some(reason) = Self::instrument_rejection_reason(instrument, date) {
return Some(reason);
}
if market.paused || candidate.is_paused {
return Some("paused");
}
if !candidate.allow_sell {
return Some("sell_disabled");
}
if market.is_at_lower_limit_price(check_price) {
return Some("open at or below lower limit");
}
if position.is_some_and(|position| position.sellable_qty(date) == 0) {
return Some("t+1 sellable quantity is zero");
}
None
}
pub fn buy_check_price(market: &DailyMarketSnapshot, price_field: PriceField) -> f64 {
market.buy_price(price_field)
}
pub fn sell_check_price(market: &DailyMarketSnapshot, price_field: PriceField) -> f64 {
match price_field {
PriceField::Last => market.price(PriceField::Last),
_ => market.sell_price(price_field),
}
}
}
+18 -33
View File
@@ -2,6 +2,7 @@ use chrono::NaiveDate;
use crate::data::{CandidateEligibility, DailyMarketSnapshot, PriceField}; use crate::data::{CandidateEligibility, DailyMarketSnapshot, PriceField};
use crate::portfolio::Position; use crate::portfolio::Position;
use crate::risk_control::ChinaAShareRiskControl;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RuleCheck { pub struct RuleCheck {
@@ -47,20 +48,6 @@ pub trait EquityRuleHooks {
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct ChinaEquityRuleHooks; pub struct ChinaEquityRuleHooks;
impl ChinaEquityRuleHooks {
fn at_upper_limit(snapshot: &DailyMarketSnapshot, price_field: PriceField) -> bool {
snapshot.is_at_upper_limit_price(snapshot.buy_price(price_field))
}
fn at_lower_limit(snapshot: &DailyMarketSnapshot, price_field: PriceField) -> bool {
let check_price = match price_field {
PriceField::Last => snapshot.price(PriceField::Last),
_ => snapshot.sell_price(price_field),
};
snapshot.is_at_lower_limit_price(check_price)
}
}
impl EquityRuleHooks for ChinaEquityRuleHooks { impl EquityRuleHooks for ChinaEquityRuleHooks {
fn can_buy( fn can_buy(
&self, &self,
@@ -69,14 +56,14 @@ impl EquityRuleHooks for ChinaEquityRuleHooks {
candidate: &CandidateEligibility, candidate: &CandidateEligibility,
price_field: PriceField, price_field: PriceField,
) -> RuleCheck { ) -> RuleCheck {
if snapshot.paused || candidate.is_paused { if let Some(reason) = ChinaAShareRiskControl::buy_rejection_reason(
return RuleCheck::reject("paused"); _execution_date,
} candidate,
if !candidate.allow_buy { snapshot,
return RuleCheck::reject("buy disabled by eligibility flags"); None,
} ChinaAShareRiskControl::buy_check_price(snapshot, price_field),
if Self::at_upper_limit(snapshot, price_field) { ) {
return RuleCheck::reject("open at or above upper limit"); return RuleCheck::reject(reason);
} }
RuleCheck::allow() RuleCheck::allow()
@@ -90,17 +77,15 @@ impl EquityRuleHooks for ChinaEquityRuleHooks {
position: &Position, position: &Position,
price_field: PriceField, price_field: PriceField,
) -> RuleCheck { ) -> RuleCheck {
if snapshot.paused || candidate.is_paused { if let Some(reason) = ChinaAShareRiskControl::sell_rejection_reason(
return RuleCheck::reject("paused"); execution_date,
} candidate,
if !candidate.allow_sell { snapshot,
return RuleCheck::reject("sell disabled by eligibility flags"); None,
} Some(position),
if Self::at_lower_limit(snapshot, price_field) { ChinaAShareRiskControl::sell_check_price(snapshot, price_field),
return RuleCheck::reject("open at or below lower limit"); ) {
} return RuleCheck::reject(reason);
if position.sellable_qty(execution_date) == 0 {
return RuleCheck::reject("t+1 sellable quantity is zero");
} }
RuleCheck::allow() RuleCheck::allow()
+18 -36
View File
@@ -17,6 +17,7 @@ use crate::events::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEvent}
use crate::futures::{FuturesAccountState, FuturesOrderIntent}; use crate::futures::{FuturesAccountState, FuturesOrderIntent};
use crate::instrument::Instrument; use crate::instrument::Instrument;
use crate::portfolio::PortfolioState; use crate::portfolio::PortfolioState;
use crate::risk_control::ChinaAShareRiskControl;
use crate::scheduler::ScheduleRule; use crate::scheduler::ScheduleRule;
use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSelector}; use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSelector};
@@ -2330,18 +2331,6 @@ impl OmniMicroCapStrategy {
true true
} }
fn special_name(&self, ctx: &StrategyContext<'_>, symbol: &str) -> bool {
let instrument_name = ctx
.data
.instruments()
.get(symbol)
.map(|instrument| instrument.name.as_str())
.unwrap_or("");
instrument_name.contains("ST")
|| instrument_name.contains('*')
|| instrument_name.contains('退')
}
fn can_sell_position(&self, ctx: &StrategyContext<'_>, date: NaiveDate, symbol: &str) -> bool { fn can_sell_position(&self, ctx: &StrategyContext<'_>, date: NaiveDate, symbol: &str) -> bool {
let Some(position) = ctx.portfolio.position(symbol) else { let Some(position) = ctx.portfolio.position(symbol) else {
return false; return false;
@@ -2355,11 +2344,15 @@ impl OmniMicroCapStrategy {
let Ok(candidate) = ctx.data.require_candidate(date, symbol) else { let Ok(candidate) = ctx.data.require_candidate(date, symbol) else {
return false; return false;
}; };
let lower_limit_check_price = market.price(PriceField::Last); ChinaAShareRiskControl::sell_rejection_reason(
!(market.paused date,
|| candidate.is_paused candidate,
|| !candidate.allow_sell market,
|| market.is_at_lower_limit_price(lower_limit_check_price)) ctx.data.instrument(symbol),
Some(position),
ChinaAShareRiskControl::sell_check_price(market, PriceField::Last),
)
.is_none()
} }
fn buy_rejection_reason( fn buy_rejection_reason(
@@ -2371,25 +2364,14 @@ impl OmniMicroCapStrategy {
let market = ctx.data.require_market(date, symbol)?; let market = ctx.data.require_market(date, symbol)?;
let candidate = ctx.data.require_candidate(date, symbol)?; let candidate = ctx.data.require_candidate(date, symbol)?;
if market.paused || candidate.is_paused { if let Some(reason) = ChinaAShareRiskControl::buy_rejection_reason(
return Ok(Some("paused".to_string())); date,
} candidate,
if candidate.is_st || self.special_name(ctx, symbol) { market,
return Ok(Some("st_or_special_name".to_string())); ctx.data.instrument(symbol),
} ChinaAShareRiskControl::buy_check_price(market, PriceField::Last),
if candidate.is_kcb { ) {
return Ok(Some("kcb".to_string())); return Ok(Some(reason.to_string()));
}
if !candidate.allow_buy {
return Ok(Some("buy_disabled".to_string()));
}
if market.is_at_upper_limit_price(market.day_open)
|| market.is_at_upper_limit_price(market.buy_price(PriceField::Last))
{
return Ok(Some("upper_limit".to_string()));
}
if market.day_open <= 1.0 {
return Ok(Some("one_yuan".to_string()));
} }
if !self.truth_selection_contains(date, symbol) if !self.truth_selection_contains(date, symbol)
&& !self.stock_passes_ma_filter(ctx, date, symbol) && !self.stock_passes_ma_filter(ctx, date, symbol)
+1 -1
View File
@@ -300,7 +300,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
PriceField::Open, PriceField::Open,
), ),
BacktestConfig { BacktestConfig {
initial_cash: 11_005.0, initial_cash: 11_008.0,
benchmark_code: "000300.SH".to_string(), benchmark_code: "000300.SH".to_string(),
start_date: Some(buy_date), start_date: Some(buy_date),
end_date: Some(payable_date), end_date: Some(payable_date),