统一策略成交保护与锁定周期并修正日期条件覆盖
This commit is contained in:
@@ -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")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user