修正next-open成交日风控语义
This commit is contained in:
+198
-12
@@ -1850,7 +1850,7 @@ where
|
|||||||
process_events: &process_events,
|
process_events: &process_events,
|
||||||
active_process_event: None,
|
active_process_event: None,
|
||||||
active_datetime: stage_datetime(
|
active_datetime: stage_datetime(
|
||||||
execution_date,
|
decision_date,
|
||||||
default_stage_time(ScheduleStage::BeforeTrading),
|
default_stage_time(ScheduleStage::BeforeTrading),
|
||||||
),
|
),
|
||||||
order_events: result.order_events.as_slice(),
|
order_events: result.order_events.as_slice(),
|
||||||
@@ -1970,7 +1970,7 @@ where
|
|||||||
process_events: &process_events,
|
process_events: &process_events,
|
||||||
active_process_event: None,
|
active_process_event: None,
|
||||||
active_datetime: stage_datetime(
|
active_datetime: stage_datetime(
|
||||||
execution_date,
|
decision_date,
|
||||||
default_stage_time(ScheduleStage::OpenAuction),
|
default_stage_time(ScheduleStage::OpenAuction),
|
||||||
),
|
),
|
||||||
order_events: result.order_events.as_slice(),
|
order_events: result.order_events.as_slice(),
|
||||||
@@ -2086,7 +2086,7 @@ where
|
|||||||
process_events: &process_events,
|
process_events: &process_events,
|
||||||
active_process_event: None,
|
active_process_event: None,
|
||||||
active_datetime: stage_datetime(
|
active_datetime: stage_datetime(
|
||||||
execution_date,
|
decision_date,
|
||||||
default_stage_time(ScheduleStage::OnDay),
|
default_stage_time(ScheduleStage::OnDay),
|
||||||
),
|
),
|
||||||
order_events: result.order_events.as_slice(),
|
order_events: result.order_events.as_slice(),
|
||||||
@@ -2118,7 +2118,7 @@ where
|
|||||||
process_events: &process_events,
|
process_events: &process_events,
|
||||||
active_process_event: None,
|
active_process_event: None,
|
||||||
active_datetime: stage_datetime(
|
active_datetime: stage_datetime(
|
||||||
execution_date,
|
decision_date,
|
||||||
default_stage_time(ScheduleStage::OnDay),
|
default_stage_time(ScheduleStage::OnDay),
|
||||||
),
|
),
|
||||||
order_events: result.order_events.as_slice(),
|
order_events: result.order_events.as_slice(),
|
||||||
@@ -2215,7 +2215,7 @@ where
|
|||||||
process_events: &process_events,
|
process_events: &process_events,
|
||||||
active_process_event: None,
|
active_process_event: None,
|
||||||
active_datetime: stage_datetime(
|
active_datetime: stage_datetime(
|
||||||
execution_date,
|
decision_date,
|
||||||
default_stage_time(ScheduleStage::Bar),
|
default_stage_time(ScheduleStage::Bar),
|
||||||
),
|
),
|
||||||
order_events: result.order_events.as_slice(),
|
order_events: result.order_events.as_slice(),
|
||||||
@@ -2519,7 +2519,7 @@ where
|
|||||||
process_events: &process_events,
|
process_events: &process_events,
|
||||||
active_process_event: None,
|
active_process_event: None,
|
||||||
active_datetime: stage_datetime(
|
active_datetime: stage_datetime(
|
||||||
execution_date,
|
decision_date,
|
||||||
default_stage_time(ScheduleStage::AfterTrading),
|
default_stage_time(ScheduleStage::AfterTrading),
|
||||||
),
|
),
|
||||||
order_events: visible_order_events.as_slice(),
|
order_events: visible_order_events.as_slice(),
|
||||||
@@ -2653,7 +2653,7 @@ where
|
|||||||
process_events: &process_events,
|
process_events: &process_events,
|
||||||
active_process_event: None,
|
active_process_event: None,
|
||||||
active_datetime: stage_datetime(
|
active_datetime: stage_datetime(
|
||||||
execution_date,
|
decision_date,
|
||||||
default_stage_time(ScheduleStage::Settlement),
|
default_stage_time(ScheduleStage::Settlement),
|
||||||
),
|
),
|
||||||
order_events: visible_order_events_after_close.as_slice(),
|
order_events: visible_order_events_after_close.as_slice(),
|
||||||
@@ -3243,7 +3243,7 @@ where
|
|||||||
process_events: process_events.as_slice(),
|
process_events: process_events.as_slice(),
|
||||||
active_process_event: None,
|
active_process_event: None,
|
||||||
active_datetime: stage_datetime(
|
active_datetime: stage_datetime(
|
||||||
execution_date,
|
decision_date,
|
||||||
default_stage_time(ScheduleStage::Settlement),
|
default_stage_time(ScheduleStage::Settlement),
|
||||||
),
|
),
|
||||||
order_events,
|
order_events,
|
||||||
@@ -3594,7 +3594,11 @@ fn collect_scheduled_decisions<S: Strategy>(
|
|||||||
fills: &[FillEvent],
|
fills: &[FillEvent],
|
||||||
) -> Result<crate::strategy::StrategyDecision, BacktestError> {
|
) -> Result<crate::strategy::StrategyDecision, BacktestError> {
|
||||||
let mut combined = crate::strategy::StrategyDecision::default();
|
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(
|
publish_phase_event(
|
||||||
strategy,
|
strategy,
|
||||||
process_event_bus,
|
process_event_bus,
|
||||||
@@ -3625,7 +3629,7 @@ fn collect_scheduled_decisions<S: Strategy>(
|
|||||||
subscriptions,
|
subscriptions,
|
||||||
process_events: process_events.as_slice(),
|
process_events: process_events.as_slice(),
|
||||||
active_process_event: None,
|
active_process_event: None,
|
||||||
active_datetime: stage_datetime(execution_date, current_time),
|
active_datetime: stage_datetime(decision_date, current_time),
|
||||||
order_events,
|
order_events,
|
||||||
fills,
|
fills,
|
||||||
},
|
},
|
||||||
@@ -3942,8 +3946,10 @@ mod tests {
|
|||||||
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
||||||
PriceField,
|
PriceField,
|
||||||
};
|
};
|
||||||
|
use crate::events::OrderStatus;
|
||||||
use crate::instrument::Instrument;
|
use crate::instrument::Instrument;
|
||||||
use crate::rules::ChinaEquityRuleHooks;
|
use crate::rules::ChinaEquityRuleHooks;
|
||||||
|
use crate::scheduler::{ScheduleRule, ScheduleStage};
|
||||||
use crate::strategy::{OrderIntent, Strategy, StrategyContext, StrategyDecision};
|
use crate::strategy::{OrderIntent, Strategy, StrategyContext, StrategyDecision};
|
||||||
|
|
||||||
const SYMBOL: &str = "000001.SZ";
|
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 {
|
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")
|
||||||
}
|
}
|
||||||
@@ -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 {
|
fn factor(date: NaiveDate) -> DailyFactorSnapshot {
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
date,
|
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 {
|
fn benchmark(date: NaiveDate) -> BenchmarkSnapshot {
|
||||||
BenchmarkSnapshot {
|
BenchmarkSnapshot {
|
||||||
date,
|
date,
|
||||||
@@ -4049,6 +4123,22 @@ mod tests {
|
|||||||
fn dataset() -> DataSet {
|
fn dataset() -> DataSet {
|
||||||
let first = d(2025, 1, 2);
|
let first = d(2025, 1, 2);
|
||||||
let second = d(2025, 1, 3);
|
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(
|
DataSet::from_components(
|
||||||
vec![Instrument {
|
vec![Instrument {
|
||||||
symbol: SYMBOL.to_string(),
|
symbol: SYMBOL.to_string(),
|
||||||
@@ -4059,9 +4149,9 @@ mod tests {
|
|||||||
delisted_at: None,
|
delisted_at: None,
|
||||||
status: "active".to_string(),
|
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![factor(first), factor(second)],
|
||||||
vec![candidate(first), candidate(second)],
|
vec![first_candidate, second_candidate],
|
||||||
vec![benchmark(first), benchmark(second)],
|
vec![benchmark(first), benchmark(second)],
|
||||||
)
|
)
|
||||||
.expect("dataset")
|
.expect("dataset")
|
||||||
@@ -4103,6 +4193,39 @@ mod tests {
|
|||||||
.expect("backtest run")
|
.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]
|
#[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);
|
||||||
@@ -4127,4 +4250,67 @@ mod tests {
|
|||||||
result.equity_curve[0].diagnostics
|
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