Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3499d4aa74 |
@@ -10,7 +10,9 @@ use crate::events::{
|
||||
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
||||
ProcessEventKind,
|
||||
};
|
||||
use crate::instrument::Instrument;
|
||||
use crate::portfolio::PortfolioState;
|
||||
use crate::risk_control::ChinaAShareRiskControl;
|
||||
use crate::rules::{EquityRuleHooks, RuleCheck};
|
||||
use crate::strategy::{
|
||||
AlgoOrderStyle, OpenOrderView, OrderIntent, StrategyDecision, TargetPortfolioOrderPricing,
|
||||
@@ -1850,19 +1852,27 @@ where
|
||||
date: NaiveDate,
|
||||
snapshot: &crate::data::DailyMarketSnapshot,
|
||||
candidate: &crate::data::CandidateEligibility,
|
||||
instrument: Option<&Instrument>,
|
||||
) -> 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 {
|
||||
return self
|
||||
.rules
|
||||
.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()
|
||||
}
|
||||
|
||||
@@ -1871,8 +1881,24 @@ where
|
||||
date: NaiveDate,
|
||||
snapshot: &crate::data::DailyMarketSnapshot,
|
||||
candidate: &crate::data::CandidateEligibility,
|
||||
instrument: Option<&Instrument>,
|
||||
position: &crate::portfolio::Position,
|
||||
) -> 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 {
|
||||
return self.rules.can_sell(
|
||||
date,
|
||||
@@ -1882,16 +1908,6 @@ where
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -1917,7 +1933,8 @@ where
|
||||
let Ok(candidate) = data.require_candidate(date, symbol) else {
|
||||
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 {
|
||||
return current_qty;
|
||||
}
|
||||
@@ -1955,7 +1972,7 @@ where
|
||||
let Ok(candidate) = data.require_candidate(date, symbol) else {
|
||||
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 {
|
||||
return current_qty;
|
||||
}
|
||||
@@ -1999,7 +2016,8 @@ where
|
||||
let position = portfolio.position(symbol)?;
|
||||
let snapshot = data.require_market(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 {
|
||||
return rule.reason;
|
||||
}
|
||||
@@ -2039,7 +2057,7 @@ where
|
||||
) -> Option<String> {
|
||||
let snapshot = data.require_market(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 {
|
||||
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 {
|
||||
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
|
||||
let status = match rule.reason.as_deref() {
|
||||
Some("paused")
|
||||
| Some("sell disabled by eligibility flags")
|
||||
| Some("sell_disabled")
|
||||
| Some("lower_limit")
|
||||
| Some("open at or below lower limit") => OrderStatus::Canceled,
|
||||
_ => 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 {
|
||||
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
|
||||
let status = match rule.reason.as_deref() {
|
||||
Some("paused")
|
||||
| Some("buy disabled by eligibility flags")
|
||||
| Some("buy_disabled")
|
||||
| Some("upper_limit")
|
||||
| Some("open at or above upper limit") => OrderStatus::Canceled,
|
||||
_ => OrderStatus::Rejected,
|
||||
};
|
||||
@@ -4721,6 +4744,8 @@ fn zero_fill_status_for_reason(reason: &str) -> OrderStatus {
|
||||
| "tick volume limit"
|
||||
| "intraday quote liquidity exhausted"
|
||||
| "no execution quotes after start"
|
||||
| "upper_limit"
|
||||
| "lower_limit"
|
||||
| "open at or above upper limit"
|
||||
| "open at or below lower limit" => OrderStatus::Canceled,
|
||||
_ => OrderStatus::Rejected,
|
||||
@@ -4733,6 +4758,8 @@ fn final_partial_fill_status(partial_reason: Option<&str>) -> OrderStatus {
|
||||
if reason.contains("market liquidity or volume limit")
|
||||
|| reason.contains("intraday quote liquidity exhausted")
|
||||
|| 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 below lower limit") =>
|
||||
{
|
||||
@@ -4913,7 +4940,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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();
|
||||
snapshot.open = 11.0;
|
||||
snapshot.day_open = 11.0;
|
||||
@@ -4927,12 +4954,9 @@ mod tests {
|
||||
ChinaEquityRuleHooks,
|
||||
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_eq!(
|
||||
default_rule.reason.as_deref(),
|
||||
Some("buy disabled by eligibility flags")
|
||||
);
|
||||
assert_eq!(default_rule.reason.as_deref(), Some("trade_disabled"));
|
||||
|
||||
let aiquant_broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
@@ -4940,7 +4964,13 @@ mod tests {
|
||||
PriceField::Last,
|
||||
)
|
||||
.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);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use thiserror::Error;
|
||||
use crate::calendar::TradingCalendar;
|
||||
use crate::futures::{FuturesCommissionType, FuturesTradingParameter};
|
||||
use crate::instrument::Instrument;
|
||||
use crate::risk_control::ChinaAShareRiskControl;
|
||||
|
||||
mod date_format {
|
||||
use chrono::NaiveDate;
|
||||
@@ -1080,8 +1081,12 @@ impl DataSet {
|
||||
let market_series_by_symbol = build_market_series(&market_by_date);
|
||||
let benchmark_series_cache =
|
||||
BenchmarkPriceSeries::new(&benchmark_by_date.values().cloned().collect::<Vec<_>>());
|
||||
let eligible_universe_by_date =
|
||||
build_eligible_universe(&factor_by_date, &candidate_index, &market_index);
|
||||
let eligible_universe_by_date = build_eligible_universe(
|
||||
&factor_by_date,
|
||||
&candidate_index,
|
||||
&market_index,
|
||||
&instruments,
|
||||
);
|
||||
let futures_params_by_symbol = build_futures_params_index(futures_params);
|
||||
|
||||
Ok(Self {
|
||||
@@ -3287,6 +3292,7 @@ fn build_eligible_universe(
|
||||
factor_by_date: &BTreeMap<NaiveDate, Vec<DailyFactorSnapshot>>,
|
||||
candidate_index: &HashMap<(NaiveDate, String), CandidateEligibility>,
|
||||
market_index: &HashMap<(NaiveDate, String), DailyMarketSnapshot>,
|
||||
instruments: &HashMap<String, Instrument>,
|
||||
) -> BTreeMap<NaiveDate, Vec<EligibleUniverseSnapshot>> {
|
||||
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 {
|
||||
continue;
|
||||
};
|
||||
if !candidate.eligible_for_selection() || market.paused {
|
||||
if ChinaAShareRiskControl::selection_rejection_reason(
|
||||
*date,
|
||||
candidate,
|
||||
market,
|
||||
instruments.get(&factor.symbol),
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
rows.push(EligibleUniverseSnapshot {
|
||||
@@ -3324,6 +3337,11 @@ fn build_eligible_universe(
|
||||
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)]
|
||||
mod tests {
|
||||
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]
|
||||
fn decision_volume_average_uses_previous_completed_days_only() {
|
||||
let series = SymbolPriceSeries::new(&[
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod platform_expr_strategy;
|
||||
pub mod platform_runtime_schema;
|
||||
pub mod platform_strategy_spec;
|
||||
pub mod portfolio;
|
||||
pub mod risk_control;
|
||||
pub mod rules;
|
||||
pub mod scheduler;
|
||||
pub mod strategy;
|
||||
@@ -66,6 +67,7 @@ pub use platform_strategy_spec::{
|
||||
StrategyRuntimeSpec, platform_expr_config_from_spec, platform_expr_config_from_value,
|
||||
};
|
||||
pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position};
|
||||
pub use risk_control::ChinaAShareRiskControl;
|
||||
pub use rules::{ChinaEquityRuleHooks, EquityRuleHooks, RuleCheck};
|
||||
pub use scheduler::{
|
||||
ScheduleFrequency, ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time,
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::data::{DailyMarketSnapshot, EligibleUniverseSnapshot, PriceField};
|
||||
use crate::engine::BacktestError;
|
||||
use crate::events::OrderSide;
|
||||
use crate::portfolio::PortfolioState;
|
||||
use crate::risk_control::ChinaAShareRiskControl;
|
||||
use crate::scheduler::{
|
||||
ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time,
|
||||
};
|
||||
@@ -433,6 +434,27 @@ struct PositionExpressionState {
|
||||
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 {
|
||||
config: PlatformExprStrategyConfig,
|
||||
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() {
|
||||
return false;
|
||||
}
|
||||
let tolerance = tick.abs().max(1e-6);
|
||||
(price - limit).abs() <= tolerance
|
||||
let tolerance = (tick.abs() * 0.5).max(1e-6);
|
||||
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 {
|
||||
@@ -1485,46 +1515,75 @@ impl PlatformExprStrategy {
|
||||
let stock_ma_short = ctx
|
||||
.data
|
||||
.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);
|
||||
let stock_ma_mid = ctx
|
||||
.data
|
||||
.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);
|
||||
let stock_ma_long = ctx
|
||||
.data
|
||||
.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);
|
||||
let stock_ma5 = ctx
|
||||
.data
|
||||
.market_decision_close_moving_average(date, symbol, 5)
|
||||
.or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "close", 5))
|
||||
.unwrap_or(f64::NAN);
|
||||
let stock_ma10 = ctx
|
||||
.data
|
||||
.market_decision_close_moving_average(date, symbol, 10)
|
||||
.or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "close", 10))
|
||||
.unwrap_or(f64::NAN);
|
||||
let stock_ma20 = ctx
|
||||
.data
|
||||
.market_decision_close_moving_average(date, symbol, 20)
|
||||
.or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "close", 20))
|
||||
.unwrap_or(f64::NAN);
|
||||
let stock_ma30 = ctx
|
||||
.data
|
||||
.market_decision_close_moving_average(date, symbol, 30)
|
||||
.or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "close", 30))
|
||||
.unwrap_or(f64::NAN);
|
||||
let stock_volume_ma5 = ctx
|
||||
.data
|
||||
.market_decision_volume_moving_average(date, symbol, 5)
|
||||
.or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "volume", 5))
|
||||
.unwrap_or(f64::NAN);
|
||||
let stock_volume_ma10 = ctx
|
||||
.data
|
||||
.market_decision_volume_moving_average(date, symbol, 10)
|
||||
.or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "volume", 10))
|
||||
.unwrap_or(f64::NAN);
|
||||
let stock_volume_ma20 = ctx
|
||||
.data
|
||||
.market_decision_volume_moving_average(date, symbol, 20)
|
||||
.or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "volume", 20))
|
||||
.unwrap_or(f64::NAN);
|
||||
let stock_volume_ma60 = ctx
|
||||
.data
|
||||
.market_decision_volume_moving_average(date, symbol, 60)
|
||||
.or_else(|| precomputed_stock_rolling_mean(&factor.extra_factors, "volume", 60))
|
||||
.unwrap_or(f64::NAN);
|
||||
let touched_upper_limit = !market.paused
|
||||
&& (market.is_at_upper_limit_price(market.close)
|
||||
@@ -1903,10 +1962,16 @@ impl PlatformExprStrategy {
|
||||
);
|
||||
scope.push("day_factors", day_factors);
|
||||
if let Some(stock) = stock {
|
||||
let at_upper_limit =
|
||||
Self::price_is_at_limit(stock.last, stock.upper_limit, stock.price_tick);
|
||||
let at_lower_limit =
|
||||
Self::price_is_at_limit(stock.last, stock.lower_limit, stock.price_tick);
|
||||
let at_upper_limit = Self::price_is_at_or_above_upper_limit(
|
||||
stock.last,
|
||||
stock.upper_limit,
|
||||
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("market_cap", stock.market_cap);
|
||||
scope.push("free_float_cap", stock.free_float_cap);
|
||||
@@ -3093,12 +3158,16 @@ impl PlatformExprStrategy {
|
||||
"rolling_mean(\"{other}\", {lookback}) requires stock context"
|
||||
))
|
||||
})?;
|
||||
ctx.data.market_decision_numeric_moving_average(
|
||||
day.date,
|
||||
&stock.symbol,
|
||||
other,
|
||||
lookback,
|
||||
)
|
||||
ctx.data
|
||||
.market_decision_numeric_moving_average(
|
||||
day.date,
|
||||
&stock.symbol,
|
||||
other,
|
||||
lookback,
|
||||
)
|
||||
.or_else(|| {
|
||||
precomputed_stock_rolling_mean(&stock.extra_factors, other, lookback)
|
||||
})
|
||||
}
|
||||
};
|
||||
value.ok_or_else(|| {
|
||||
@@ -4581,10 +4650,13 @@ impl PlatformExprStrategy {
|
||||
let Some(market) = ctx.data.market(date, &factor.symbol) else {
|
||||
continue;
|
||||
};
|
||||
if market.paused && !self.config.aiquant_transaction_cost {
|
||||
if self
|
||||
.baseline_risk_rejection_reason(ctx, date, &factor.symbol, candidate, market)
|
||||
.is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !self.stock_passes_universe_exclude(candidate, market, false) {
|
||||
if !self.stock_passes_universe_exclude(candidate, market) {
|
||||
continue;
|
||||
}
|
||||
rows.push(EligibleUniverseSnapshot {
|
||||
@@ -4602,17 +4674,32 @@ impl PlatformExprStrategy {
|
||||
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(
|
||||
&self,
|
||||
candidate: &crate::data::CandidateEligibility,
|
||||
market: &DailyMarketSnapshot,
|
||||
has_special_name: bool,
|
||||
) -> bool {
|
||||
let excludes = &self.config.universe_exclude;
|
||||
if excludes.iter().any(|item| item == "paused") && (market.paused || candidate.is_paused) {
|
||||
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;
|
||||
}
|
||||
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 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;
|
||||
if excludes.iter().any(|item| item == "paused") && (market.paused || candidate.is_paused) {
|
||||
return Ok(Some("paused".to_string()));
|
||||
}
|
||||
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 {
|
||||
return Ok(Some("kcb".to_string()));
|
||||
@@ -5609,6 +5702,22 @@ mod tests {
|
||||
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]
|
||||
fn platform_expr_rewrites_prelude_assignment_ternary() {
|
||||
let prelude = "let cap_low = sig_close <= 0 ? 7 : max(5, min(cap_low_raw, 70));";
|
||||
@@ -6858,10 +6967,10 @@ mod tests {
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
is_st: true,
|
||||
is_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
@@ -6922,6 +7031,109 @@ mod tests {
|
||||
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 {
|
||||
TradingCalendar::new(vec![
|
||||
d(2025, 1, 30),
|
||||
@@ -7810,7 +8022,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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 symbols = ["301001.SZ", "000001.SZ"];
|
||||
let data = DataSet::from_components(
|
||||
@@ -7949,10 +8161,14 @@ mod tests {
|
||||
|
||||
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,
|
||||
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]
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use chrono::NaiveDate;
|
||||
|
||||
use crate::data::{CandidateEligibility, DailyMarketSnapshot, PriceField};
|
||||
use crate::portfolio::Position;
|
||||
use crate::risk_control::ChinaAShareRiskControl;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RuleCheck {
|
||||
@@ -47,20 +48,6 @@ pub trait EquityRuleHooks {
|
||||
#[derive(Debug, Clone, Default)]
|
||||
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 {
|
||||
fn can_buy(
|
||||
&self,
|
||||
@@ -69,14 +56,14 @@ impl EquityRuleHooks for ChinaEquityRuleHooks {
|
||||
candidate: &CandidateEligibility,
|
||||
price_field: PriceField,
|
||||
) -> RuleCheck {
|
||||
if snapshot.paused || candidate.is_paused {
|
||||
return RuleCheck::reject("paused");
|
||||
}
|
||||
if !candidate.allow_buy {
|
||||
return RuleCheck::reject("buy disabled by eligibility flags");
|
||||
}
|
||||
if Self::at_upper_limit(snapshot, price_field) {
|
||||
return RuleCheck::reject("open at or above upper limit");
|
||||
if let Some(reason) = ChinaAShareRiskControl::buy_rejection_reason(
|
||||
_execution_date,
|
||||
candidate,
|
||||
snapshot,
|
||||
None,
|
||||
ChinaAShareRiskControl::buy_check_price(snapshot, price_field),
|
||||
) {
|
||||
return RuleCheck::reject(reason);
|
||||
}
|
||||
|
||||
RuleCheck::allow()
|
||||
@@ -90,17 +77,15 @@ impl EquityRuleHooks for ChinaEquityRuleHooks {
|
||||
position: &Position,
|
||||
price_field: PriceField,
|
||||
) -> RuleCheck {
|
||||
if snapshot.paused || candidate.is_paused {
|
||||
return RuleCheck::reject("paused");
|
||||
}
|
||||
if !candidate.allow_sell {
|
||||
return RuleCheck::reject("sell disabled by eligibility flags");
|
||||
}
|
||||
if Self::at_lower_limit(snapshot, price_field) {
|
||||
return RuleCheck::reject("open at or below lower limit");
|
||||
}
|
||||
if position.sellable_qty(execution_date) == 0 {
|
||||
return RuleCheck::reject("t+1 sellable quantity is zero");
|
||||
if let Some(reason) = ChinaAShareRiskControl::sell_rejection_reason(
|
||||
execution_date,
|
||||
candidate,
|
||||
snapshot,
|
||||
None,
|
||||
Some(position),
|
||||
ChinaAShareRiskControl::sell_check_price(snapshot, price_field),
|
||||
) {
|
||||
return RuleCheck::reject(reason);
|
||||
}
|
||||
|
||||
RuleCheck::allow()
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::events::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEvent}
|
||||
use crate::futures::{FuturesAccountState, FuturesOrderIntent};
|
||||
use crate::instrument::Instrument;
|
||||
use crate::portfolio::PortfolioState;
|
||||
use crate::risk_control::ChinaAShareRiskControl;
|
||||
use crate::scheduler::ScheduleRule;
|
||||
use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSelector};
|
||||
|
||||
@@ -2330,18 +2331,6 @@ impl OmniMicroCapStrategy {
|
||||
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 {
|
||||
let Some(position) = ctx.portfolio.position(symbol) else {
|
||||
return false;
|
||||
@@ -2355,11 +2344,15 @@ impl OmniMicroCapStrategy {
|
||||
let Ok(candidate) = ctx.data.require_candidate(date, symbol) else {
|
||||
return false;
|
||||
};
|
||||
let lower_limit_check_price = market.price(PriceField::Last);
|
||||
!(market.paused
|
||||
|| candidate.is_paused
|
||||
|| !candidate.allow_sell
|
||||
|| market.is_at_lower_limit_price(lower_limit_check_price))
|
||||
ChinaAShareRiskControl::sell_rejection_reason(
|
||||
date,
|
||||
candidate,
|
||||
market,
|
||||
ctx.data.instrument(symbol),
|
||||
Some(position),
|
||||
ChinaAShareRiskControl::sell_check_price(market, PriceField::Last),
|
||||
)
|
||||
.is_none()
|
||||
}
|
||||
|
||||
fn buy_rejection_reason(
|
||||
@@ -2371,25 +2364,14 @@ impl OmniMicroCapStrategy {
|
||||
let market = ctx.data.require_market(date, symbol)?;
|
||||
let candidate = ctx.data.require_candidate(date, symbol)?;
|
||||
|
||||
if market.paused || candidate.is_paused {
|
||||
return Ok(Some("paused".to_string()));
|
||||
}
|
||||
if candidate.is_st || self.special_name(ctx, symbol) {
|
||||
return Ok(Some("st_or_special_name".to_string()));
|
||||
}
|
||||
if candidate.is_kcb {
|
||||
return Ok(Some("kcb".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 let Some(reason) = ChinaAShareRiskControl::buy_rejection_reason(
|
||||
date,
|
||||
candidate,
|
||||
market,
|
||||
ctx.data.instrument(symbol),
|
||||
ChinaAShareRiskControl::buy_check_price(market, PriceField::Last),
|
||||
) {
|
||||
return Ok(Some(reason.to_string()));
|
||||
}
|
||||
if !self.truth_selection_contains(date, symbol)
|
||||
&& !self.stock_passes_ma_filter(ctx, date, symbol)
|
||||
|
||||
@@ -300,7 +300,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
PriceField::Open,
|
||||
),
|
||||
BacktestConfig {
|
||||
initial_cash: 11_005.0,
|
||||
initial_cash: 11_008.0,
|
||||
benchmark_code: "000300.SH".to_string(),
|
||||
start_date: Some(buy_date),
|
||||
end_date: Some(payable_date),
|
||||
|
||||
Reference in New Issue
Block a user