370 lines
13 KiB
Rust
370 lines
13 KiB
Rust
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")));
|
|
}
|
|
}
|