修正next-open成交日风控语义

This commit is contained in:
boris
2026-07-03 21:08:27 +08:00
parent 3d98ec35e7
commit 25001fd3e4
+198 -12
View File
@@ -1850,7 +1850,7 @@ where
process_events: &process_events,
active_process_event: None,
active_datetime: stage_datetime(
execution_date,
decision_date,
default_stage_time(ScheduleStage::BeforeTrading),
),
order_events: result.order_events.as_slice(),
@@ -1970,7 +1970,7 @@ where
process_events: &process_events,
active_process_event: None,
active_datetime: stage_datetime(
execution_date,
decision_date,
default_stage_time(ScheduleStage::OpenAuction),
),
order_events: result.order_events.as_slice(),
@@ -2086,7 +2086,7 @@ where
process_events: &process_events,
active_process_event: None,
active_datetime: stage_datetime(
execution_date,
decision_date,
default_stage_time(ScheduleStage::OnDay),
),
order_events: result.order_events.as_slice(),
@@ -2118,7 +2118,7 @@ where
process_events: &process_events,
active_process_event: None,
active_datetime: stage_datetime(
execution_date,
decision_date,
default_stage_time(ScheduleStage::OnDay),
),
order_events: result.order_events.as_slice(),
@@ -2215,7 +2215,7 @@ where
process_events: &process_events,
active_process_event: None,
active_datetime: stage_datetime(
execution_date,
decision_date,
default_stage_time(ScheduleStage::Bar),
),
order_events: result.order_events.as_slice(),
@@ -2519,7 +2519,7 @@ where
process_events: &process_events,
active_process_event: None,
active_datetime: stage_datetime(
execution_date,
decision_date,
default_stage_time(ScheduleStage::AfterTrading),
),
order_events: visible_order_events.as_slice(),
@@ -2653,7 +2653,7 @@ where
process_events: &process_events,
active_process_event: None,
active_datetime: stage_datetime(
execution_date,
decision_date,
default_stage_time(ScheduleStage::Settlement),
),
order_events: visible_order_events_after_close.as_slice(),
@@ -3243,7 +3243,7 @@ where
process_events: process_events.as_slice(),
active_process_event: None,
active_datetime: stage_datetime(
execution_date,
decision_date,
default_stage_time(ScheduleStage::Settlement),
),
order_events,
@@ -3594,7 +3594,11 @@ fn collect_scheduled_decisions<S: Strategy>(
fills: &[FillEvent],
) -> Result<crate::strategy::StrategyDecision, BacktestError> {
let mut combined = crate::strategy::StrategyDecision::default();
for rule in scheduler.triggered_rules_at(execution_date, stage, current_time, rules) {
// In lagged modes such as next_bar_open, scheduled callbacks generate
// signals on the decision date while the broker later matches them on the
// execution date. Triggering schedules with the execution date would let a
// T+1 calendar state suppress or create T-day signals.
for rule in scheduler.triggered_rules_at(decision_date, stage, current_time, rules) {
publish_phase_event(
strategy,
process_event_bus,
@@ -3625,7 +3629,7 @@ fn collect_scheduled_decisions<S: Strategy>(
subscriptions,
process_events: process_events.as_slice(),
active_process_event: None,
active_datetime: stage_datetime(execution_date, current_time),
active_datetime: stage_datetime(decision_date, current_time),
order_events,
fills,
},
@@ -3942,8 +3946,10 @@ mod tests {
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
PriceField,
};
use crate::events::OrderStatus;
use crate::instrument::Instrument;
use crate::rules::ChinaEquityRuleHooks;
use crate::scheduler::{ScheduleRule, ScheduleStage};
use crate::strategy::{OrderIntent, Strategy, StrategyContext, StrategyDecision};
const SYMBOL: &str = "000001.SZ";
@@ -3976,6 +3982,46 @@ mod tests {
}
}
#[derive(Debug)]
struct ScheduledBuyStrategy {
rule: ScheduleRule,
expected_decision_date: NaiveDate,
}
impl Strategy for ScheduledBuyStrategy {
fn name(&self) -> &str {
"scheduled_buy"
}
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.decision_date, self.expected_decision_date);
assert_eq!(
ctx.current_datetime().map(|value| value.date()),
Some(self.expected_decision_date)
);
if ctx.portfolio.position(SYMBOL).is_none() {
return Ok(StrategyDecision {
order_intents: vec![OrderIntent::Shares {
symbol: SYMBOL.to_string(),
quantity: 100,
reason: "scheduled_test_buy".to_string(),
}],
..StrategyDecision::default()
});
}
Ok(StrategyDecision::default())
}
}
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
}
@@ -4006,6 +4052,22 @@ mod tests {
}
}
fn market_with_state(
date: NaiveDate,
open: f64,
close: f64,
paused: bool,
upper_limit: f64,
lower_limit: f64,
) -> DailyMarketSnapshot {
DailyMarketSnapshot {
paused,
upper_limit,
lower_limit,
..market(date, open, close)
}
}
fn factor(date: NaiveDate) -> DailyFactorSnapshot {
DailyFactorSnapshot {
date,
@@ -4035,6 +4097,18 @@ mod tests {
}
}
fn candidate_with_state(
date: NaiveDate,
is_paused: bool,
allow_buy: bool,
) -> CandidateEligibility {
CandidateEligibility {
is_paused,
allow_buy,
..candidate(date)
}
}
fn benchmark(date: NaiveDate) -> BenchmarkSnapshot {
BenchmarkSnapshot {
date,
@@ -4049,6 +4123,22 @@ mod tests {
fn dataset() -> DataSet {
let first = d(2025, 1, 2);
let second = d(2025, 1, 3);
dataset_with(
market(first, 10.0, 11.5),
market(second, 12.0, 99.0),
candidate(first),
candidate(second),
)
}
fn dataset_with(
first_market: DailyMarketSnapshot,
second_market: DailyMarketSnapshot,
first_candidate: CandidateEligibility,
second_candidate: CandidateEligibility,
) -> DataSet {
let first = first_market.date;
let second = second_market.date;
DataSet::from_components(
vec![Instrument {
symbol: SYMBOL.to_string(),
@@ -4059,9 +4149,9 @@ mod tests {
delisted_at: None,
status: "active".to_string(),
}],
vec![market(first, 10.0, 11.5), market(second, 12.0, 99.0)],
vec![first_market, second_market],
vec![factor(first), factor(second)],
vec![candidate(first), candidate(second)],
vec![first_candidate, second_candidate],
vec![benchmark(first), benchmark(second)],
)
.expect("dataset")
@@ -4103,6 +4193,39 @@ mod tests {
.expect("backtest run")
}
fn run_scheduled_next_open_with_dataset(dataset: DataSet) -> super::BacktestResult {
let first = d(2025, 1, 2);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Open,
)
.with_matching_type(MatchingType::NextBarOpen)
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_inactive_limit(false);
let config = BacktestConfig {
initial_cash: 100_000.0,
benchmark_code: "000852.SH".to_string(),
start_date: Some(first),
end_date: Some(d(2025, 1, 3)),
decision_lag_trading_days: 1,
execution_price_field: PriceField::Open,
};
BacktestEngine::new(
dataset,
ScheduledBuyStrategy {
rule: ScheduleRule::weekly_by_weekday("weekly_signal", 4, ScheduleStage::OnDay),
expected_decision_date: first,
},
broker,
config,
)
.run()
.expect("backtest run")
}
#[test]
fn current_bar_close_uses_decision_day_close_for_fill() {
let result = run_with_matching(MatchingType::CurrentBarClose, PriceField::Close, 0);
@@ -4127,4 +4250,67 @@ mod tests {
result.equity_curve[0].diagnostics
);
}
#[test]
fn next_bar_open_scheduled_signal_uses_decision_date_not_execution_date() {
let result = run_scheduled_next_open_with_dataset(dataset());
assert_eq!(result.fills.len(), 1);
assert_eq!(result.fills[0].date, d(2025, 1, 3));
assert_eq!(result.fills[0].price, 12.0);
}
#[test]
fn next_bar_open_execution_risk_ignores_decision_day_paused_state() {
let first = d(2025, 1, 2);
let second = d(2025, 1, 3);
let result = run_scheduled_next_open_with_dataset(dataset_with(
market_with_state(first, 10.0, 11.5, true, 10.0, 9.0),
market_with_state(second, 12.0, 99.0, false, 200.0, 1.0),
candidate_with_state(first, true, false),
candidate(second),
));
assert_eq!(result.fills.len(), 1);
assert_eq!(result.fills[0].date, second);
assert_eq!(result.fills[0].price, 12.0);
}
#[test]
fn next_bar_open_execution_risk_rejects_execution_day_paused_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_with_state(second, 12.0, 99.0, true, 200.0, 1.0),
candidate(first),
candidate_with_state(second, true, true),
));
assert!(result.fills.is_empty());
assert!(result.order_events.iter().any(|event| {
event.date == second
&& event.status == OrderStatus::Canceled
&& event.reason.contains("paused")
}));
}
#[test]
fn next_bar_open_execution_risk_rejects_execution_day_upper_limit_buy() {
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_with_state(second, 12.0, 99.0, false, 12.0, 1.0),
candidate(first),
candidate(second),
));
assert!(result.fills.is_empty());
assert!(result.order_events.iter().any(|event| {
event.date == second
&& event.status == OrderStatus::Canceled
&& event.reason.contains("open at or above upper limit")
}));
}
}