Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e0877b586 | |||
| 36833b7a6a | |||
| 4c0157b66c | |||
| f7d16fb664 | |||
| 97cdfa5972 | |||
| f2e228e0a3 | |||
| 33924b1fba |
@@ -381,6 +381,8 @@ pub struct BrokerSimulator<C, R> {
|
||||
runtime_intraday_end_time: Cell<Option<NaiveTime>>,
|
||||
runtime_decision_date: Cell<Option<NaiveDate>>,
|
||||
runtime_buy_denials: RefCell<BTreeMap<String, String>>,
|
||||
runtime_auto_buy_denials: RefCell<BTreeMap<String, String>>,
|
||||
runtime_auto_sell_denials: RefCell<BTreeMap<String, String>>,
|
||||
runtime_order_created_date: Cell<Option<NaiveDate>>,
|
||||
runtime_decision_total_equity: Cell<Option<f64>>,
|
||||
runtime_target_position_limit: Cell<Option<usize>>,
|
||||
@@ -414,6 +416,8 @@ impl<C, R> BrokerSimulator<C, R> {
|
||||
runtime_intraday_end_time: Cell::new(None),
|
||||
runtime_decision_date: Cell::new(None),
|
||||
runtime_buy_denials: RefCell::new(BTreeMap::new()),
|
||||
runtime_auto_buy_denials: RefCell::new(BTreeMap::new()),
|
||||
runtime_auto_sell_denials: RefCell::new(BTreeMap::new()),
|
||||
runtime_order_created_date: Cell::new(None),
|
||||
runtime_decision_total_equity: Cell::new(None),
|
||||
runtime_target_position_limit: Cell::new(None),
|
||||
@@ -451,6 +455,8 @@ impl<C, R> BrokerSimulator<C, R> {
|
||||
runtime_intraday_end_time: Cell::new(None),
|
||||
runtime_decision_date: Cell::new(None),
|
||||
runtime_buy_denials: RefCell::new(BTreeMap::new()),
|
||||
runtime_auto_buy_denials: RefCell::new(BTreeMap::new()),
|
||||
runtime_auto_sell_denials: RefCell::new(BTreeMap::new()),
|
||||
runtime_order_created_date: Cell::new(None),
|
||||
runtime_decision_total_equity: Cell::new(None),
|
||||
runtime_target_position_limit: Cell::new(None),
|
||||
@@ -1389,6 +1395,11 @@ where
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let previous_decision_date = self.runtime_decision_date.get();
|
||||
let previous_buy_denials = self.runtime_buy_denials.replace(decision.buy_denials.clone());
|
||||
let protection_denials = |scope| decision.risk_decisions.iter()
|
||||
.filter(|row| !row.accepted && row.stage == "automatic_trade_protection" && row.scope == scope)
|
||||
.map(|row| (row.symbol.clone(), row.reason.clone())).collect();
|
||||
let previous_auto_buy_denials = self.runtime_auto_buy_denials.replace(protection_denials(crate::risk_control::RiskCheckScope::Buy));
|
||||
let previous_auto_sell_denials = self.runtime_auto_sell_denials.replace(protection_denials(crate::risk_control::RiskCheckScope::Sell));
|
||||
let previous_order_created_date = self.runtime_order_created_date.get();
|
||||
let previous_decision_total_equity = self.runtime_decision_total_equity.get();
|
||||
self.runtime_decision_date.set(Some(decision_date));
|
||||
@@ -1398,6 +1409,8 @@ where
|
||||
.set(decision_total_equity.filter(|equity| equity.is_finite() && *equity >= 0.0));
|
||||
let result = self.execute_with_runtime_dates(date, portfolio, data, decision);
|
||||
self.runtime_buy_denials.replace(previous_buy_denials);
|
||||
self.runtime_auto_buy_denials.replace(previous_auto_buy_denials);
|
||||
self.runtime_auto_sell_denials.replace(previous_auto_sell_denials);
|
||||
self.runtime_decision_date.set(previous_decision_date);
|
||||
self.runtime_order_created_date
|
||||
.set(previous_order_created_date);
|
||||
@@ -2850,6 +2863,15 @@ where
|
||||
return;
|
||||
}
|
||||
|
||||
let protection = match existing.side {
|
||||
OrderSide::Buy => self.runtime_auto_buy_denials.borrow().get(&existing.symbol).cloned(),
|
||||
OrderSide::Sell => self.runtime_auto_sell_denials.borrow().get(&existing.symbol).cloned(),
|
||||
};
|
||||
if let Some(denial) = protection
|
||||
&& (target_limit_price.to_bits() != existing.limit_price.to_bits() || target_total_quantity != existing.requested_quantity) {
|
||||
Self::emit_open_order_update_rejected(report, date, order_id, Some(&existing.symbol), Some(existing.side), reason, &denial);
|
||||
return;
|
||||
}
|
||||
let resets_queue_priority = target_limit_price.to_bits() != existing.limit_price.to_bits()
|
||||
|| target_total_quantity > existing.requested_quantity;
|
||||
{
|
||||
@@ -4139,6 +4161,9 @@ where
|
||||
minimum_order_quantity: u32,
|
||||
order_step_size: u32,
|
||||
) -> Option<String> {
|
||||
if let Some(reason) = self.runtime_auto_sell_denials.borrow().get(symbol) {
|
||||
return Some(reason.clone());
|
||||
}
|
||||
if current_qty == 0 {
|
||||
return None;
|
||||
}
|
||||
@@ -4299,6 +4324,10 @@ where
|
||||
algo_request: Option<&AlgoExecutionRequest>,
|
||||
report: &mut BrokerExecutionReport,
|
||||
) -> Result<(), BacktestError> {
|
||||
// Existing accepted orders are not canceled by a subsequently enabled lock.
|
||||
if emit_creation_events && self.runtime_auto_sell_denials.borrow().contains_key(symbol) {
|
||||
return Ok(());
|
||||
}
|
||||
let remainder_policy = self.effective_remainder_policy(date, allow_pending_limit);
|
||||
let Some(position) = portfolio.position(symbol) else {
|
||||
return Ok(());
|
||||
@@ -6074,6 +6103,9 @@ where
|
||||
algo_request: Option<&AlgoExecutionRequest>,
|
||||
report: &mut BrokerExecutionReport,
|
||||
) -> Result<(), BacktestError> {
|
||||
if emit_creation_events && self.runtime_auto_buy_denials.borrow().contains_key(symbol) {
|
||||
return Ok(());
|
||||
}
|
||||
let remainder_policy = self.effective_remainder_policy(date, allow_pending_limit);
|
||||
if portfolio
|
||||
.position(symbol)
|
||||
@@ -7286,9 +7318,15 @@ where
|
||||
execution_price: f64,
|
||||
) -> Option<&'static str> {
|
||||
if !execution_price.is_finite() || execution_price <= 0.0 {
|
||||
return None;
|
||||
return Some("invalid execution price");
|
||||
}
|
||||
match side {
|
||||
OrderSide::Buy
|
||||
if self.risk_config.static_rules.reject_one_yuan_buy
|
||||
&& execution_price <= 1.0 =>
|
||||
{
|
||||
Some("one_yuan")
|
||||
}
|
||||
OrderSide::Buy
|
||||
if self.risk_config.static_rules.reject_upper_limit_buy
|
||||
&& snapshot.is_at_upper_limit_price(execution_price) =>
|
||||
@@ -7591,6 +7629,11 @@ where
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, raw_quote_price) {
|
||||
execution_block_reason.get_or_insert(reason);
|
||||
execution_block_timestamp = Some(quote.timestamp);
|
||||
continue;
|
||||
}
|
||||
let mark_price = self.quote_mark_price(quote, raw_quote_price);
|
||||
let remaining_qty = requested_qty.saturating_sub(filled_qty);
|
||||
if remaining_qty == 0 {
|
||||
@@ -8564,6 +8607,54 @@ mod tests {
|
||||
assert_eq!(fill.quantity, 1_200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_execution_leg_rechecks_one_yuan_including_slippage_and_limit_price() {
|
||||
let mut snapshot = limit_test_snapshot();
|
||||
snapshot.open = 1.2;
|
||||
snapshot.last_price = 1.2;
|
||||
snapshot.upper_limit = 2.0;
|
||||
snapshot.lower_limit = 0.5;
|
||||
let date = snapshot.date;
|
||||
let start = date.and_hms_opt(10, 0, 0).unwrap();
|
||||
let end = date.and_hms_opt(10, 2, 0).unwrap();
|
||||
let mut cheap = limit_test_quote(0.9, 0.9, 0.9);
|
||||
cheap.timestamp = date.and_hms_opt(10, 1, 0).unwrap();
|
||||
let mut later = limit_test_quote(1.2, 1.2, 1.2);
|
||||
later.timestamp = end;
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_volume_limit(false).with_liquidity_limit(false);
|
||||
|
||||
let fill = broker.select_execution_fill(
|
||||
&snapshot, &[cheap.clone(), later], OrderSide::Buy, MatchingType::Vwap,
|
||||
Some(start), Some(end), 100, 100, 100, 100, false, None, None, None,
|
||||
).unwrap();
|
||||
assert_eq!(fill.quantity, 100);
|
||||
assert_eq!(fill.legs.len(), 1);
|
||||
assert_eq!(fill.legs[0].execution_timestamp, Some(end));
|
||||
assert_eq!(fill.legs[0].price, 1.2);
|
||||
|
||||
let slipped = broker.with_slippage_model(SlippageModel::PriceRatio(0.2));
|
||||
let blocked = slipped.select_execution_fill(
|
||||
&snapshot, &[cheap], OrderSide::Buy, MatchingType::Vwap,
|
||||
Some(start), Some(end), 100, 100, 100, 100, false, None, None, None,
|
||||
).unwrap();
|
||||
assert_eq!(blocked.quantity, 0);
|
||||
assert_eq!(blocked.unfilled_reason, Some("one_yuan"));
|
||||
assert_eq!(slipped.execution_price_with_limit_slippage_or_rejection(&snapshot, OrderSide::Buy, 1.0, None), Err("one_yuan"));
|
||||
|
||||
let limit_broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_slippage_model(SlippageModel::LimitPrice);
|
||||
assert_eq!(limit_broker.execution_price_with_limit_slippage_or_rejection(
|
||||
&snapshot, OrderSide::Buy, 1.2, Some(0.9)), Err("one_yuan"));
|
||||
let mut risk = FidcRiskControlConfig::default();
|
||||
risk.static_rules.reject_one_yuan_buy = false;
|
||||
let allowed = limit_broker.with_risk_config(risk);
|
||||
assert_eq!(allowed.execution_price_with_limit_slippage_or_rejection(
|
||||
&snapshot, OrderSide::Buy, 1.2, Some(0.9)), Ok(0.9));
|
||||
assert_eq!(allowed.execution_limit_rejection_reason(&snapshot, OrderSide::Buy, f64::NAN), Some("invalid execution price"));
|
||||
assert_eq!(allowed.execution_limit_rejection_reason(&snapshot, OrderSide::Sell, 0.9), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minute_last_uses_volume_delta_when_level1_depth_missing() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::TradingCalendar;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TradingActionOrigin {
|
||||
Strategy,
|
||||
Manual,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AutomaticTradeProtection {
|
||||
#[serde(default, deserialize_with = "optional_days")]
|
||||
pub buy_protection_days: u32,
|
||||
#[serde(default, deserialize_with = "optional_days")]
|
||||
pub sell_cooldown_days: u32,
|
||||
#[serde(default, deserialize_with = "optional_days")]
|
||||
pub max_holding_days: u32,
|
||||
#[serde(default, deserialize_with = "optional_locks")]
|
||||
pub locks: Vec<AutomaticTradeLock>,
|
||||
}
|
||||
|
||||
pub fn deserialize_optional_policy<'de, D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<AutomaticTradeProtection, D::Error> {
|
||||
Ok(Option::<AutomaticTradeProtection>::deserialize(deserializer)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn optional_days<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<u32, D::Error> {
|
||||
let raw = serde_json::Value::deserialize(deserializer)?;
|
||||
if raw.is_null() {
|
||||
return Ok(0);
|
||||
}
|
||||
raw.as_f64()
|
||||
.filter(|value| {
|
||||
value.is_finite() && value.fract() == 0.0 && *value >= 0.0 && *value <= 3650.0
|
||||
})
|
||||
.map(|value| value as u32)
|
||||
.ok_or_else(|| serde::de::Error::custom("protection days must be integers in 0..3650"))
|
||||
}
|
||||
|
||||
fn optional_locks<'de, D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<Vec<AutomaticTradeLock>, D::Error> {
|
||||
Ok(Option::<Vec<AutomaticTradeLock>>::deserialize(deserializer)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AutomaticTradeLock {
|
||||
pub symbol: String,
|
||||
pub start_date: NaiveDate,
|
||||
pub end_date: Option<NaiveDate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct HoldingLifecycleEvidence {
|
||||
pub has_position: bool,
|
||||
pub opened_date: Option<NaiveDate>,
|
||||
pub last_buy_date: Option<NaiveDate>,
|
||||
pub last_sell_date: Option<NaiveDate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct AutomaticTradePermission {
|
||||
pub buy_denial: Option<&'static str>,
|
||||
pub sell_denial: Option<&'static str>,
|
||||
pub max_holding_exit: bool,
|
||||
}
|
||||
|
||||
impl AutomaticTradeProtection {
|
||||
pub fn enabled(&self) -> bool {
|
||||
self.buy_protection_days > 0
|
||||
|| self.sell_cooldown_days > 0
|
||||
|| self.max_holding_days > 0
|
||||
|| !self.locks.is_empty()
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if [
|
||||
self.buy_protection_days,
|
||||
self.sell_cooldown_days,
|
||||
self.max_holding_days,
|
||||
]
|
||||
.into_iter()
|
||||
.any(|days| days > 3650)
|
||||
{
|
||||
return Err("automatic_trade_holding_days_out_of_range: expected 0..3650".into());
|
||||
}
|
||||
if self.locks.len() > 2000 {
|
||||
return Err("automatic_trade_locks_limit: maximum 2000 intervals".into());
|
||||
}
|
||||
for lock in &self.locks {
|
||||
let valid_symbol = lock.symbol.split_once('.').is_some_and(|(code, venue)| {
|
||||
code.len() == 6
|
||||
&& code.bytes().all(|ch| ch.is_ascii_digit())
|
||||
&& matches!(venue, "SH" | "SZ" | "BJ")
|
||||
});
|
||||
if !valid_symbol {
|
||||
return Err(format!(
|
||||
"automatic_trade_lock_invalid_symbol: {}",
|
||||
lock.symbol
|
||||
));
|
||||
}
|
||||
if lock.end_date.is_some_and(|end| end < lock.start_date) {
|
||||
return Err(format!(
|
||||
"automatic_trade_lock_invalid_interval: {}",
|
||||
lock.symbol
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn evaluate(
|
||||
&self,
|
||||
symbol: &str,
|
||||
execution_date: NaiveDate,
|
||||
evidence: &HoldingLifecycleEvidence,
|
||||
calendar: &TradingCalendar,
|
||||
) -> Result<AutomaticTradePermission, String> {
|
||||
self.validate()?;
|
||||
if self.locks.iter().any(|lock| {
|
||||
lock.symbol == symbol
|
||||
&& lock.start_date <= execution_date
|
||||
&& lock.end_date.is_none_or(|end| execution_date <= end)
|
||||
}) {
|
||||
return Ok(AutomaticTradePermission {
|
||||
buy_denial: Some("automatic_trade_locked"),
|
||||
sell_denial: Some("automatic_trade_locked"),
|
||||
max_holding_exit: false,
|
||||
});
|
||||
}
|
||||
let elapsed = |date: NaiveDate| -> Result<usize, String> {
|
||||
let start = calendar.index_of(date).ok_or_else(|| {
|
||||
format!(
|
||||
"automatic_trade_holding_calendar_missing: symbol={symbol} fact_date={date}"
|
||||
)
|
||||
})?;
|
||||
let end = calendar.index_of(execution_date).ok_or_else(|| format!("automatic_trade_holding_calendar_missing: symbol={symbol} execution_date={execution_date}"))?;
|
||||
end.checked_sub(start).ok_or_else(|| format!("automatic_trade_holding_future_fact: symbol={symbol} fact_date={date} execution_date={execution_date}"))
|
||||
};
|
||||
let mut decision = AutomaticTradePermission::default();
|
||||
if self.buy_protection_days > 0
|
||||
&& evidence.has_position
|
||||
&& let Some(date) = evidence.last_buy_date
|
||||
&& elapsed(date)? <= self.buy_protection_days as usize
|
||||
{
|
||||
decision.sell_denial = Some("buy_fill_protection");
|
||||
}
|
||||
if self.sell_cooldown_days > 0
|
||||
&& let Some(date) = evidence.last_sell_date
|
||||
&& elapsed(date)? <= self.sell_cooldown_days as usize
|
||||
{
|
||||
decision.buy_denial = Some("sell_fill_cooldown");
|
||||
}
|
||||
if self.max_holding_days > 0 && evidence.has_position {
|
||||
let opened = evidence.opened_date.ok_or_else(|| format!("automatic_trade_opened_date_missing: symbol={symbol}; require confirmed position lifecycle evidence"))?;
|
||||
decision.max_holding_exit = elapsed(opened)? >= self.max_holding_days as usize
|
||||
&& decision.sell_denial.is_none();
|
||||
if decision.max_holding_exit {
|
||||
decision.buy_denial = Some("maximum_holding_exit");
|
||||
}
|
||||
}
|
||||
Ok(decision)
|
||||
}
|
||||
|
||||
/// The caller supplies origin from its authenticated execution path, never
|
||||
/// from an untrusted order-body flag. Broker and ordinary risk checks remain.
|
||||
pub fn evaluate_for_origin(
|
||||
&self,
|
||||
origin: TradingActionOrigin,
|
||||
symbol: &str,
|
||||
execution_date: NaiveDate,
|
||||
evidence: &HoldingLifecycleEvidence,
|
||||
calendar: &TradingCalendar,
|
||||
) -> Result<AutomaticTradePermission, String> {
|
||||
self.validate()?;
|
||||
match origin {
|
||||
TradingActionOrigin::Strategy => {
|
||||
self.evaluate(symbol, execution_date, evidence, calendar)
|
||||
}
|
||||
TradingActionOrigin::Manual => Ok(AutomaticTradePermission::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn d(value: &str) -> NaiveDate {
|
||||
NaiveDate::parse_from_str(value, "%Y-%m-%d").unwrap()
|
||||
}
|
||||
fn calendar() -> TradingCalendar {
|
||||
TradingCalendar::new(
|
||||
[
|
||||
"2026-09-11",
|
||||
"2026-09-14",
|
||||
"2026-09-15",
|
||||
"2026-09-16",
|
||||
"2026-09-17",
|
||||
]
|
||||
.into_iter()
|
||||
.map(d)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_complete_sessions_protect_through_wednesday_not_72_hours() {
|
||||
let policy = AutomaticTradeProtection {
|
||||
buy_protection_days: 3,
|
||||
sell_cooldown_days: 3,
|
||||
..Default::default()
|
||||
};
|
||||
let evidence = HoldingLifecycleEvidence {
|
||||
has_position: true,
|
||||
last_buy_date: Some(d("2026-09-11")),
|
||||
last_sell_date: Some(d("2026-09-11")),
|
||||
..Default::default()
|
||||
};
|
||||
for day in ["2026-09-11", "2026-09-14", "2026-09-15", "2026-09-16"] {
|
||||
let decision = policy
|
||||
.evaluate("000001.SZ", d(day), &evidence, &calendar())
|
||||
.unwrap();
|
||||
assert_eq!(decision.sell_denial, Some("buy_fill_protection"));
|
||||
assert_eq!(decision.buy_denial, Some("sell_fill_cooldown"));
|
||||
}
|
||||
assert_eq!(
|
||||
policy
|
||||
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
|
||||
.unwrap(),
|
||||
AutomaticTradePermission::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_locks_are_inclusive_and_override_timed_exit_without_changing_other_symbols() {
|
||||
let policy = AutomaticTradeProtection {
|
||||
max_holding_days: 1,
|
||||
locks: vec![AutomaticTradeLock {
|
||||
symbol: "000001.SZ".into(),
|
||||
start_date: d("2026-09-11"),
|
||||
end_date: Some(d("2026-09-16")),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let evidence = HoldingLifecycleEvidence {
|
||||
has_position: true,
|
||||
opened_date: Some(d("2026-09-11")),
|
||||
..Default::default()
|
||||
};
|
||||
let locked = policy
|
||||
.evaluate("000001.SZ", d("2026-09-16"), &evidence, &calendar())
|
||||
.unwrap();
|
||||
assert_eq!(locked.sell_denial, Some("automatic_trade_locked"));
|
||||
assert!(!locked.max_holding_exit);
|
||||
assert!(
|
||||
policy
|
||||
.evaluate("600000.SH", d("2026-09-16"), &evidence, &calendar())
|
||||
.unwrap()
|
||||
.max_holding_exit
|
||||
);
|
||||
assert!(
|
||||
policy
|
||||
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
|
||||
.unwrap()
|
||||
.max_holding_exit
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_disabled_and_missing_calendar_or_opened_date_are_not_inferred() {
|
||||
let evidence = HoldingLifecycleEvidence {
|
||||
has_position: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
AutomaticTradeProtection::default()
|
||||
.evaluate(
|
||||
"000001.SZ",
|
||||
d("2026-09-17"),
|
||||
&evidence,
|
||||
&TradingCalendar::new(vec![])
|
||||
)
|
||||
.unwrap(),
|
||||
AutomaticTradePermission::default()
|
||||
);
|
||||
let policy = AutomaticTradeProtection {
|
||||
max_holding_days: 1,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
policy
|
||||
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
|
||||
.unwrap_err()
|
||||
.contains("opened_date_missing")
|
||||
);
|
||||
let evidence = HoldingLifecycleEvidence {
|
||||
opened_date: Some(d("2026-09-10")),
|
||||
..evidence
|
||||
};
|
||||
assert!(
|
||||
policy
|
||||
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
|
||||
.unwrap_err()
|
||||
.contains("calendar_missing")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_origin_only_bypasses_automatic_policy_not_an_order_or_broker_permission() {
|
||||
let policy = AutomaticTradeProtection {
|
||||
locks: vec![AutomaticTradeLock {
|
||||
symbol: "000001.SZ".into(),
|
||||
start_date: d("2026-09-11"),
|
||||
end_date: None,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
policy
|
||||
.evaluate_for_origin(
|
||||
TradingActionOrigin::Manual,
|
||||
"000001.SZ",
|
||||
d("2026-09-14"),
|
||||
&HoldingLifecycleEvidence::default(),
|
||||
&calendar()
|
||||
)
|
||||
.unwrap(),
|
||||
AutomaticTradePermission::default()
|
||||
);
|
||||
assert_eq!(
|
||||
policy
|
||||
.evaluate_for_origin(
|
||||
TradingActionOrigin::Strategy,
|
||||
"000001.SZ",
|
||||
d("2026-09-14"),
|
||||
&HoldingLifecycleEvidence::default(),
|
||||
&calendar()
|
||||
)
|
||||
.unwrap()
|
||||
.buy_denial,
|
||||
Some("automatic_trade_locked")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_date_follows_fills_not_partial_sales_or_corporate_conversions() {
|
||||
let mut portfolio = crate::PortfolioState::new(100_000.0);
|
||||
let position = portfolio.position_mut("000001.SZ");
|
||||
position.buy(d("2026-09-11"), 100, 10.0);
|
||||
position.buy(d("2026-09-14"), 200, 10.0);
|
||||
position.sell(100, 10.0).unwrap();
|
||||
assert_eq!(position.opened_date(), Some(d("2026-09-11")));
|
||||
portfolio
|
||||
.apply_successor_conversion("000001.SZ", "000002.SZ", 2.0, 0.0)
|
||||
.unwrap();
|
||||
let successor = portfolio.position_mut("000002.SZ");
|
||||
assert_eq!(successor.opened_date(), Some(d("2026-09-11")));
|
||||
assert_eq!(successor.last_buy_date(), Some(d("2026-09-14")));
|
||||
successor.sell(400, 5.0).unwrap();
|
||||
assert_eq!(successor.opened_date(), None);
|
||||
successor.buy(d("2026-09-17"), 100, 5.0);
|
||||
assert_eq!(successor.opened_date(), Some(d("2026-09-17")));
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ pub mod risk_control;
|
||||
pub mod rules;
|
||||
pub mod scheduler;
|
||||
pub mod strategy;
|
||||
pub mod holding_policy;
|
||||
pub mod signal_contract;
|
||||
pub mod strategy_ai;
|
||||
pub mod universe;
|
||||
|
||||
@@ -27,6 +27,7 @@ use crate::numeric_expr_vm::{
|
||||
Scratch as NumericVmScratch, Value as NumericVmValue, ValueType as NumericVmValueType,
|
||||
};
|
||||
use crate::portfolio::PortfolioState;
|
||||
use crate::holding_policy::{AutomaticTradeProtection, AutomaticTradePermission, HoldingLifecycleEvidence};
|
||||
use crate::portfolio_loss::{ClosedPortfolioSession, PortfolioLossConfig, PortfolioLossState};
|
||||
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit};
|
||||
use crate::scheduler::{ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler};
|
||||
@@ -657,6 +658,7 @@ pub struct PlatformExprStrategyConfig {
|
||||
pub retry_empty_rebalance: bool,
|
||||
pub calendar_rebalance_interval: bool,
|
||||
pub max_holding_days: Option<i64>,
|
||||
pub automatic_trade_protection: AutomaticTradeProtection,
|
||||
pub weak_market_shrink_overweight_threshold: Option<f64>,
|
||||
pub commission_rate: Option<f64>,
|
||||
pub minimum_commission: Option<f64>,
|
||||
@@ -740,6 +742,7 @@ impl PlatformExprStrategyConfig {
|
||||
retry_empty_rebalance: false,
|
||||
calendar_rebalance_interval: false,
|
||||
max_holding_days: None,
|
||||
automatic_trade_protection: AutomaticTradeProtection::default(),
|
||||
weak_market_shrink_overweight_threshold: None,
|
||||
commission_rate: None,
|
||||
minimum_commission: None,
|
||||
@@ -1350,6 +1353,12 @@ enum RuntimeHelperResolution {
|
||||
}
|
||||
|
||||
pub struct PlatformExprStrategy {
|
||||
protection_fill_count: usize,
|
||||
protection_last_buys: BTreeMap<String, NaiveDate>,
|
||||
protection_last_sells: BTreeMap<String, NaiveDate>,
|
||||
automatic_trade_permissions: BTreeMap<String, AutomaticTradePermission>,
|
||||
external_automatic_trade_evidence: Option<(NaiveDate, BTreeMap<String, HoldingLifecycleEvidence>, crate::TradingCalendar)>,
|
||||
automatic_holding_days: BTreeMap<String, i64>,
|
||||
pattern_results_date: RefCell<Option<NaiveDate>>,
|
||||
pattern_results: RefCell<BTreeMap<(NaiveDate, String, String), crate::daily_patterns::PatternResult>>,
|
||||
pattern_contexts: RefCell<BTreeMap<String,crate::daily_patterns::ResearchContext>>,
|
||||
@@ -1614,7 +1623,10 @@ impl PlatformExprStrategy {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(config: PlatformExprStrategyConfig) -> Self {
|
||||
pub fn new(mut config: PlatformExprStrategyConfig) -> Self {
|
||||
if config.automatic_trade_protection.max_holding_days > 0 && config.max_holding_days.is_none() {
|
||||
config.max_holding_days = Some(i64::from(config.automatic_trade_protection.max_holding_days));
|
||||
}
|
||||
let mut engine = Engine::new();
|
||||
engine.set_fast_operators(false);
|
||||
engine.set_fail_on_invalid_map_property(true);
|
||||
@@ -1762,6 +1774,12 @@ impl PlatformExprStrategy {
|
||||
Self {
|
||||
config,
|
||||
engine,
|
||||
protection_fill_count: 0,
|
||||
protection_last_buys: BTreeMap::new(),
|
||||
protection_last_sells: BTreeMap::new(),
|
||||
automatic_trade_permissions: BTreeMap::new(),
|
||||
external_automatic_trade_evidence: None,
|
||||
automatic_holding_days: BTreeMap::new(),
|
||||
rebalance_day_counter: 0,
|
||||
last_rebalance_date: None,
|
||||
last_target_selection: None,
|
||||
@@ -2197,7 +2215,37 @@ impl PlatformExprStrategy {
|
||||
}
|
||||
matches!(
|
||||
name,
|
||||
"signal_close"
|
||||
"trade_date"
|
||||
| "current_date"
|
||||
| "date"
|
||||
| "decision_date"
|
||||
| "execution_date"
|
||||
| "signal_open"
|
||||
| "benchmark_open"
|
||||
| "has_dynamic_universe"
|
||||
| "dynamic_universe_count"
|
||||
| "has_subscriptions"
|
||||
| "subscription_count"
|
||||
| "subscription_guard_required"
|
||||
| "free_float_cap_or_market_cap"
|
||||
| "turnover"
|
||||
| "minimum_order_quantity"
|
||||
| "order_step_size"
|
||||
| "in_dynamic_universe"
|
||||
| "is_subscribed"
|
||||
| "stock_volume_ma5"
|
||||
| "stock_volume_ma10"
|
||||
| "stock_volume_ma20"
|
||||
| "stock_volume_ma60"
|
||||
| "stock_volume_ma100"
|
||||
| "volume_ma5"
|
||||
| "volume_ma10"
|
||||
| "volume_ma20"
|
||||
| "volume_ma60"
|
||||
| "volume_ma100"
|
||||
| "available_sellable_qty"
|
||||
| "reserved_open_sell_qty"
|
||||
| "signal_close"
|
||||
| "benchmark_close"
|
||||
| "benchmark_signal_close"
|
||||
| "signal_ma5"
|
||||
@@ -2568,6 +2616,11 @@ impl PlatformExprStrategy {
|
||||
}
|
||||
|
||||
fn max_holding_days_exceeded(&self, symbol: &str) -> Option<i64> {
|
||||
if self.config.automatic_trade_protection.max_holding_days > 0 {
|
||||
return self.automatic_trade_permissions.get(symbol)
|
||||
.filter(|permission| permission.max_holding_exit)
|
||||
.and_then(|_| self.automatic_holding_days.get(symbol).copied());
|
||||
}
|
||||
let max_days = self.config.max_holding_days.filter(|value| *value > 0)?;
|
||||
let holding_days = *self.position_holding_days.get(symbol)?;
|
||||
(holding_days >= max_days).then_some(holding_days)
|
||||
@@ -3405,6 +3458,9 @@ impl PlatformExprStrategy {
|
||||
let position = projected.position(symbol)?;
|
||||
let current_qty = position.quantity;
|
||||
let sellable_qty = position.sellable_qty(date);
|
||||
if self.automatic_trade_permissions.get(symbol).is_some_and(|permission| permission.sell_denial.is_some()) {
|
||||
return None;
|
||||
}
|
||||
let quantity = current_qty.min(sellable_qty);
|
||||
if quantity == 0 {
|
||||
return None;
|
||||
@@ -3549,6 +3605,9 @@ impl PlatformExprStrategy {
|
||||
let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol);
|
||||
let order_step_size = self.projected_order_step_size(ctx, symbol);
|
||||
let sellable_qty = projected.position(symbol)?.sellable_qty(date);
|
||||
if self.automatic_trade_permissions.get(symbol).is_some_and(|permission| permission.sell_denial.is_some()) {
|
||||
return None;
|
||||
}
|
||||
if sellable_qty == 0 {
|
||||
return None;
|
||||
}
|
||||
@@ -6186,7 +6245,8 @@ impl PlatformExprStrategy {
|
||||
.filter(|identifier| !normalized_identifiers.contains(*identifier)),
|
||||
);
|
||||
for identifier in factor_identifiers {
|
||||
if Self::is_reserved_scope_name(identifier.as_str())
|
||||
if scope.contains(identifier)
|
||||
|| Self::is_reserved_scope_name(identifier.as_str())
|
||||
|| self.prelude_declared_identifiers.contains(identifier)
|
||||
|| (!self.stock_extra_factor_identifiers.contains(identifier)
|
||||
&& !item.extra_factors.contains_key(identifier.as_str())
|
||||
@@ -10729,6 +10789,9 @@ impl PlatformExprStrategy {
|
||||
symbol: &str,
|
||||
execution_time: Option<NaiveTime>,
|
||||
) -> bool {
|
||||
if self.automatic_trade_permissions.get(symbol).is_some_and(|permission| permission.sell_denial.is_some()) {
|
||||
return false;
|
||||
}
|
||||
let Some(position) = ctx.portfolio.position(symbol) else {
|
||||
return false;
|
||||
};
|
||||
@@ -10768,6 +10831,9 @@ impl PlatformExprStrategy {
|
||||
symbol: &str,
|
||||
_stock: &StockExpressionState,
|
||||
) -> Result<Option<String>, BacktestError> {
|
||||
if let Some(reason) = self.automatic_trade_permissions.get(symbol).and_then(|permission| permission.buy_denial) {
|
||||
return Ok(Some(reason.into()));
|
||||
}
|
||||
let market = ctx.data.require_market(date, symbol)?;
|
||||
let candidate = ctx.data.require_candidate(date, symbol)?;
|
||||
|
||||
@@ -12227,6 +12293,7 @@ impl Strategy for PlatformExprStrategy {
|
||||
}
|
||||
|
||||
fn before_trading(&mut self, ctx: &StrategyContext<'_>) -> Result<(), BacktestError> {
|
||||
self.sync_automatic_trade_protection(ctx)?;
|
||||
let Some(config) = self.config.portfolio_loss_control.clone() else { return Ok(()); };
|
||||
if ctx.futures_account.is_some() {
|
||||
return Err(BacktestError::Execution("portfolio loss control currently requires equity-only accounting".to_owned()));
|
||||
@@ -12342,6 +12409,7 @@ impl Strategy for PlatformExprStrategy {
|
||||
ctx: &StrategyContext<'_>,
|
||||
rule: &ScheduleRule,
|
||||
) -> Result<StrategyDecision, BacktestError> {
|
||||
self.sync_automatic_trade_protection(ctx)?;
|
||||
let mut decision = if self.config.explicit_actions.is_empty() {
|
||||
StrategyDecision::default()
|
||||
} else {
|
||||
@@ -12361,7 +12429,7 @@ impl Strategy for PlatformExprStrategy {
|
||||
self.executing_scheduled_rotation = false;
|
||||
decision.merge_from(rotation?);
|
||||
}
|
||||
self.attach_buy_denials(ctx, &mut decision)?;
|
||||
self.attach_buy_denials(ctx, &mut decision, true)?;
|
||||
self.append_pattern_diagnostics(&mut decision);
|
||||
Ok(decision)
|
||||
}
|
||||
@@ -12388,6 +12456,7 @@ impl Strategy for PlatformExprStrategy {
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<BTreeSet<String>, BacktestError> {
|
||||
self.sync_automatic_trade_protection(ctx)?;
|
||||
let mut symbols = ctx
|
||||
.portfolio
|
||||
.positions()
|
||||
@@ -12407,13 +12476,14 @@ impl Strategy for PlatformExprStrategy {
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, BacktestError> {
|
||||
self.sync_automatic_trade_protection(ctx)?;
|
||||
if self.config.explicit_action_stage == PlatformExplicitActionStage::OpenAuction
|
||||
&& !self.config.explicit_actions.is_empty()
|
||||
&& self.config.explicit_action_schedule.is_none()
|
||||
&& self.unscheduled_explicit_actions_are_due(ctx.decision_date, ctx.execution_date)
|
||||
{
|
||||
let mut decision = self.explicit_action_decision(ctx)?;
|
||||
self.attach_buy_denials(ctx, &mut decision)?;
|
||||
self.attach_buy_denials(ctx, &mut decision, true)?;
|
||||
self.append_pattern_diagnostics(&mut decision);
|
||||
return Ok(decision);
|
||||
}
|
||||
@@ -12421,15 +12491,52 @@ impl Strategy for PlatformExprStrategy {
|
||||
}
|
||||
|
||||
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
|
||||
self.sync_automatic_trade_protection(ctx)?;
|
||||
let mut decision = self.compute_day_decision(ctx)?;
|
||||
self.attach_buy_denials(ctx, &mut decision)?;
|
||||
let expiry_due = !(self.config.signal_book.is_some() && self.config.explicit_action_schedule.is_some())
|
||||
&& !(self.config.rotation_enabled && self.config.rebalance_schedule.as_ref().and_then(|schedule|schedule.time_rule.as_ref()).is_some() && !self.executing_scheduled_rotation);
|
||||
self.attach_buy_denials(ctx, &mut decision, expiry_due)?;
|
||||
self.append_pattern_diagnostics(&mut decision);
|
||||
Ok(decision)
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformExprStrategy {
|
||||
fn attach_buy_denials(&self, ctx: &StrategyContext<'_>, decision: &mut StrategyDecision) -> Result<(), BacktestError> {
|
||||
fn attach_buy_denials(&self, ctx: &StrategyContext<'_>, decision: &mut StrategyDecision, expiry_due: bool) -> Result<(), BacktestError> {
|
||||
let expired = self.automatic_trade_permissions.iter().filter(|(_, permission)| expiry_due && permission.max_holding_exit)
|
||||
.map(|(symbol, _)| symbol.clone()).collect::<BTreeSet<_>>();
|
||||
if !expired.is_empty() {
|
||||
// A configured full exit is a final target, not an additional
|
||||
// partial sell appended after another action for the same stock.
|
||||
let mut portfolio_target = false;
|
||||
decision.order_intents.retain_mut(|intent| {
|
||||
let (keep, portfolio) = Self::apply_maximum_holding_target(intent, &expired);
|
||||
portfolio_target |= portfolio;
|
||||
keep
|
||||
});
|
||||
if !portfolio_target {
|
||||
let exits = expired.iter().map(|symbol| OrderIntent::TargetValue {
|
||||
symbol:symbol.clone(), target_value:0.0, reason:"max_holding_days_exit".into(),
|
||||
});
|
||||
decision.order_intents.splice(0..0, exits);
|
||||
}
|
||||
}
|
||||
for (symbol, permission) in &self.automatic_trade_permissions {
|
||||
for (scope, reason) in [(crate::risk_control::RiskCheckScope::Buy, permission.buy_denial), (crate::risk_control::RiskCheckScope::Sell, permission.sell_denial)] {
|
||||
if let Some(reason) = reason {
|
||||
if scope == crate::risk_control::RiskCheckScope::Buy {
|
||||
decision.buy_denials.entry(symbol.clone()).and_modify(|value| { value.push_str("; "); value.push_str(reason); }).or_insert_with(|| reason.into());
|
||||
}
|
||||
decision.risk_decisions.push(FidcRiskDecisionAudit {
|
||||
date: ctx.execution_date, symbol: symbol.clone(), scope,
|
||||
stage: "automatic_trade_protection".into(), accepted: false,
|
||||
rule_code: reason.into(), reason: reason.into(),
|
||||
config_version: Some("strategy_automatic_trade_protection_v1".into()),
|
||||
data_epoch: ctx.execution_date.to_string(), selection_batch_id: None, order_id: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.config.signal_book.is_none() && self.config.buy_filter_expr.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -12468,6 +12575,89 @@ impl PlatformExprStrategy {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_maximum_holding_target(intent: &mut OrderIntent, expired: &BTreeSet<String>) -> (bool, bool) {
|
||||
match intent {
|
||||
OrderIntent::WithTimeInForce { intent, .. } => Self::apply_maximum_holding_target(intent, expired),
|
||||
OrderIntent::TargetPortfolioSmart { target_weights, .. } => {
|
||||
for symbol in expired { target_weights.insert(symbol.clone(), 0.0); }
|
||||
(true, true)
|
||||
}
|
||||
OrderIntent::Shares {symbol,..} | OrderIntent::LimitShares {symbol,..}
|
||||
| OrderIntent::Lots {symbol,..} | OrderIntent::LimitLots {symbol,..}
|
||||
| OrderIntent::TargetShares {symbol,..} | OrderIntent::LimitTargetShares {symbol,..}
|
||||
| OrderIntent::TargetValue {symbol,..} | OrderIntent::LimitTargetValue {symbol,..}
|
||||
| OrderIntent::TimedTargetValue {symbol,..} | OrderIntent::Value {symbol,..}
|
||||
| OrderIntent::LimitValue {symbol,..} | OrderIntent::Percent {symbol,..}
|
||||
| OrderIntent::LimitPercent {symbol,..} | OrderIntent::TargetPercent {symbol,..}
|
||||
| OrderIntent::LimitTargetPercent {symbol,..} | OrderIntent::AlgoValue {symbol,..}
|
||||
| OrderIntent::AlgoPercent {symbol,..} => (!expired.contains(symbol), false),
|
||||
_ => (true, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Online adapters must validate the account, quantity, policy and snapshot
|
||||
/// identity before injecting actual fill evidence into a rebuilt strategy.
|
||||
pub fn set_external_automatic_trade_evidence(&mut self, execution_date: NaiveDate, facts: BTreeMap<String, HoldingLifecycleEvidence>, calendar: crate::TradingCalendar) {
|
||||
self.external_automatic_trade_evidence = Some((execution_date, facts, calendar));
|
||||
}
|
||||
|
||||
fn sync_automatic_trade_protection(&mut self, ctx: &StrategyContext<'_>) -> Result<(), BacktestError> {
|
||||
let policy = &self.config.automatic_trade_protection;
|
||||
if !policy.enabled() { return Ok(()); }
|
||||
policy.validate().map_err(BacktestError::Execution)?;
|
||||
if ctx.futures_account.is_some() {
|
||||
return Err(BacktestError::Execution("automatic_trade_protection_requires_equity_position_evidence".into()));
|
||||
}
|
||||
if let Some((date, facts, calendar)) = &self.external_automatic_trade_evidence {
|
||||
if *date != ctx.execution_date { return Err(BacktestError::Execution("automatic_trade_protection_external_date_changed".into())); }
|
||||
self.automatic_trade_permissions.clear();
|
||||
self.automatic_holding_days.clear();
|
||||
for symbol in ctx.portfolio.positions().keys() {
|
||||
if !facts.contains_key(symbol) { return Err(BacktestError::Execution(format!("automatic_trade_protection_position_fact_missing:{symbol}"))); }
|
||||
}
|
||||
for (symbol, fact) in facts {
|
||||
let permission = policy.evaluate(symbol, *date, fact, calendar).map_err(BacktestError::Execution)?;
|
||||
self.automatic_trade_permissions.insert(symbol.clone(), permission);
|
||||
if let Some(opened) = fact.opened_date
|
||||
&& let (Some(start), Some(end)) = (calendar.index_of(opened), calendar.index_of(*date)) {
|
||||
self.automatic_holding_days.insert(symbol.clone(), end.saturating_sub(start) as i64);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if ctx.fills.len() < self.protection_fill_count {
|
||||
return Err(BacktestError::Execution("automatic_trade_fill_history_rewound".into()));
|
||||
}
|
||||
for fill in &ctx.fills[self.protection_fill_count..] {
|
||||
if fill.quantity == 0 { continue; }
|
||||
let date = fill.execution_date.unwrap_or(fill.date);
|
||||
if date > ctx.execution_date { return Err(BacktestError::Execution("automatic_trade_future_fill".into())); }
|
||||
let values = match fill.side { OrderSide::Buy => &mut self.protection_last_buys, OrderSide::Sell => &mut self.protection_last_sells };
|
||||
values.entry(fill.symbol.clone()).and_modify(|previous| *previous = (*previous).max(date)).or_insert(date);
|
||||
}
|
||||
self.protection_fill_count = ctx.fills.len();
|
||||
let mut symbols = ctx.portfolio.positions().keys().cloned().collect::<BTreeSet<_>>();
|
||||
symbols.extend(self.protection_last_sells.keys().cloned());
|
||||
symbols.extend(policy.locks.iter().map(|lock| lock.symbol.clone()));
|
||||
self.automatic_trade_permissions.clear();
|
||||
self.automatic_holding_days.clear();
|
||||
for symbol in symbols {
|
||||
let position = ctx.portfolio.position(&symbol).filter(|position| position.quantity > 0);
|
||||
let evidence = HoldingLifecycleEvidence {
|
||||
has_position: position.is_some(), opened_date: position.and_then(|position| position.opened_date()),
|
||||
last_buy_date: self.protection_last_buys.get(&symbol).copied().into_iter().chain(position.and_then(|position|position.last_buy_date())).max(),
|
||||
last_sell_date: self.protection_last_sells.get(&symbol).copied(),
|
||||
};
|
||||
let permission = policy.evaluate(&symbol, ctx.execution_date, &evidence, ctx.data.calendar()).map_err(BacktestError::Execution)?;
|
||||
if let Some(opened) = evidence.opened_date
|
||||
&& let (Some(start), Some(end)) = (ctx.data.calendar().index_of(opened), ctx.data.calendar().index_of(ctx.execution_date)) {
|
||||
self.automatic_holding_days.insert(symbol.clone(), end.saturating_sub(start) as i64);
|
||||
}
|
||||
self.automatic_trade_permissions.insert(symbol, permission);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_pattern_diagnostics(&self, decision: &mut StrategyDecision) {
|
||||
let mut contexts=BTreeMap::<String,serde_json::Value>::new();
|
||||
for ((date, symbol, spec), result) in self.pattern_results.borrow().iter() {
|
||||
@@ -12955,7 +13145,7 @@ impl PlatformExprStrategy {
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
if self.config.rotation_enabled
|
||||
if (self.config.rotation_enabled || self.config.automatic_trade_protection.max_holding_days > 0)
|
||||
&& let Some(max_holding_days) = self.config.max_holding_days.filter(|value| *value > 0)
|
||||
{
|
||||
for position in ctx.portfolio.positions().values() {
|
||||
@@ -14384,7 +14574,7 @@ mod tests {
|
||||
let strategy = PlatformExprStrategy::new(cfg);
|
||||
let mut decision = crate::StrategyDecision::default();
|
||||
decision.order_intents.push(OrderIntent::TargetValue { symbol: symbol.to_string(), target_value: 10_000.0, reason: "buy".to_string() });
|
||||
let error = strategy.attach_buy_denials(&ctx, &mut decision).unwrap_err();
|
||||
let error = strategy.attach_buy_denials(&ctx, &mut decision, true).unwrap_err();
|
||||
assert!(error.to_string().contains("buy condition quote unavailable"), "{error}");
|
||||
assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly);
|
||||
}
|
||||
@@ -14440,7 +14630,7 @@ mod tests {
|
||||
ctx.active_datetime = Some(date.and_hms_opt(hour, minute, 0).unwrap());
|
||||
let mut decision = crate::StrategyDecision::default();
|
||||
decision.order_intents.push(OrderIntent::TargetValue { symbol: symbol.to_string(), target_value: 10_000.0, reason: "target".to_string() });
|
||||
strategy.attach_buy_denials(&ctx, &mut decision).unwrap();
|
||||
strategy.attach_buy_denials(&ctx, &mut decision, true).unwrap();
|
||||
assert_eq!(decision.buy_denials.contains_key(symbol), denied);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,7 +664,7 @@ fn normalize_strategy_aliases_in_value_inner(
|
||||
for (key, child) in object.iter_mut() {
|
||||
normalize_strategy_aliases_in_value_inner(
|
||||
child,
|
||||
in_risk_policy || matches!(key.as_str(), "riskPolicy" | "risk_policy"),
|
||||
in_risk_policy || matches!(key.as_str(), "riskPolicy" | "risk_policy" | "automaticTradeProtection" | "automatic_trade_protection"),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
@@ -686,6 +686,7 @@ const STRATEGY_ALIAS_GROUPS: &[(&str, &[&str])] = &[
|
||||
("signalSymbol", &["signal_symbol"]),
|
||||
("engineConfig", &["engine_config"]),
|
||||
("runtimeExpressions", &["runtime_expressions"]),
|
||||
("automaticTradeProtection", &["automatic_trade_protection"]),
|
||||
("rebalanceSchedule", &["rebalance_schedule"]),
|
||||
("skipWindows", &["skip_windows"]),
|
||||
("dynamicRange", &["dynamic_range"]),
|
||||
@@ -1004,6 +1005,8 @@ pub struct StrategyExpressionOrderingConfig {
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StrategyExpressionTradingConfig {
|
||||
#[serde(default, alias = "automatic_trade_protection")]
|
||||
pub automatic_trade_protection: Option<crate::holding_policy::AutomaticTradeProtection>,
|
||||
#[serde(default, alias = "buy_filter_expr")]
|
||||
pub buy_filter_expr: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -2330,6 +2333,10 @@ pub fn platform_expr_config_from_spec(
|
||||
}
|
||||
}
|
||||
if let Some(trading) = runtime_expr.trading.as_ref() {
|
||||
if let Some(policy) = &trading.automatic_trade_protection {
|
||||
policy.validate()?;
|
||||
cfg.automatic_trade_protection = policy.clone();
|
||||
}
|
||||
if let Some(expr) = trading.buy_filter_expr.as_ref() {
|
||||
cfg.buy_filter_expr = expr.clone();
|
||||
}
|
||||
@@ -2639,6 +2646,14 @@ pub fn platform_expr_config_from_spec(
|
||||
return Err("consume_signal_requires_verified_signal_book".into());
|
||||
}
|
||||
|
||||
let has_automatic_policy = spec.runtime_expressions.as_ref().and_then(|runtime| runtime.trading.as_ref()).is_some_and(|trading| trading.automatic_trade_protection.is_some());
|
||||
if has_automatic_policy {
|
||||
let limit = i64::from(cfg.automatic_trade_protection.max_holding_days);
|
||||
if cfg.max_holding_days.is_some_and(|previous| previous != limit) {
|
||||
return Err("conflicting maximum holding policies".into());
|
||||
}
|
||||
cfg.max_holding_days = (limit > 0).then_some(limit);
|
||||
}
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ pub struct PositionLot {
|
||||
pub struct Position {
|
||||
pub symbol: String,
|
||||
pub quantity: u32,
|
||||
opened_date: Option<NaiveDate>,
|
||||
last_buy_date: Option<NaiveDate>,
|
||||
// ALV-compatible moving average execution price; partial sells do not rebase it.
|
||||
pub average_price: f64,
|
||||
// ALV-compatible moving average including buy costs; partial sells do not rebase it.
|
||||
@@ -88,6 +90,8 @@ impl Position {
|
||||
Self {
|
||||
symbol: symbol.into(),
|
||||
quantity: 0,
|
||||
opened_date: None,
|
||||
last_buy_date: None,
|
||||
average_price: 0.0,
|
||||
average_cost: 0.0,
|
||||
last_price: 0.0,
|
||||
@@ -114,6 +118,12 @@ impl Position {
|
||||
self.quantity == 0
|
||||
}
|
||||
|
||||
pub fn opened_date(&self) -> Option<NaiveDate> {
|
||||
self.opened_date
|
||||
}
|
||||
|
||||
pub fn last_buy_date(&self) -> Option<NaiveDate> { self.last_buy_date }
|
||||
|
||||
pub fn buy(&mut self, date: NaiveDate, quantity: u32, price: f64) {
|
||||
self.buy_with_mark_price(date, quantity, price, price);
|
||||
}
|
||||
@@ -130,6 +140,10 @@ impl Position {
|
||||
}
|
||||
|
||||
let previous_quantity = self.quantity;
|
||||
self.last_buy_date = Some(self.last_buy_date.map_or(date, |previous| previous.max(date)));
|
||||
if previous_quantity == 0 {
|
||||
self.opened_date = Some(date);
|
||||
}
|
||||
let previous_average_price = self.average_price;
|
||||
let previous_average_cost = self.average_cost;
|
||||
let gross_amount = fixed_money_or_panic(
|
||||
@@ -267,6 +281,7 @@ impl Position {
|
||||
.checked_add(total_proceeds)
|
||||
.ok_or_else(|| "fixed-point day sell value overflow".to_string())?;
|
||||
if self.quantity == 0 {
|
||||
self.opened_date = None;
|
||||
self.average_price = 0.0;
|
||||
self.recalculate_average_cost();
|
||||
} else {
|
||||
@@ -1224,6 +1239,8 @@ impl PortfolioState {
|
||||
}
|
||||
|
||||
let old_quantity = old_position.quantity;
|
||||
let old_opened_date = old_position.opened_date;
|
||||
let old_last_buy_date = old_position.last_buy_date;
|
||||
let last_price = old_position.last_price;
|
||||
let old_average_price = old_position.average_price;
|
||||
let old_average_cost = old_position.average_cost;
|
||||
@@ -1263,6 +1280,14 @@ impl PortfolioState {
|
||||
.entry(new_symbol.to_string())
|
||||
.or_insert_with(|| Position::new(new_symbol));
|
||||
let successor_quantity_before = successor.quantity;
|
||||
successor.opened_date = match (successor.opened_date, old_opened_date) {
|
||||
(Some(current), Some(previous)) => Some(current.min(previous)),
|
||||
(current, previous) => current.or(previous),
|
||||
};
|
||||
successor.last_buy_date = match (successor.last_buy_date, old_last_buy_date) {
|
||||
(Some(current), Some(previous)) => Some(current.max(previous)),
|
||||
(current, previous) => current.or(previous),
|
||||
};
|
||||
let successor_average_price_before = successor.average_price;
|
||||
let successor_average_cost_before = successor.average_cost;
|
||||
successor.lots.extend(converted_lots);
|
||||
|
||||
@@ -264,6 +264,10 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
title: "期货 runtime action 与提交校验".to_string(),
|
||||
detail: "runtimeExpressions.trading.actions 支持 futures_order、futures_open、futures_close、futures_close_today、futures_close_yesterday;字段包括 symbol、direction=long|short、quantityExpr/amountExpr、可选 limitPriceExpr、transactionCostExpr、whenExpr 和 reason。期货-only 策略把请求初始资金分配给期货账户且股票账户为0;股票+期货混合策略必须显式声明 futuresInitialCash,可选 stockInitialCash。合约必须先由 Source Lake 发布 futures_contract_daily、futures_contract_spec_history、futures_cost_margin_history 三张真实数据集;缺任一张时生成/回测必须失败,禁止手写默认乘数、保证金、费用或价格。订单进入撮合前继续检查上市/退市日期、停牌、trading_phase、限价 tick、涨跌停、反向挂单自成交、保证金和可平今昨仓。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "trading.automatic_trade_protection(...)".to_string(),
|
||||
detail: r#"当前股票/ETF策略的独立自动交易保护:trading.automatic_trade_protection({"buy_protection_days":3,"sell_cooldown_days":3,"max_holding_days":90,"locks":[{"symbol":"000001.SZ","start_date":"2026-09-11","end_date":null}]})。配置冻结到 runtimeExpressions.trading.automaticTradeProtection,回测、paper/live 共用内核;不并入全局风控。0/null/未填关闭对应周期;成交日及之后N个完整正式交易日内,买入保护禁止自动卖出及止盈止损,卖出冷却禁止自动增加仓位;只由真实成交启动或延长,拒绝/未成交/撤单不启动。最长持有按首次实际建仓后的正式交易日计数,加仓与部分卖出不重置,清仓后再开仓重置;日期锁定两端包含且高于自动退出,持仓占用真实预算和槽位。人工交易通过独立服务路径执行,仍校验权限、券商及T+1,不接受客户端origin旁路。持仓来源、实际成交或正式日历缺失时明确拒绝;期货与股票期货混合账户尚不支持此能力,不得悄悄忽略。旧trading.max_holding_days仍保留旧含义,不得和新配置声明不同最大周期。"#.to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "trading.rotation / order.* / order.modify / cancel.* / update_universe / subscribe".to_string(),
|
||||
detail: "支持股票显式下单、期货 runtime action、撤单、AlgoOrder、动态 universe 和账户资金动作。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段;需要模拟 平台内核 的日内订阅保护时,可写 trading.subscription_guard(true),未订阅 symbol 的显式订单会被拦截,TargetPortfolioSmart + AlgoOrder 会过滤未订阅标的。用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99, time_in_force=\"gtc\")、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices=VWAPOrder(930, 940), valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])、account.deposit_withdraw(100000, receiving_days=0)、account.finance_repay(50000)、account.set_management_fee_rate(0.001)。股票订单和 target_portfolio_smart 支持可选关键字 time_in_force=\"day|ioc|fok|gtc\",编译后写入 runtimeExpressions.trading.actions[].timeInForce:DAY 日内保留并在收盘 Expired,IOC 立即撤销未成交余量,FOK 必须全量可成交否则零成交,GTC 仅支持限价单并跨交易日保留;VWAP/TWAP 不接受 FOK/GTC。期货 action 必须由编译器写入结构化 runtimeExpressions,不得让策略源码直接构造 FuturesOrderIntent 或硬编码合约参数。symbol 使用标准证券/合约代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# 策略级自动交易保护
|
||||
|
||||
## 统一合同
|
||||
|
||||
`runtimeExpressions.trading.automaticTradeProtection` 是每个股票/ETF策略自己的不可变配置。股票池、表达式轮动和显式订单复用 `holding_policy` 内核,不新增全局共享配置,也不修改未配置的历史策略。
|
||||
|
||||
```json
|
||||
{"buy_protection_days":3,"sell_cooldown_days":3,"max_holding_days":90,"locks":[{"symbol":"000001.SZ","start_date":"2026-09-11","end_date":"2026-09-16"}]}
|
||||
```
|
||||
|
||||
- 周期为空、null或0关闭,必须为0—3650整数;锁定支持同股多个区间,起止日包含当日,截止null持续有效。
|
||||
- 买入保护禁止自动减仓/清仓及止盈止损;卖出冷却禁止自动增加仓位。只有实际成交计时,部分成交延长对应最后成交日;未成交、拒绝、撤单不启动。
|
||||
- 成交日及后续N个完整正式交易日均受保护。例如周五成交、N=3,保护到下周三结束,周四恢复;不按72小时或自然日替代。
|
||||
- 最长持有从连续持仓第一次实际买入开始,跨正式交易日计数;加仓、部分卖出和有证据的证券转换不重置,完全卖出再买入开启新周期。锁定和买入保护优先于最长持有退出。
|
||||
- 日期锁定禁止自动买卖,已接受的挂单不自动撤销;手工路径只绕过自动策略保护,不绕过账户授权、T+1、券商和风控。
|
||||
- 保留的真实持仓继续占用资金与席位,不把未完成卖出当现金。最长持有退出先形成唯一最终目标,不能叠加一笔策略部分卖出和一笔框架全量卖出。
|
||||
- 在线上下文重建必须注入已经校验的真实成交/持仓快照,不能把重建日或旧行情日当建仓日。期货或股票期货混合账户未纳入本合同,显式拒绝。
|
||||
|
||||
## 根因补充修复
|
||||
|
||||
组合 `decision_date == "2026-09-11" && symbol == "000001.SZ"` 会落到字符串表达式路径。旧代码遗漏日期等内建标识符的保留登记,又按“额外因子”注入NaN,覆盖同名真实日期,造成选股错误。现登记全部已注入内建字段,并禁止额外因子覆盖已存在的作用域变量。单独数字VM日期测试不足以发现该问题,新增日期+证券混合选择回归。
|
||||
|
||||
## 验证与边界
|
||||
|
||||
原生完整回测测试验证:显式策略真实模拟成交日启动3日保护/禁买、日期锁定零委托、最长持有让位于保护、锁定持仓占据资金与席位、解锁后才按候选顺序买入;序列化和别名归一不改max_holding_days字段,冲突策略拒绝。现有534核心用例通过(6个既有忽略项)。这些是隔离内核测试,不是GT实际成交验收。
|
||||
@@ -0,0 +1,87 @@
|
||||
# 执行价风控与共享信号账户隔离验收
|
||||
|
||||
## 结论
|
||||
|
||||
本次修复已通过测试并发布 177。只证明一元股请求阶段价格修复、同一共享信号的账户隔离和既有真实样本结果不变;完整生产闭环尚未完成。next-open 全天容量、动态滑点的日内可见性及逐成交腿风控仍是未关闭项,不能称为全部成交无未来信息。
|
||||
|
||||
## 修复
|
||||
|
||||
- `risk_control.rs` 的 Buy 一元股规则改用本次 `check_price`,不再读取日线 `is_one_yuan` 或当天更早的 `day_open`。无效价格拒绝,其他缺失风险事实仍拒绝;显式 Selection 规则保留。
|
||||
- Trading 共用 `risk.rs` 的 Paper/Live 订单前检查使用新鲜 `last_price`,不再被日线标记或开盘价覆盖。选股阶段的开关和日线标记单独处理。
|
||||
- 缺执行价格继续输出具体 `missing_execution_price field=open`,保留 `historical_price_fallback=false`;停牌等权威状态仍优先,不因新通用校验丢失根因。
|
||||
- 没有修改共享信号内容、模型、账号权限、运行配置、Source Lake、研究 checkpoint 或既有回测数据。
|
||||
|
||||
## 账户隔离组合
|
||||
|
||||
隔离共享核心测试使用同一份经过原生校验的 `fidc.signal-book/v2`,信号只表达保留 50% 持仓。当前价 10、止损 10%、止盈 20%;每个账户独立计算实际订单。
|
||||
|
||||
| 原数量 | 买入价 | 买入费用总额 | 预期剩余 | 结果 |
|
||||
| ---: | ---: | ---: | ---: | --- |
|
||||
| 1,000 | 8.00 | 0 | 0 | 止盈优先于半仓目标 |
|
||||
| 1,000 | 10.00 | 0 | 500 | 按本账户数量减半 |
|
||||
| 3,000 | 10.00 | 0 | 1,500 | 不共用其他账户数量 |
|
||||
| 1,000 | 12.00 | 0 | 0 | 止损 |
|
||||
| 1,000 | 11.11 | 0 | 500 | 尚未跨过止损阈值 |
|
||||
| 1,000 | 11.11 | 2.00 | 0 | 含费用成本跨过止损阈值 |
|
||||
|
||||
六种账户卖出后,当天再消费同一买入目标均不得买回;独立未卖出账户可正常买入。同一信号版本不变,策略规划不预先修改持仓。这些是隔离合成账户测试,不是券商委托/成交证据。
|
||||
|
||||
## 回归与真实回放
|
||||
|
||||
- Engine:656 通过,8 个专用测试忽略。
|
||||
- Trading 工作区:537 通过,9 个专用测试忽略。
|
||||
- Backtest Runner:370 通过、3 忽略;API:99 通过、1 忽略。
|
||||
- 一元股专项覆盖真实执行价为 0.9/1.0/1.2、旧标记与新价格相反、缺失其他风险事实、NaN/无效价及开关独立性。
|
||||
|
||||
实际 HTTP 回测使用原始冻结请求、信号和 bundle,未复制结果:
|
||||
|
||||
- 原基准:`btr_req_260d0f3179d40fda5c918d48eba0a239bd335406c82a1cbe`。
|
||||
- 新运行:`btr_1789091571964_2429476_0`。
|
||||
- 区间:2025-02-05 至 2025-02-10;初始资金 10,000,000;目标 10 仓;next-open;滑点 0.002;佣金万三、最低 5。
|
||||
- 两次均 28 成交,最终资产 10,089,448.918844,收益 0.89448918844%。
|
||||
- 订单、成交、账户事件、权益、持仓、风险审计六项摘要相同。
|
||||
- Canonical SHA256:`befa50b3ec5b94adafede459903db7e2542797cf0eefe2de32900afc83ca1481`。
|
||||
- 服务端 3.954 秒,客户端含轮询 6.072 秒。此短区间已有缓存样本不能代表全市场冷态或多年性能。
|
||||
|
||||
## 发布
|
||||
|
||||
- Engine `d3c36e947894fd220b62ecd6fbfe02473f70bd2c`,tag `v2026.9.11`。
|
||||
- Trading `dfcec36bd1c0f92e73cd30073540eb39dbd02835`,tag `v2026.9.11`。
|
||||
- Backtest service `75202cc3b876daf99d0d2dffb988ca456c34aabf`,重新链接上述引擎。
|
||||
- 只使用官方 installer,以 Boris 构建和运行。发布后 Backtest/Runtime/Paper/Live 的进程和 HTTP `/healthz` 正常,运行二进制核对独立清单,不仅检查源码 HEAD。实时行情 `/readyz` 仍为 503,原因如下,不能宣称自然交易正常。
|
||||
- 原 3 Paper / 1 Live 配置和状态摘要前后相同;本次投影 Paper 为 `52a909117fe8f01ae35a327bd86310e2583d291609bba6596dc0f49a2b10559c`,Live 为 `aaec1e9dbc984012e9fe677e54db1efb860edd59d5840d1dc87c4b230b26bac6`。仅与本次相同投影的发布前数据对比,不与此前其他字段投影混比。
|
||||
- Source PID 2267019、因子主进程 PID 2178403、NRestarts 均不变。本轮未调用 Source/因子重启入口;不能由主 PID 不变推断全部因子子任务已经验收。
|
||||
|
||||
## 未关闭问题
|
||||
|
||||
### 实时行情配额
|
||||
|
||||
发布后文件日志审查发现 THS `-4302`:本周行情用量超过 1.5 亿。受保护的行情源目录只返回 `ths_realtime`,enabled=true、ready=false;没有已配置可用的授权备用源。行情 `/readyz` 返回503、snapshot_count=0,实盘日志反复记录实际执行日2026-09-11请求150证券、收到0新鲜行情,因此 next-open 规划失败。
|
||||
|
||||
所查尾部8,000行日志中,配额告警最早已出现在01:30:12 UTC(上海09:30),早于本轮09:58的Trading发布。不能把该故障归因于本次一元股代码或用重启解决。不得拿昨日收盘、Source历史数据或手工报价代替实时价格;恢复账户配额或配置正式授权的可用行情源后,才能继续自然交易验收。
|
||||
|
||||
Paper的3条WARN为启动重建的PG读取,分别约1.015/1.122/1.460秒;本轮未见ERROR,但这只是采样范围,不能称全部日志无异常。证据:`realtime-quota-timeline.json`、`realtime-provider-readiness.json`、`post-deploy-file-log-audit.json`。Runtime无新采样日志不等于实际调度通过。
|
||||
|
||||
### 执行容量与校准
|
||||
|
||||
独立依赖探针确认:保持 next-open 订单和开盘价不变,仅修改执行日后来形成的全天量,成交量从 100 变为 1,000;仅修改全天 high/low,动态滑点成交价从 10.305 变为 11.000。探针是合成输入,不冒充市场证据。
|
||||
|
||||
详见 `/Users/boris/WorkSpace/docs/fidc/execution-time-capacity-coordination-20260911.md`。下一步须分离实测执行时点容量和声明的容量估计、冻结校准数据时钟、覆盖挂单逐成交腿;不能偷偷改为昨日成交量、关闭限制或使用未来一分钟量。V18 研究只允许新不可变后继评估,不能改现有结果。
|
||||
|
||||
自然 Paper 观察与正式 Live 仍需真实合格版本和正式审批。当前研究控制模型仅 23 个验证日,2026 留出期继续封存;不得为演示闭环降低门槛、伪造 observed、代替审批或手工发证券订单。
|
||||
|
||||
### 并发代码合并
|
||||
|
||||
报告推送时远端新增 `33924b1/f2e228e` 的策略自动交易保护。已保留并合并至main `f7d16fb`,177源码同步,组合引擎回归666通过、8忽略。该合并后的新保护尚未由本任务部署,线上仍使用本报告列出的d3c36e9/dfcec36清单;不能把源码同步当作发布或把对方功能归为本次已完成的自然交易验收。
|
||||
|
||||
随后 Trading main 新增 `4ff7ee9aeefa2e1013098212dc75b4969499a1a6`,本机与177均已正常快进同步,合并组合工作区545通过、10忽略。此为并发功能合并后的源码测试,同样不改变本次发布清单;不重复部署另一个任务尚在验收的完整交易保护功能。
|
||||
|
||||
## 证据
|
||||
|
||||
177 根目录:`/srv/fidc/canonical/run/research/execution-risk-signal-audit-20260911/`。
|
||||
|
||||
- `engine-full-tests-v2.log`、`trading-full-tests.log`、`backtest-full-tests.log`。
|
||||
- `deploy-before.json`、`deploy-after.json`、`running-binary-verification.json`。
|
||||
- `same-signal-backtest/request.json`、`submission.json`、`result.json`、`comparison.json`。
|
||||
- `execution-time-dependency-probe.json` SHA256:`feeb69275b8ad537f16e4c119cc7e59dd3a15773334fb591e27d7afdf34311d3`。
|
||||
- 官方部署日志与独立探针源码保存在相同证据根,不写入交易数据库或修改原始行情。
|
||||
Reference in New Issue
Block a user