补齐next-open卖出执行日风控测试
This commit is contained in:
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user