统一策略成交保护与锁定周期并修正日期条件覆盖
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
use chrono::NaiveDate;
|
||||
use fidc_core::holding_policy::{AutomaticTradeLock, AutomaticTradeProtection};
|
||||
use fidc_core::{
|
||||
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
|
||||
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyMarketSnapshot, DataSet, Instrument,
|
||||
MatchingType, OrderSide, PlatformExplicitOrderKind, PlatformExprStrategy,
|
||||
PlatformExprStrategyConfig, PlatformTradeAction, PriceField,
|
||||
};
|
||||
|
||||
fn d(day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 9, day).unwrap()
|
||||
}
|
||||
fn data() -> DataSet {
|
||||
let dates = [11, 14, 15, 16, 17, 18].map(d);
|
||||
DataSet::from_components(
|
||||
vec![Instrument {
|
||||
symbol: "000001.SZ".into(),
|
||||
name: "测试".into(),
|
||||
board: "SZ".into(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()),
|
||||
delisted_at: None,
|
||||
status: "active".into(),
|
||||
}],
|
||||
dates
|
||||
.iter()
|
||||
.map(|date| DailyMarketSnapshot {
|
||||
date: *date,
|
||||
symbol: "000001.SZ".into(),
|
||||
timestamp: Some(format!("{date} 15:00:00")),
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
high: 10.0,
|
||||
low: 10.0,
|
||||
close: 10.0,
|
||||
last_price: 10.0,
|
||||
bid1: 10.0,
|
||||
ask1: 10.0,
|
||||
prev_close: 10.0,
|
||||
volume: 100_000,
|
||||
minute_volume: 100_000,
|
||||
bid1_volume: 100_000,
|
||||
ask1_volume: 100_000,
|
||||
trading_phase: Some("continuous".into()),
|
||||
paused: false,
|
||||
upper_limit: 11.0,
|
||||
lower_limit: 9.0,
|
||||
price_tick: 0.01,
|
||||
})
|
||||
.collect(),
|
||||
dates
|
||||
.iter()
|
||||
.map(|date| fidc_core::DailyFactorSnapshot {
|
||||
date: *date,
|
||||
symbol: "000001.SZ".into(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: Some(1.0),
|
||||
extra_factors: Default::default(),
|
||||
})
|
||||
.collect(),
|
||||
dates
|
||||
.iter()
|
||||
.map(|date| CandidateEligibility {
|
||||
date: *date,
|
||||
symbol: "000001.SZ".into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
})
|
||||
.collect(),
|
||||
dates
|
||||
.iter()
|
||||
.map(|date| BenchmarkSnapshot {
|
||||
date: *date,
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 100.0,
|
||||
close: 100.0,
|
||||
prev_close: 100.0,
|
||||
volume: 1_000_000,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn action(quantity: &str, when: &str) -> PlatformTradeAction {
|
||||
PlatformTradeAction::Order {
|
||||
kind: PlatformExplicitOrderKind::Shares,
|
||||
symbol: "000001.SZ".into(),
|
||||
amount_expr: quantity.into(),
|
||||
when_expr: Some(when.into()),
|
||||
limit_price_expr: None,
|
||||
time_in_force: None,
|
||||
start_time_expr: None,
|
||||
end_time_expr: None,
|
||||
reason: "configured_strategy_action".into(),
|
||||
}
|
||||
}
|
||||
fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult {
|
||||
let mut config = PlatformExprStrategyConfig::generic();
|
||||
config.signal_symbol = "000001.SZ".into();
|
||||
config.benchmark_symbol = "000300.SH".into();
|
||||
config.rotation_enabled = false;
|
||||
config.automatic_trade_protection = policy;
|
||||
config.explicit_actions = vec![
|
||||
action(
|
||||
"100",
|
||||
"decision_date == \"2026-09-11\" || decision_date == \"2026-09-18\"",
|
||||
),
|
||||
action("-100", "decision_date >= \"2026-09-14\""),
|
||||
];
|
||||
config.matching_type = MatchingType::CurrentBarClose;
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(MatchingType::CurrentBarClose);
|
||||
BacktestEngine::new(
|
||||
data(),
|
||||
PlatformExprStrategy::new(config),
|
||||
broker,
|
||||
BacktestConfig {
|
||||
initial_cash: 10_000.0,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(d(11)),
|
||||
end_date: Some(d(18)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Close,
|
||||
},
|
||||
)
|
||||
.run()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framework_protection_uses_fills_and_covers_explicit_strategy_orders() {
|
||||
let result = run(AutomaticTradeProtection {
|
||||
buy_protection_days: 3,
|
||||
sell_cooldown_days: 3,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
result
|
||||
.fills
|
||||
.iter()
|
||||
.map(|fill| (fill.date, fill.side, fill.quantity))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(d(11), OrderSide::Buy, 100), (d(17), OrderSide::Sell, 100)]
|
||||
);
|
||||
assert!(!result.order_events.iter().any(|order| order.date == d(14)
|
||||
|| order.date == d(15)
|
||||
|| order.date == d(16)
|
||||
|| order.date == d(18)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_lock_blocks_initial_strategy_buy_without_a_rejected_order() {
|
||||
let result = run(AutomaticTradeProtection {
|
||||
locks: vec![AutomaticTradeLock {
|
||||
symbol: "000001.SZ".into(),
|
||||
start_date: d(11),
|
||||
end_date: None,
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
assert!(result.fills.is_empty());
|
||||
assert!(result.order_events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximum_holding_policy_applies_to_discrete_strategies_and_yields_to_buy_protection() {
|
||||
let result = run(AutomaticTradeProtection {
|
||||
max_holding_days: 1,
|
||||
buy_protection_days: 3,
|
||||
sell_cooldown_days: 3,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
result
|
||||
.fills
|
||||
.iter()
|
||||
.map(|fill| (fill.date, fill.side))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(d(11), OrderSide::Buy), (d(17), OrderSide::Sell)]
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.order_events
|
||||
.iter()
|
||||
.any(|order| order.reason == "max_holding_days_exit")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_framework_policy_survives_shared_alias_normalization_and_rejects_conflicts() {
|
||||
let policy = serde_json::json!({"buy_protection_days":3,"sell_cooldown_days":3,"max_holding_days":90,"locks":[{"symbol":"000001.SZ","start_date":"2026-09-11","end_date":null}]});
|
||||
for key in ["automaticTradeProtection", "automatic_trade_protection"] {
|
||||
let value = serde_json::json!({"runtimeExpressions":{"trading":{key:policy}}});
|
||||
let cfg = fidc_core::platform_expr_config_from_value("test", "000001.SZ", &value).unwrap();
|
||||
assert_eq!(cfg.automatic_trade_protection.buy_protection_days, 3);
|
||||
assert_eq!(cfg.max_holding_days, Some(90));
|
||||
assert_eq!(cfg.automatic_trade_protection.locks.len(), 1);
|
||||
}
|
||||
let conflict = serde_json::json!({"runtimeExpressions":{"trading":{"maxHoldingDays":30,"automaticTradeProtection":policy}}});
|
||||
assert!(
|
||||
fidc_core::platform_expr_config_from_value("test", "000001.SZ", &conflict)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("conflicting maximum")
|
||||
);
|
||||
let unknown = serde_json::json!({"runtimeExpressions":{"trading":{"automaticTradeProtection":{"origin":"manual"}}}});
|
||||
assert!(fidc_core::platform_expr_config_from_value("test", "000001.SZ", &unknown).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_holding_keeps_its_slot_even_when_cash_can_buy_the_next_candidate() {
|
||||
let base = data();
|
||||
let dates = [11, 14, 15, 16, 17, 18].map(d);
|
||||
let symbols = ["000001.SZ", "000002.SZ"];
|
||||
let dataset = DataSet::from_components(
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| {
|
||||
let mut row = base.instruments()["000001.SZ"].clone();
|
||||
row.symbol = (*symbol).into();
|
||||
row
|
||||
})
|
||||
.collect(),
|
||||
dates
|
||||
.iter()
|
||||
.flat_map(|date| {
|
||||
symbols.iter().map(|symbol| {
|
||||
let mut row = base.market(*date, "000001.SZ").unwrap().clone();
|
||||
row.symbol = (*symbol).into();
|
||||
row
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
dates
|
||||
.iter()
|
||||
.flat_map(|date| {
|
||||
symbols.iter().map(|symbol| {
|
||||
let mut row = base.factor(*date, "000001.SZ").unwrap().clone();
|
||||
row.symbol = (*symbol).into();
|
||||
row
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
dates
|
||||
.iter()
|
||||
.flat_map(|date| {
|
||||
symbols.iter().map(|symbol| {
|
||||
let mut row = base.candidate(*date, "000001.SZ").unwrap().clone();
|
||||
row.symbol = (*symbol).into();
|
||||
row
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
dates
|
||||
.iter()
|
||||
.map(|date| BenchmarkSnapshot {
|
||||
date: *date,
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 100.0,
|
||||
close: 100.0,
|
||||
prev_close: 100.0,
|
||||
volume: 100_000,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut config = PlatformExprStrategyConfig::generic();
|
||||
config.signal_symbol = "000001.SZ".into();
|
||||
config.benchmark_symbol = "000300.SH".into();
|
||||
config.strategy_name = "protection_test".into();
|
||||
config.max_positions = 1;
|
||||
config.selection_limit_expr = "1".into();
|
||||
config.refresh_rate = 1;
|
||||
config.exposure_expr = "0.5".into();
|
||||
config.market_cap_lower_expr = "0".into();
|
||||
config.market_cap_upper_expr = "100".into();
|
||||
config.stock_filter_expr="(decision_date == \"2026-09-11\" && symbol == \"000001.SZ\") || (decision_date != \"2026-09-11\" && symbol == \"000002.SZ\")".into();
|
||||
config.automatic_trade_protection = AutomaticTradeProtection {
|
||||
locks: vec![AutomaticTradeLock {
|
||||
symbol: "000001.SZ".into(),
|
||||
start_date: d(14),
|
||||
end_date: Some(d(16)),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(MatchingType::CurrentBarClose);
|
||||
let result = BacktestEngine::new(
|
||||
dataset,
|
||||
PlatformExprStrategy::new(config),
|
||||
broker,
|
||||
BacktestConfig {
|
||||
initial_cash: 10_000.0,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(d(11)),
|
||||
end_date: Some(d(18)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Close,
|
||||
},
|
||||
)
|
||||
.run()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result
|
||||
.fills
|
||||
.first()
|
||||
.map(|fill| (fill.symbol.as_str(), fill.date)),
|
||||
Some(("000001.SZ", d(11)))
|
||||
);
|
||||
assert!(
|
||||
!result
|
||||
.fills
|
||||
.iter()
|
||||
.any(|fill| [d(14), d(15), d(16)].contains(&fill.date)),
|
||||
"{:?}",
|
||||
result.fills
|
||||
);
|
||||
assert!(
|
||||
result.fills.iter().any(|fill| fill.symbol == "000002.SZ"
|
||||
&& fill.side == OrderSide::Buy
|
||||
&& fill.date == d(17)),
|
||||
"{:?}",
|
||||
result.fills
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user