Files
fidc-backtest-engine/crates/fidc-core/tests/automatic_trade_protection.rs
T

491 lines
18 KiB
Rust

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(),
}
}
#[test]
fn observed_manual_trades_then_split_keep_real_fill_protection_and_lock_dates() {
for sell_during_lock in [false, true] {
let sale = if sell_during_lock {
("manual-sell", "Sell", "2026-09-16T01:31:00Z", "2026-09-16T01:31:01Z", "5", "0.5", 200)
} else {
("manual-sell", "Sell", "2026-09-14T01:31:00Z", "2026-09-14T01:31:01Z", "10", "0.5", 100)
};
let actions = [
("new-buy", "Buy", "2026-09-14T01:30:00Z", "2026-09-14T01:30:01Z", "10", "0.25", 100),
("late-buy", "Buy", "2026-09-11T06:00:00Z", "2026-09-14T01:30:02Z", "10", "0.75", 100),
sale,
].into_iter().enumerate().map(|(index, (id, side, executed, observed, price, fee, quantity))| {
let executed: chrono::DateTime<chrono::Utc> = executed.parse().unwrap();
let observed: chrono::DateTime<chrono::Utc> = observed.parse().unwrap();
let created = executed - chrono::Duration::seconds(1);
serde_json::json!({"actionId":id,"source":"manual_security_trade","auditEventIds":[format!("audit-{id}")],
"confirmedAt":created,"confirmationObservedAt":created,"outcome":"orders_terminal","orders":[{
"orderId":id,"brokerOrderId":id,"sourceAdapter":"paper","symbol":"000001.SZ","side":side,"quantity":quantity,
"orderCreatedAt":created,"terminalObservedAt":observed,"terminalStatus":"filled","fills":[{
"tradeId":id,"observationEventId":id,"observationSequence":index+1,
"tradeDate":executed.date_naive(),"executedAt":executed,"observedAt":observed,
"feeObservationEventId":id,"feeObservationSequence":index+1,"feeObservedAt":observed,
"timestampPrecision":"second","quantity":quantity,"price":price,"totalFee":fee
}]
}]})
}).collect::<Vec<_>>();
let mut replay: fidc_core::manual_execution::ManualExecutionReplay = serde_json::from_value(serde_json::json!({
"schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a",
"sourceContractSha256":"a".repeat(64),"contentSha256":"","observationCutoff":"2026-09-18T08:00:00Z","actions":actions,
})).unwrap();
replay.content_sha256 = replay.content_digest().unwrap();
let mut parts = data().snapshot_components();
for row in &mut parts.market {
if row.date >= d(15) {
row.day_open = 5.;
row.open = 5.;
row.high = 5.;
row.low = 5.;
row.close = 5.;
row.last_price = 5.;
row.bid1 = 5.;
row.ask1 = 5.;
row.prev_close = 5.;
row.upper_limit = 5.5;
row.lower_limit = 4.5;
}
}
parts.corporate_actions.push(fidc_core::CorporateAction {
date: d(15),
symbol: "000001.SZ".into(),
payable_date: None,
share_cash: 0.,
share_bonus: 1.,
share_gift: 0.,
issue_quantity: 0.,
issue_price: 0.,
reform: false,
adjust_factor: None,
successor_symbol: None,
successor_ratio: None,
successor_cash: None,
});
let data = DataSet::from_components_with_actions(
parts.instruments,
parts.market,
parts.factors,
parts.candidates,
parts.benchmarks,
parts.corporate_actions,
)
.unwrap();
let mut config = PlatformExprStrategyConfig::generic();
config.signal_symbol = "000001.SZ".into();
config.benchmark_symbol = "000300.SH".into();
config.rotation_enabled = false;
config.matching_type = MatchingType::CurrentBarClose;
config.volume_capacity_mode =
fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
config.automatic_trade_protection = AutomaticTradeProtection {
buy_protection_days: 3,
sell_cooldown_days: 3,
max_holding_days: 1,
locks: vec![AutomaticTradeLock {
symbol: "000001.SZ".into(),
start_date: d(16),
end_date: Some(d(17)),
}],
};
config.explicit_actions = vec![action("-200", "decision_date >= \"2026-09-14\"")];
let result = BacktestEngine::new(
data,
PlatformExprStrategy::new(config),
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::CurrentBarClose)
.with_volume_capacity_mode(
fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit,
),
BacktestConfig {
initial_cash: 10000.,
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,
},
)
.with_observed_manual_executions(replay)
.unwrap()
.run()
.unwrap();
assert_eq!(result.manual_executions.len(), 3);
assert_eq!(result.manual_executions[2].quantity_after, if sell_during_lock { 200 } else { 100 });
assert_eq!(result.fills.len(), 1, "{:?}", result.fills);
assert_eq!(
(
result.fills[0].date,
result.fills[0].side,
result.fills[0].quantity,
result.fills[0].price
),
(d(18), OrderSide::Sell, 200, 5.)
);
assert!(result.fills[0].reason.contains("max_holding_days_exit"));
for day in [14, 15] {
for rule in ["buy_fill_protection", "sell_fill_cooldown"] {
if rule == "sell_fill_cooldown" && sell_during_lock { continue; }
assert!(result.risk_decisions.iter().any(|audit| audit.date == d(day)
&& audit.symbol == "000001.SZ" && audit.rule_code == rule && !audit.accepted), "day={day} rule={rule}");
}
}
for day in [16, 17] {
assert!(result.risk_decisions.iter().any(|audit| audit.date == d(day)
&& audit.symbol == "000001.SZ" && audit.rule_code == "automatic_trade_locked" && !audit.accepted));
}
assert!(
result
.daily_holdings
.iter()
.any(|row| row.date == d(15) && row.quantity == if sell_during_lock { 400 } else { 200 })
);
assert!(result.holdings_summary.is_empty());
assert!(
result
.equity_curve
.iter()
.all(|point| point.external_cash_flow == 0.)
);
}
}
fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult {
let mut config = PlatformExprStrategyConfig::generic();
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
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_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.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.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
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_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.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
);
}