修正next-open成交日风控语义
This commit is contained in:
+198
-12
@@ -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")
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user