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, } pub fn deserialize_optional_policy<'de, D: serde::Deserializer<'de>>( deserializer: D, ) -> Result { Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) } fn optional_days<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { 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, D::Error> { Ok(Option::>::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, } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct HoldingLifecycleEvidence { pub has_position: bool, pub opened_date: Option, pub last_buy_date: Option, pub last_sell_date: Option, } #[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 { 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 { 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 { 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"))); } }