补齐next-open执行日风控回归测试

This commit is contained in:
boris
2026-07-03 21:30:06 +08:00
parent 25001fd3e4
commit f45a5fd0a7
+151 -24
View File
@@ -3948,6 +3948,7 @@ mod tests {
}; };
use crate::events::OrderStatus; use crate::events::OrderStatus;
use crate::instrument::Instrument; use crate::instrument::Instrument;
use crate::risk_control::FidcRiskControlConfig;
use crate::rules::ChinaEquityRuleHooks; use crate::rules::ChinaEquityRuleHooks;
use crate::scheduler::{ScheduleRule, ScheduleStage}; use crate::scheduler::{ScheduleRule, ScheduleStage};
use crate::strategy::{OrderIntent, Strategy, StrategyContext, StrategyDecision}; use crate::strategy::{OrderIntent, Strategy, StrategyContext, StrategyDecision};
@@ -4109,6 +4110,20 @@ mod tests {
} }
} }
fn st_candidate(date: NaiveDate) -> CandidateEligibility {
CandidateEligibility {
is_st: true,
..candidate(date)
}
}
fn one_yuan_candidate(date: NaiveDate) -> CandidateEligibility {
CandidateEligibility {
is_one_yuan: true,
..candidate(date)
}
}
fn benchmark(date: NaiveDate) -> BenchmarkSnapshot { fn benchmark(date: NaiveDate) -> BenchmarkSnapshot {
BenchmarkSnapshot { BenchmarkSnapshot {
date, date,
@@ -4131,24 +4146,44 @@ mod tests {
) )
} }
fn default_instrument() -> Instrument {
Instrument {
symbol: SYMBOL.to_string(),
name: "Test Stock".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(d(2020, 1, 1)),
delisted_at: None,
status: "active".to_string(),
}
}
fn dataset_with( fn dataset_with(
first_market: DailyMarketSnapshot, first_market: DailyMarketSnapshot,
second_market: DailyMarketSnapshot, second_market: DailyMarketSnapshot,
first_candidate: CandidateEligibility, first_candidate: CandidateEligibility,
second_candidate: CandidateEligibility, second_candidate: CandidateEligibility,
) -> DataSet {
dataset_with_instrument(
default_instrument(),
first_market,
second_market,
first_candidate,
second_candidate,
)
}
fn dataset_with_instrument(
instrument: Instrument,
first_market: DailyMarketSnapshot,
second_market: DailyMarketSnapshot,
first_candidate: CandidateEligibility,
second_candidate: CandidateEligibility,
) -> DataSet { ) -> DataSet {
let first = first_market.date; let first = first_market.date;
let second = second_market.date; let second = second_market.date;
DataSet::from_components( DataSet::from_components(
vec![Instrument { vec![instrument],
symbol: SYMBOL.to_string(),
name: "Test Stock".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(d(2020, 1, 1)),
delisted_at: None,
status: "active".to_string(),
}],
vec![first_market, second_market], vec![first_market, second_market],
vec![factor(first), factor(second)], vec![factor(first), factor(second)],
vec![first_candidate, second_candidate], vec![first_candidate, second_candidate],
@@ -4194,16 +4229,32 @@ mod tests {
} }
fn run_scheduled_next_open_with_dataset(dataset: DataSet) -> super::BacktestResult { fn run_scheduled_next_open_with_dataset(dataset: DataSet) -> super::BacktestResult {
let first = d(2025, 1, 2); run_scheduled_next_open_with_dataset_and_broker(
let broker = BrokerSimulator::new_with_execution_price( dataset,
scheduled_next_open_broker(FidcRiskControlConfig::default()),
)
}
fn scheduled_next_open_broker(
risk_config: FidcRiskControlConfig,
) -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks> {
BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(), ChinaAShareCostModel::default(),
ChinaEquityRuleHooks, ChinaEquityRuleHooks,
PriceField::Open, PriceField::Open,
) )
.with_matching_type(MatchingType::NextBarOpen) .with_matching_type(MatchingType::NextBarOpen)
.with_risk_config(risk_config)
.with_volume_limit(false) .with_volume_limit(false)
.with_liquidity_limit(false) .with_liquidity_limit(false)
.with_inactive_limit(false); .with_inactive_limit(false)
}
fn run_scheduled_next_open_with_dataset_and_broker(
dataset: DataSet,
broker: BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks>,
) -> super::BacktestResult {
let first = d(2025, 1, 2);
let config = BacktestConfig { let config = BacktestConfig {
initial_cash: 100_000.0, initial_cash: 100_000.0,
benchmark_code: "000852.SH".to_string(), benchmark_code: "000852.SH".to_string(),
@@ -4226,6 +4277,16 @@ mod tests {
.expect("backtest run") .expect("backtest run")
} }
fn assert_next_open_canceled_with_reason(result: &super::BacktestResult, reason: &str) {
let execution_date = d(2025, 1, 3);
assert!(result.fills.is_empty());
assert!(result.order_events.iter().any(|event| {
event.date == execution_date
&& matches!(event.status, OrderStatus::Canceled | OrderStatus::Rejected)
&& event.reason.contains(reason)
}));
}
#[test] #[test]
fn current_bar_close_uses_decision_day_close_for_fill() { fn current_bar_close_uses_decision_day_close_for_fill() {
let result = run_with_matching(MatchingType::CurrentBarClose, PriceField::Close, 0); let result = run_with_matching(MatchingType::CurrentBarClose, PriceField::Close, 0);
@@ -4276,6 +4337,22 @@ mod tests {
assert_eq!(result.fills[0].price, 12.0); assert_eq!(result.fills[0].price, 12.0);
} }
#[test]
fn next_bar_open_execution_risk_ignores_decision_day_st_state() {
let first = d(2025, 1, 2);
let second = d(2025, 1, 3);
let result = run_scheduled_next_open_with_dataset(dataset_with(
market(first, 10.0, 11.5),
market(second, 12.0, 99.0),
st_candidate(first),
candidate(second),
));
assert_eq!(result.fills.len(), 1);
assert_eq!(result.fills[0].date, second);
assert_eq!(result.fills[0].price, 12.0);
}
#[test] #[test]
fn next_bar_open_execution_risk_rejects_execution_day_paused_state() { fn next_bar_open_execution_risk_rejects_execution_day_paused_state() {
let first = d(2025, 1, 2); let first = d(2025, 1, 2);
@@ -4287,12 +4364,7 @@ mod tests {
candidate_with_state(second, true, true), candidate_with_state(second, true, true),
)); ));
assert!(result.fills.is_empty()); assert_next_open_canceled_with_reason(&result, "paused");
assert!(result.order_events.iter().any(|event| {
event.date == second
&& event.status == OrderStatus::Canceled
&& event.reason.contains("paused")
}));
} }
#[test] #[test]
@@ -4306,11 +4378,66 @@ mod tests {
candidate(second), candidate(second),
)); ));
assert!(result.fills.is_empty()); assert_next_open_canceled_with_reason(&result, "open at or above upper limit");
assert!(result.order_events.iter().any(|event| { }
event.date == second
&& event.status == OrderStatus::Canceled #[test]
&& event.reason.contains("open at or above upper limit") fn next_bar_open_execution_risk_rejects_execution_day_st_state() {
})); let first = d(2025, 1, 2);
let second = d(2025, 1, 3);
let result = run_scheduled_next_open_with_dataset(dataset_with(
market(first, 10.0, 11.5),
market(second, 12.0, 99.0),
candidate(first),
st_candidate(second),
));
assert_next_open_canceled_with_reason(&result, "st");
}
#[test]
fn next_bar_open_execution_risk_rejects_execution_day_one_yuan_state() {
let first = d(2025, 1, 2);
let second = d(2025, 1, 3);
let result = run_scheduled_next_open_with_dataset(dataset_with(
market(first, 10.0, 11.5),
market(second, 12.0, 99.0),
candidate(first),
one_yuan_candidate(second),
));
assert_next_open_canceled_with_reason(&result, "one_yuan");
}
#[test]
fn next_bar_open_execution_risk_rejects_execution_day_delisted_state() {
let first = d(2025, 1, 2);
let second = d(2025, 1, 3);
let mut instrument = default_instrument();
instrument.delisted_at = Some(second);
let result = run_scheduled_next_open_with_dataset(dataset_with_instrument(
instrument,
market(first, 10.0, 11.5),
market(second, 12.0, 99.0),
candidate(first),
candidate(second),
));
assert_next_open_canceled_with_reason(&result, "inactive_or_delisted");
}
#[test]
fn next_bar_open_execution_risk_rejects_blacklisted_buy_on_execution_day() {
let mut risk_config = FidcRiskControlConfig::default();
risk_config
.static_rules
.blacklisted_symbols
.insert(SYMBOL.to_string());
let result = run_scheduled_next_open_with_dataset_and_broker(
dataset(),
scheduled_next_open_broker(risk_config),
);
assert_next_open_canceled_with_reason(&result, "blacklisted");
} }
} }