补齐next-open卖出执行日风控测试

This commit is contained in:
boris
2026-07-03 21:50:44 +08:00
parent f45a5fd0a7
commit 9fa588fef8
+265 -1
View File
@@ -3946,7 +3946,7 @@ mod tests {
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
PriceField, PriceField,
}; };
use crate::events::OrderStatus; use crate::events::{OrderSide, OrderStatus};
use crate::instrument::Instrument; use crate::instrument::Instrument;
use crate::risk_control::FidcRiskControlConfig; use crate::risk_control::FidcRiskControlConfig;
use crate::rules::ChinaEquityRuleHooks; use crate::rules::ChinaEquityRuleHooks;
@@ -4023,6 +4023,60 @@ mod tests {
} }
} }
#[derive(Debug)]
struct ScheduledRoundTripStrategy {
rule: ScheduleRule,
buy_decision_date: NaiveDate,
sell_decision_date: NaiveDate,
}
impl Strategy for ScheduledRoundTripStrategy {
fn name(&self) -> &str {
"scheduled_round_trip"
}
fn schedule_rules(&self) -> Vec<ScheduleRule> {
vec![self.rule.clone()]
}
fn on_scheduled(
&mut self,
ctx: &StrategyContext<'_>,
rule: &ScheduleRule,
) -> Result<StrategyDecision, super::BacktestError> {
assert_eq!(rule.name, self.rule.name);
assert_eq!(
ctx.current_datetime().map(|value| value.date()),
Some(ctx.decision_date)
);
if ctx.decision_date == self.buy_decision_date
&& ctx.portfolio.position(SYMBOL).is_none()
{
return Ok(StrategyDecision {
order_intents: vec![OrderIntent::Shares {
symbol: SYMBOL.to_string(),
quantity: 100,
reason: "round_trip_buy".to_string(),
}],
..StrategyDecision::default()
});
}
if ctx.decision_date == self.sell_decision_date {
if let Some(position) = ctx.portfolio.position(SYMBOL) {
return Ok(StrategyDecision {
order_intents: vec![OrderIntent::Shares {
symbol: SYMBOL.to_string(),
quantity: -(position.quantity as i32),
reason: "round_trip_sell".to_string(),
}],
..StrategyDecision::default()
});
}
}
Ok(StrategyDecision::default())
}
}
fn d(year: i32, month: u32, day: u32) -> NaiveDate { fn d(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("valid date") NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
} }
@@ -4069,6 +4123,18 @@ mod tests {
} }
} }
fn market_with_volume(
date: NaiveDate,
open: f64,
close: f64,
volume: u64,
) -> DailyMarketSnapshot {
DailyMarketSnapshot {
volume,
..market(date, open, close)
}
}
fn factor(date: NaiveDate) -> DailyFactorSnapshot { fn factor(date: NaiveDate) -> DailyFactorSnapshot {
DailyFactorSnapshot { DailyFactorSnapshot {
date, date,
@@ -4110,6 +4176,18 @@ mod tests {
} }
} }
fn candidate_with_sell_state(
date: NaiveDate,
is_paused: bool,
allow_sell: bool,
) -> CandidateEligibility {
CandidateEligibility {
is_paused,
allow_sell,
..candidate(date)
}
}
fn st_candidate(date: NaiveDate) -> CandidateEligibility { fn st_candidate(date: NaiveDate) -> CandidateEligibility {
CandidateEligibility { CandidateEligibility {
is_st: true, is_st: true,
@@ -4192,6 +4270,28 @@ mod tests {
.expect("dataset") .expect("dataset")
} }
fn dataset_from_market_and_candidates(
markets: Vec<DailyMarketSnapshot>,
candidates: Vec<CandidateEligibility>,
) -> DataSet {
let factors = markets
.iter()
.map(|market| factor(market.date))
.collect::<Vec<_>>();
let benchmarks = markets
.iter()
.map(|market| benchmark(market.date))
.collect::<Vec<_>>();
DataSet::from_components(
vec![default_instrument()],
markets,
factors,
candidates,
benchmarks,
)
.expect("dataset")
}
fn run_with_matching( fn run_with_matching(
matching_type: MatchingType, matching_type: MatchingType,
execution_price_field: PriceField, execution_price_field: PriceField,
@@ -4277,6 +4377,35 @@ mod tests {
.expect("backtest run") .expect("backtest run")
} }
fn run_scheduled_round_trip_next_open_with_dataset_and_broker(
dataset: DataSet,
broker: BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks>,
) -> super::BacktestResult {
let first = d(2025, 1, 2);
let third = d(2025, 1, 6);
let config = BacktestConfig {
initial_cash: 100_000.0,
benchmark_code: "000852.SH".to_string(),
start_date: Some(first),
end_date: Some(d(2025, 1, 7)),
decision_lag_trading_days: 1,
execution_price_field: PriceField::Open,
};
BacktestEngine::new(
dataset,
ScheduledRoundTripStrategy {
rule: ScheduleRule::daily("daily_round_trip", ScheduleStage::OnDay),
buy_decision_date: first,
sell_decision_date: third,
},
broker,
config,
)
.run()
.expect("backtest run")
}
fn assert_next_open_canceled_with_reason(result: &super::BacktestResult, reason: &str) { fn assert_next_open_canceled_with_reason(result: &super::BacktestResult, reason: &str) {
let execution_date = d(2025, 1, 3); let execution_date = d(2025, 1, 3);
assert!(result.fills.is_empty()); assert!(result.fills.is_empty());
@@ -4287,6 +4416,18 @@ mod tests {
})); }));
} }
fn assert_round_trip_sell_canceled_with_reason(result: &super::BacktestResult, reason: &str) {
let execution_date = d(2025, 1, 7);
assert!(result.fills.iter().any(|fill| fill.side == OrderSide::Buy));
assert!(result.fills.iter().all(|fill| fill.side != OrderSide::Sell));
assert!(result.order_events.iter().any(|event| {
event.date == execution_date
&& event.side == OrderSide::Sell
&& 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);
@@ -4440,4 +4581,127 @@ mod tests {
assert_next_open_canceled_with_reason(&result, "blacklisted"); assert_next_open_canceled_with_reason(&result, "blacklisted");
} }
#[test]
fn next_bar_open_sell_risk_ignores_decision_day_paused_and_lower_limit_state() {
let first = d(2025, 1, 2);
let second = d(2025, 1, 3);
let third = d(2025, 1, 6);
let fourth = d(2025, 1, 7);
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
dataset_from_market_and_candidates(
vec![
market(first, 10.0, 10.5),
market(second, 11.0, 11.5),
market_with_state(third, 9.0, 9.0, true, 20.0, 9.0),
market(fourth, 12.0, 12.2),
],
vec![
candidate(first),
candidate(second),
candidate_with_sell_state(third, true, false),
candidate(fourth),
],
),
scheduled_next_open_broker(FidcRiskControlConfig::default()),
);
let sell_fill = result
.fills
.iter()
.find(|fill| fill.side == OrderSide::Sell)
.expect("sell should execute on actual execution date");
assert_eq!(sell_fill.date, fourth);
assert_eq!(sell_fill.price, 12.0);
}
#[test]
fn next_bar_open_sell_risk_rejects_execution_day_lower_limit_state() {
let first = d(2025, 1, 2);
let second = d(2025, 1, 3);
let third = d(2025, 1, 6);
let fourth = d(2025, 1, 7);
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
dataset_from_market_and_candidates(
vec![
market(first, 10.0, 10.5),
market(second, 11.0, 11.5),
market(third, 12.0, 12.5),
market_with_state(fourth, 9.0, 9.0, false, 20.0, 9.0),
],
vec![
candidate(first),
candidate(second),
candidate(third),
candidate(fourth),
],
),
scheduled_next_open_broker(FidcRiskControlConfig::default()),
);
assert_round_trip_sell_canceled_with_reason(&result, "open at or below lower limit");
}
#[test]
fn next_bar_open_sell_volume_limit_ignores_decision_day_zero_volume() {
let first = d(2025, 1, 2);
let second = d(2025, 1, 3);
let third = d(2025, 1, 6);
let fourth = d(2025, 1, 7);
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default())
.with_volume_limit(true)
.with_volume_percent(0.25);
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
dataset_from_market_and_candidates(
vec![
market(first, 10.0, 10.5),
market(second, 11.0, 11.5),
market_with_volume(third, 12.0, 12.5, 0),
market(fourth, 12.0, 12.2),
],
vec![
candidate(first),
candidate(second),
candidate(third),
candidate(fourth),
],
),
broker,
);
assert!(result.fills.iter().any(|fill| fill.side == OrderSide::Buy));
assert!(result.fills.iter().any(|fill| {
fill.side == OrderSide::Sell && fill.date == fourth && (fill.price - 12.0).abs() < 1e-9
}));
}
#[test]
fn next_bar_open_sell_volume_limit_rejects_execution_day_zero_volume() {
let first = d(2025, 1, 2);
let second = d(2025, 1, 3);
let third = d(2025, 1, 6);
let fourth = d(2025, 1, 7);
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default())
.with_volume_limit(true)
.with_volume_percent(0.25);
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
dataset_from_market_and_candidates(
vec![
market(first, 10.0, 10.5),
market(second, 11.0, 11.5),
market(third, 12.0, 12.5),
market_with_volume(fourth, 12.0, 12.2, 0),
],
vec![
candidate(first),
candidate(second),
candidate(third),
candidate(fourth),
],
),
broker,
);
assert_round_trip_sell_canceled_with_reason(&result, "daily volume limit");
}
} }