chore: 更新 fidc-backtest-engine - 2026-05-22
This commit is contained in:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(&[
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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::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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user