Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e0877b586 | |||
| 36833b7a6a | |||
| 4c0157b66c | |||
| f7d16fb664 | |||
| 97cdfa5972 | |||
| f2e228e0a3 | |||
| 33924b1fba | |||
| d3c36e9478 | |||
| c32807db1d | |||
| 20d723e2a1 | |||
| c2b939e818 | |||
| 6684f48f95 | |||
| adbfadcc07 | |||
| cb6f57be6f | |||
| e75db2d7b0 | |||
| e42dc6938b | |||
| 4f4c1ab7e0 | |||
| e549a23c66 | |||
| 4b21fc4f3f | |||
| e4bac1cf40 | |||
| 1de96494b3 | |||
| d9bac529d6 | |||
| 3e8d652af1 | |||
| 23043ee18b | |||
| c7c2e69b88 | |||
| e6746a7a0e | |||
| 123467d7ae | |||
| b3a3bdbdfd | |||
| 3f9cff1ee5 | |||
| db88abb9e0 | |||
| 75e5e32281 | |||
| 7d05f8f7c7 | |||
| d01f32ca5b | |||
| 3dd7b2bd50 | |||
| c8f6ed102c | |||
| 4664f1a2d3 | |||
| 40481e8825 | |||
| 2473cc04bb | |||
| 3fa1004ec5 | |||
| c4632bacf1 | |||
| 7dcaae594a | |||
| 999bf5bd01 | |||
| b281045df5 | |||
| 35acb1c7e7 | |||
| bbbd9cf3e0 | |||
| 5dc5ef9df5 | |||
| fe8f6c1c26 |
Generated
+999
-9
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"crates/fidc-core",
|
"crates/fidc-core",
|
||||||
|
"crates/fidc-signal-client",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
io::stdin().read_to_string(&mut input)?;
|
io::stdin().read_to_string(&mut input)?;
|
||||||
let output = if input.trim().is_empty() {
|
let output = if input.trim().is_empty() {
|
||||||
factor_events::catalog()
|
factor_events::catalog()
|
||||||
|
} else if serde_json::from_str::<Value>(&input)?.get("rank_history").is_some() {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct Rank { dates:Vec<chrono::NaiveDate>, universe:Vec<String>, values:std::collections::BTreeMap<String,Vec<Option<f64>>> }
|
||||||
|
let value:Value=serde_json::from_str(&input)?;
|
||||||
|
let request:Rank=serde_json::from_value(value["rank_history"].clone())?;
|
||||||
|
json!({"result":fidc_core::factor_cross_section::rank_history(&request.dates,&request.universe,&request.values)?})
|
||||||
} else {
|
} else {
|
||||||
let request: Request = serde_json::from_str(&input)?;
|
let request: Request = serde_json::from_str(&input)?;
|
||||||
let results = request
|
let results = request
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use std::io::{self, Read};
|
||||||
|
fn main() {
|
||||||
|
let mut input=String::new();io::stdin().read_to_string(&mut input).unwrap();
|
||||||
|
let request=serde_json::from_str(&input).unwrap();
|
||||||
|
match fidc_core::market_event_context::aggregate(request) {
|
||||||
|
Ok(value)=>println!("{}",serde_json::to_string(&value).unwrap()),
|
||||||
|
Err(error)=>{eprintln!("{error}");std::process::exit(1);}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
use std::io::Read;
|
||||||
|
fn main() {
|
||||||
|
let mut input = String::new();
|
||||||
|
std::io::stdin().read_to_string(&mut input).unwrap();
|
||||||
|
let value: serde_json::Value = serde_json::from_str(&input).unwrap();
|
||||||
|
let spec: fidc_core::daily_patterns::PatternSpec =
|
||||||
|
serde_json::from_value(value["spec"].clone()).unwrap();
|
||||||
|
let bars: Vec<fidc_core::session_events::MinuteBar> =
|
||||||
|
serde_json::from_value(value["bars"].clone()).unwrap();
|
||||||
|
let result = fidc_core::session_events::evaluate(
|
||||||
|
&spec.validate().unwrap(),
|
||||||
|
value["symbol"].as_str().unwrap(),
|
||||||
|
&bars,
|
||||||
|
serde_json::from_value(value["decision_at"].clone()).unwrap(),
|
||||||
|
);
|
||||||
|
match result {
|
||||||
|
Ok(row) => println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::json!({"contract":fidc_core::session_events::CONTRACT,"row":row,"read_only":true,"source_evidence_verified":false})
|
||||||
|
),
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("{error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
use std::io::{Read, Write};
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut raw=Vec::new();
|
||||||
|
std::io::stdin().take(64*1024*1024+1).read_to_end(&mut raw)?;
|
||||||
|
if raw.len()>64*1024*1024 {return Err("signal_book_transport_limit".into());}
|
||||||
|
let book:fidc_core::signal_contract::SignalBook=serde_json::from_slice(&raw)?;
|
||||||
|
let version=book.content_sha256()?;
|
||||||
|
let validated=book.validate()?;
|
||||||
|
let result=serde_json::json!({"schema":fidc_core::signal_contract::SIGNAL_BOOK_SCHEMA,
|
||||||
|
"versionSha256":version,"symbols":validated.symbols(),
|
||||||
|
"onlineAllowed":validated.require_observed().is_ok()});
|
||||||
|
std::io::stdout().write_all(serde_json::to_string(&result)?.as_bytes())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+110
-19
@@ -381,6 +381,8 @@ pub struct BrokerSimulator<C, R> {
|
|||||||
runtime_intraday_end_time: Cell<Option<NaiveTime>>,
|
runtime_intraday_end_time: Cell<Option<NaiveTime>>,
|
||||||
runtime_decision_date: Cell<Option<NaiveDate>>,
|
runtime_decision_date: Cell<Option<NaiveDate>>,
|
||||||
runtime_buy_denials: RefCell<BTreeMap<String, String>>,
|
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_order_created_date: Cell<Option<NaiveDate>>,
|
||||||
runtime_decision_total_equity: Cell<Option<f64>>,
|
runtime_decision_total_equity: Cell<Option<f64>>,
|
||||||
runtime_target_position_limit: Cell<Option<usize>>,
|
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_intraday_end_time: Cell::new(None),
|
||||||
runtime_decision_date: Cell::new(None),
|
runtime_decision_date: Cell::new(None),
|
||||||
runtime_buy_denials: RefCell::new(BTreeMap::new()),
|
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_order_created_date: Cell::new(None),
|
||||||
runtime_decision_total_equity: Cell::new(None),
|
runtime_decision_total_equity: Cell::new(None),
|
||||||
runtime_target_position_limit: 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_intraday_end_time: Cell::new(None),
|
||||||
runtime_decision_date: Cell::new(None),
|
runtime_decision_date: Cell::new(None),
|
||||||
runtime_buy_denials: RefCell::new(BTreeMap::new()),
|
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_order_created_date: Cell::new(None),
|
||||||
runtime_decision_total_equity: Cell::new(None),
|
runtime_decision_total_equity: Cell::new(None),
|
||||||
runtime_target_position_limit: Cell::new(None),
|
runtime_target_position_limit: Cell::new(None),
|
||||||
@@ -1389,6 +1395,11 @@ where
|
|||||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||||
let previous_decision_date = self.runtime_decision_date.get();
|
let previous_decision_date = self.runtime_decision_date.get();
|
||||||
let previous_buy_denials = self.runtime_buy_denials.replace(decision.buy_denials.clone());
|
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_order_created_date = self.runtime_order_created_date.get();
|
||||||
let previous_decision_total_equity = self.runtime_decision_total_equity.get();
|
let previous_decision_total_equity = self.runtime_decision_total_equity.get();
|
||||||
self.runtime_decision_date.set(Some(decision_date));
|
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));
|
.set(decision_total_equity.filter(|equity| equity.is_finite() && *equity >= 0.0));
|
||||||
let result = self.execute_with_runtime_dates(date, portfolio, data, decision);
|
let result = self.execute_with_runtime_dates(date, portfolio, data, decision);
|
||||||
self.runtime_buy_denials.replace(previous_buy_denials);
|
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_decision_date.set(previous_decision_date);
|
||||||
self.runtime_order_created_date
|
self.runtime_order_created_date
|
||||||
.set(previous_order_created_date);
|
.set(previous_order_created_date);
|
||||||
@@ -2850,6 +2863,15 @@ where
|
|||||||
return;
|
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()
|
let resets_queue_priority = target_limit_price.to_bits() != existing.limit_price.to_bits()
|
||||||
|| target_total_quantity > existing.requested_quantity;
|
|| target_total_quantity > existing.requested_quantity;
|
||||||
{
|
{
|
||||||
@@ -4139,6 +4161,9 @@ where
|
|||||||
minimum_order_quantity: u32,
|
minimum_order_quantity: u32,
|
||||||
order_step_size: u32,
|
order_step_size: u32,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
|
if let Some(reason) = self.runtime_auto_sell_denials.borrow().get(symbol) {
|
||||||
|
return Some(reason.clone());
|
||||||
|
}
|
||||||
if current_qty == 0 {
|
if current_qty == 0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -4299,6 +4324,10 @@ where
|
|||||||
algo_request: Option<&AlgoExecutionRequest>,
|
algo_request: Option<&AlgoExecutionRequest>,
|
||||||
report: &mut BrokerExecutionReport,
|
report: &mut BrokerExecutionReport,
|
||||||
) -> Result<(), BacktestError> {
|
) -> 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 remainder_policy = self.effective_remainder_policy(date, allow_pending_limit);
|
||||||
let Some(position) = portfolio.position(symbol) else {
|
let Some(position) = portfolio.position(symbol) else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -6074,6 +6103,9 @@ where
|
|||||||
algo_request: Option<&AlgoExecutionRequest>,
|
algo_request: Option<&AlgoExecutionRequest>,
|
||||||
report: &mut BrokerExecutionReport,
|
report: &mut BrokerExecutionReport,
|
||||||
) -> Result<(), BacktestError> {
|
) -> 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);
|
let remainder_policy = self.effective_remainder_policy(date, allow_pending_limit);
|
||||||
if portfolio
|
if portfolio
|
||||||
.position(symbol)
|
.position(symbol)
|
||||||
@@ -6166,6 +6198,24 @@ where
|
|||||||
} else {
|
} else {
|
||||||
rule
|
rule
|
||||||
};
|
};
|
||||||
|
if (rule.allowed || rule.reason.as_deref() == Some("invalid execution price"))
|
||||||
|
&& let Some(missing_reason) =
|
||||||
|
self.missing_daily_execution_price_reason(snapshot, algo_request)
|
||||||
|
{
|
||||||
|
Self::reject_missing_execution_price_order(
|
||||||
|
report,
|
||||||
|
date,
|
||||||
|
order_id,
|
||||||
|
symbol,
|
||||||
|
OrderSide::Buy,
|
||||||
|
requested_qty,
|
||||||
|
reason,
|
||||||
|
missing_reason,
|
||||||
|
emit_creation_events,
|
||||||
|
);
|
||||||
|
self.clear_open_order(order_id);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
if !rule.allowed {
|
if !rule.allowed {
|
||||||
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
|
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
|
||||||
let status = match rule.reason.as_deref() {
|
let status = match rule.reason.as_deref() {
|
||||||
@@ -6202,24 +6252,6 @@ where
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(missing_reason) =
|
|
||||||
self.missing_daily_execution_price_reason(snapshot, algo_request)
|
|
||||||
{
|
|
||||||
Self::reject_missing_execution_price_order(
|
|
||||||
report,
|
|
||||||
date,
|
|
||||||
order_id,
|
|
||||||
symbol,
|
|
||||||
OrderSide::Buy,
|
|
||||||
requested_qty,
|
|
||||||
reason,
|
|
||||||
missing_reason,
|
|
||||||
emit_creation_events,
|
|
||||||
);
|
|
||||||
self.clear_open_order(order_id);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let current_position_quantity = portfolio
|
let current_position_quantity = portfolio
|
||||||
.position(symbol)
|
.position(symbol)
|
||||||
.map(|position| position.quantity)
|
.map(|position| position.quantity)
|
||||||
@@ -7286,9 +7318,15 @@ where
|
|||||||
execution_price: f64,
|
execution_price: f64,
|
||||||
) -> Option<&'static str> {
|
) -> Option<&'static str> {
|
||||||
if !execution_price.is_finite() || execution_price <= 0.0 {
|
if !execution_price.is_finite() || execution_price <= 0.0 {
|
||||||
return None;
|
return Some("invalid execution price");
|
||||||
}
|
}
|
||||||
match side {
|
match side {
|
||||||
|
OrderSide::Buy
|
||||||
|
if self.risk_config.static_rules.reject_one_yuan_buy
|
||||||
|
&& execution_price <= 1.0 =>
|
||||||
|
{
|
||||||
|
Some("one_yuan")
|
||||||
|
}
|
||||||
OrderSide::Buy
|
OrderSide::Buy
|
||||||
if self.risk_config.static_rules.reject_upper_limit_buy
|
if self.risk_config.static_rules.reject_upper_limit_buy
|
||||||
&& snapshot.is_at_upper_limit_price(execution_price) =>
|
&& snapshot.is_at_upper_limit_price(execution_price) =>
|
||||||
@@ -7591,6 +7629,11 @@ where
|
|||||||
else {
|
else {
|
||||||
continue;
|
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 mark_price = self.quote_mark_price(quote, raw_quote_price);
|
||||||
let remaining_qty = requested_qty.saturating_sub(filled_qty);
|
let remaining_qty = requested_qty.saturating_sub(filled_qty);
|
||||||
if remaining_qty == 0 {
|
if remaining_qty == 0 {
|
||||||
@@ -8564,6 +8607,54 @@ mod tests {
|
|||||||
assert_eq!(fill.quantity, 1_200);
|
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]
|
#[test]
|
||||||
fn minute_last_uses_volume_delta_when_level1_depth_missing() {
|
fn minute_last_uses_volume_delta_when_level1_depth_missing() {
|
||||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
|
||||||
|
|||||||
@@ -10,9 +10,14 @@ pub const CONTRACT: &str = "fidc_daily_ohlcv_pattern_v1";
|
|||||||
pub fn catalog() -> Value {
|
pub fn catalog() -> Value {
|
||||||
json!({"contract":CONTRACT,"templates":{
|
json!({"contract":CONTRACT,"templates":{
|
||||||
"expression":{"label":"指标与事件条件","parameters":{"history_window":[300,2,3000]},"stages":["selection","buy","sell","position_management"],"method":"冻结历史窗口与表达式;预热不足或未定义值不产生信号。复用共享指标事件内核,不修改既有任务。"},
|
"expression":{"label":"指标与事件条件","parameters":{"history_window":[300,2,3000]},"stages":["selection","buy","sell","position_management"],"method":"冻结历史窗口与表达式;预热不足或未定义值不产生信号。复用共享指标事件内核,不修改既有任务。"},
|
||||||
|
"session_event":{"label":"已完成分钟事件","parameters":{"opening_minutes":[30,1,120],"volume_window":[5,2,120],"volume_multiple":[3.0,1,20]},"stages":["selection","buy","sell"],"method":"仅本交易日完整分钟OHLCVA,信号K线必须早于执行时点;不使用盘口快照伪造K线。"},
|
||||||
"strength":{"label":"趋势强势","parameters":{"momentum_window":[25,5,120],"fast_window":[20,2,60],"slow_window":[60,20,252]},"stages":["selection","buy"],"method":"收盘价>短均线>长均线,按区间动量排序;不是当日金叉。"},
|
"strength":{"label":"趋势强势","parameters":{"momentum_window":[25,5,120],"fast_window":[20,2,60],"slow_window":[60,20,252]},"stages":["selection","buy"],"method":"收盘价>短均线>长均线,按区间动量排序;不是当日金叉。"},
|
||||||
"breakout":{"label":"前高突破","parameters":{"high_window":[60,5,252],"volume_window":[10,2,60],"volume_multiple":[1.3,1,10],"max_upper_shadow":[0.1,0,1]},"stages":["selection","buy"],"method":"收盘突破此前N日最高价,量达到此前M日均量倍数,上影比例受限;参考窗口不含当日。"},
|
"breakout":{"label":"前高突破","parameters":{"high_window":[60,5,252],"volume_window":[10,2,60],"volume_multiple":[1.3,1,10],"max_upper_shadow":[0.1,0,1]},"stages":["selection","buy"],"method":"收盘突破此前N日最高价,量达到此前M日均量倍数,上影比例受限;参考窗口不含当日。"},
|
||||||
"volume_spike":{"label":"放量上涨","parameters":{"volume_window":[5,2,60],"volume_multiple":[3.0,1,10]},"stages":["selection","buy"],"method":"当日上涨且量达到此前N日最大量的指定倍数;不等同价格创新高。"},
|
"volume_spike":{"label":"放量上涨","parameters":{"volume_window":[5,2,60],"volume_multiple":[3.0,1,10]},"stages":["selection","buy"],"method":"当日上涨且量达到此前N日最大量的指定倍数;不等同价格创新高。"},
|
||||||
|
"mean_volume_spike":{"label":"均量倍增","parameters":{"volume_window":[5,2,60],"volume_multiple":[3.0,1,10]},"stages":["selection","buy"],"method":"量达到此前N个交易日均量的M倍且当日上涨。分母不含当日;保留与最大量规则的区别。"},
|
||||||
|
"mean_shrink_breakout":{"label":"倍量后缩量阳线突破","parameters":{"spike_lookback":[5,2,30],"volume_window":[5,2,60],"volume_multiple":[3.0,1,10],"shrink_ratio":[0.5,0.01,1]},"stages":["selection","buy"],"method":"此前出现N日均量M倍放量,当前缩量阳线收盘突破该放量日最高价。"},
|
||||||
|
"breakout_retest":{"label":"突破回踩站回","parameters":{"high_window":[60,5,252],"retest_lookback":[10,2,30],"price_tolerance":[0.02,0,0.2],"shrink_ratio":[0.8,0.01,1]},"stages":["selection","buy"],"method":"观察窗先收盘突破此前N日最高价,随后低点回踩突破位容差区,今日收盘站回该位且不低于昨日、成交量收缩。突破与回踩不得同日。"},
|
||||||
|
"limit_consolidation":{"label":"涨停后整理(日线)","parameters":{"anchor_lag":[4,2,30],"price_band":[0.05,0,0.3],"volume_band":[0.15,0,2],"ma_window":[5,2,60]},"stages":["selection","buy"],"method":"明确T-i日按真实涨停价收盘,后续收盘和量相对锚日偏离受限,今日收盘低于完整日线均线;不是盘中动态MA条件。"},
|
||||||
"shrink_breakout":{"label":"缩量突破","parameters":{"spike_lookback":[5,2,30],"volume_window":[5,2,60],"volume_multiple":[3.0,1,10],"shrink_ratio":[0.5,0.01,1]},"stages":["selection","buy"],"method":"此前观察窗有放量日,今日收盘超过该日最高价,成交量不超过其指定比例。"},
|
"shrink_breakout":{"label":"缩量突破","parameters":{"spike_lookback":[5,2,30],"volume_window":[5,2,60],"volume_multiple":[3.0,1,10],"shrink_ratio":[0.5,0.01,1]},"stages":["selection","buy"],"method":"此前观察窗有放量日,今日收盘超过该日最高价,成交量不超过其指定比例。"},
|
||||||
"ma_below":{"label":"均线下方","parameters":{"ma_window":[20,2,252]},"stages":["sell"],"method":"完整收盘价低于含当日的N日均线;独立卖出条件。"},
|
"ma_below":{"label":"均线下方","parameters":{"ma_window":[20,2,252]},"stages":["sell"],"method":"完整收盘价低于含当日的N日均线;独立卖出条件。"},
|
||||||
"volume_down":{"label":"放量下跌","parameters":{"volume_window":[5,2,60],"volume_multiple":[3.0,1,10]},"stages":["sell"],"method":"当日下跌且量达到此前N日最大量的指定倍数。"}
|
"volume_down":{"label":"放量下跌","parameters":{"volume_window":[5,2,60],"volume_multiple":[3.0,1,10]},"stages":["sell"],"method":"当日下跌且量达到此前N日最大量的指定倍数。"}
|
||||||
@@ -27,9 +32,29 @@ pub struct PatternSpec {
|
|||||||
pub parameters: BTreeMap<String, Value>,
|
pub parameters: BTreeMap<String, Value>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub expression: Option<crate::factor_events::Expr>,
|
pub expression: Option<crate::factor_events::Expr>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub execution_context: Option<crate::pattern_context::ExecutionContext>,
|
||||||
|
#[serde(default,skip_serializing_if="Option::is_none")]
|
||||||
|
pub session_event:Option<String>,
|
||||||
}
|
}
|
||||||
impl PatternSpec {
|
impl PatternSpec {
|
||||||
pub fn validate(mut self) -> Result<Self, String> {
|
pub fn validate(self) -> Result<Self, String> {
|
||||||
|
if self.template=="session_event" {
|
||||||
|
if !self.session_event.as_deref().is_some_and(|id|crate::session_events::EVENTS.contains(&id)) || self.execution_context.is_some() {return Err("session_event_contract_invalid".into());}
|
||||||
|
} else if self.session_event.is_some() {return Err("unexpected_session_event_id".into());}
|
||||||
|
let allowed = if let Some(context) = &self.execution_context {
|
||||||
|
context.validate(self.expression.as_ref().ok_or("pattern_context_requires_expression")?)?;
|
||||||
|
crate::pattern_context::CONTEXT_FIELDS
|
||||||
|
} else { &[] };
|
||||||
|
let spec = self.validate_with_context(allowed)?;
|
||||||
|
if let Some(context) = &spec.execution_context {
|
||||||
|
if context.rank_universe.len().saturating_mul(spec.history_len()) > 2_000_000 {
|
||||||
|
return Err("pattern_rank_window_budget_exceeded: 完整截面不得截断".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(spec)
|
||||||
|
}
|
||||||
|
fn validate_with_context(mut self, context_fields: &[&str]) -> Result<Self, String> {
|
||||||
if (self.template == "expression") != self.expression.is_some() {
|
if (self.template == "expression") != self.expression.is_some() {
|
||||||
return Err("expression_template_requires_expression_only".into());
|
return Err("expression_template_requires_expression_only".into());
|
||||||
}
|
}
|
||||||
@@ -49,7 +74,7 @@ impl PatternSpec {
|
|||||||
];
|
];
|
||||||
let missing = crate::factor_events::field_dependencies(expr)
|
let missing = crate::factor_events::field_dependencies(expr)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|f| !supported.contains(&f.as_str()))
|
.filter(|f| !supported.contains(&f.as_str()) && !context_fields.contains(&f.as_str()))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
if !missing.is_empty() {
|
if !missing.is_empty() {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -75,7 +100,7 @@ impl PatternSpec {
|
|||||||
if number < bounds[1].as_f64().unwrap() || number > bounds[2].as_f64().unwrap() {
|
if number < bounds[1].as_f64().unwrap() || number > bounds[2].as_f64().unwrap() {
|
||||||
return Err(format!("{key}超出允许范围"));
|
return Err(format!("{key}超出允许范围"));
|
||||||
}
|
}
|
||||||
if key.ends_with("window") || key == "spike_lookback" {
|
if key.ends_with("window") || key.ends_with("lookback") || key == "anchor_lag" || key=="opening_minutes" {
|
||||||
if number.fract() != 0.0 {
|
if number.fract() != 0.0 {
|
||||||
return Err(format!("{key}必须是整数"));
|
return Err(format!("{key}必须是整数"));
|
||||||
}
|
}
|
||||||
@@ -97,12 +122,15 @@ impl PatternSpec {
|
|||||||
}
|
}
|
||||||
pub fn history_len(&self) -> usize {
|
pub fn history_len(&self) -> usize {
|
||||||
match self.template.as_str() {
|
match self.template.as_str() {
|
||||||
|
"session_event"=>1,
|
||||||
"expression" => self.n("history_window"),
|
"expression" => self.n("history_window"),
|
||||||
"strength" => self.n("slow_window").max(self.n("momentum_window") + 1),
|
"strength" => self.n("slow_window").max(self.n("momentum_window") + 1),
|
||||||
"breakout" => self.n("high_window").max(self.n("volume_window")) + 1,
|
"breakout" => self.n("high_window").max(self.n("volume_window")) + 1,
|
||||||
"volume_spike" | "volume_down" => self.n("volume_window") + 1,
|
"volume_spike" | "volume_down" | "mean_volume_spike" => self.n("volume_window") + 1,
|
||||||
|
"breakout_retest" => self.n("high_window") + self.n("retest_lookback") + 1,
|
||||||
|
"limit_consolidation" => (self.n("anchor_lag")+1).max(self.n("ma_window")),
|
||||||
"ma_below" => self.n("ma_window").max(2),
|
"ma_below" => self.n("ma_window").max(2),
|
||||||
"shrink_breakout" => self.n("spike_lookback") + self.n("volume_window") + 1,
|
"shrink_breakout" | "mean_shrink_breakout" => self.n("spike_lookback") + self.n("volume_window") + 1,
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,6 +149,10 @@ pub struct PatternBar {
|
|||||||
pub prev_close: Option<f64>,
|
pub prev_close: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub amount: Option<f64>,
|
pub amount: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub upper_limit: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub no_limit: Option<bool>,
|
||||||
pub adjustment_factor_backward1: Option<f64>,
|
pub adjustment_factor_backward1: Option<f64>,
|
||||||
pub paused: Option<bool>,
|
pub paused: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -182,6 +214,17 @@ pub fn evaluate(
|
|||||||
days: &[NaiveDate],
|
days: &[NaiveDate],
|
||||||
series: &PatternSeries,
|
series: &PatternSeries,
|
||||||
) -> Result<PatternResult, String> {
|
) -> Result<PatternResult, String> {
|
||||||
|
evaluate_with_context(spec, days, series, &BTreeMap::new(), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn evaluate_with_context(
|
||||||
|
spec: &PatternSpec,
|
||||||
|
days: &[NaiveDate],
|
||||||
|
series: &PatternSeries,
|
||||||
|
context: &BTreeMap<String, Vec<Option<f64>>>,
|
||||||
|
numeric_output: bool,
|
||||||
|
) -> Result<PatternResult, String> {
|
||||||
|
if spec.template=="session_event" {return Err("session_event_requires_completed_minute_endpoint".into());}
|
||||||
if days.len() != spec.history_len() || days.windows(2).any(|w| w[0] >= w[1]) {
|
if days.len() != spec.history_len() || days.windows(2).any(|w| w[0] >= w[1]) {
|
||||||
return Err("pattern_calendar_incomplete: 需要完整、唯一且递增的真实交易日窗口".into());
|
return Err("pattern_calendar_incomplete: 需要完整、唯一且递增的真实交易日窗口".into());
|
||||||
}
|
}
|
||||||
@@ -190,7 +233,7 @@ pub fn evaluate(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|b| (b.date, b))
|
.map(|b| (b.date, b))
|
||||||
.collect::<BTreeMap<_, _>>();
|
.collect::<BTreeMap<_, _>>();
|
||||||
if by_day.len() != series.bars.len() || series.bars.iter().any(|b| !days.contains(&b.date)) {
|
if by_day.len() != series.bars.len() || series.bars.iter().any(|b| days.binary_search(&b.date).is_err()) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"pattern_input_invalid: symbol={}, reason=duplicate_or_out_of_scope",
|
"pattern_input_invalid: symbol={}, reason=duplicate_or_out_of_scope",
|
||||||
series.symbol
|
series.symbol
|
||||||
@@ -350,7 +393,7 @@ pub fn evaluate(
|
|||||||
)?;
|
)?;
|
||||||
if value < 0.0 || (name == "prev_close" && value == 0.0) {
|
if value < 0.0 || (name == "prev_close" && value == 0.0) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"pattern_input_invalid: {} {d} {name}",
|
"pattern_input_invalid: symbol={}, date={d}, field={name}, reason=invalid_value",
|
||||||
series.symbol
|
series.symbol
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -359,6 +402,13 @@ pub fn evaluate(
|
|||||||
.collect::<Result<Vec<_>, String>>()?;
|
.collect::<Result<Vec<_>, String>>()?;
|
||||||
fields.insert(name.into(), values);
|
fields.insert(name.into(), values);
|
||||||
}
|
}
|
||||||
|
for (name, values) in context {
|
||||||
|
if fields.contains_key(name) || values.len() != days.len()
|
||||||
|
|| values.iter().flatten().any(|v| !v.is_finite()) {
|
||||||
|
return Err(format!("research_context_invalid: {} {name}", series.symbol));
|
||||||
|
}
|
||||||
|
fields.insert(name.clone(), values.clone());
|
||||||
|
}
|
||||||
let frame = crate::factor_events::Frame {
|
let frame = crate::factor_events::Frame {
|
||||||
symbol: series.symbol.clone(),
|
symbol: series.symbol.clone(),
|
||||||
frequency: "1d".into(),
|
frequency: "1d".into(),
|
||||||
@@ -373,6 +423,12 @@ pub fn evaluate(
|
|||||||
result.values["expression_contract"] = json!(crate::factor_events::CONTRACT);
|
result.values["expression_contract"] = json!(crate::factor_events::CONTRACT);
|
||||||
result.values["price_policy"] = json!("backward1_anchored_to_decision_close");
|
result.values["price_policy"] = json!("backward1_anchored_to_decision_close");
|
||||||
result.score = latest;
|
result.score = latest;
|
||||||
|
if numeric_output {
|
||||||
|
if values.value_type != crate::factor_events::ValueType::Number {
|
||||||
|
return Err("research_rank_input_requires_numeric_expression".into());
|
||||||
|
}
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
if latest.is_none() {
|
if latest.is_none() {
|
||||||
result.exclusion = Some(
|
result.exclusion = Some(
|
||||||
json!({"reason":"expression_undefined_or_warmup","signal_date":days.last()}),
|
json!({"reason":"expression_undefined_or_warmup","signal_date":days.last()}),
|
||||||
@@ -434,11 +490,12 @@ pub fn evaluate(
|
|||||||
spec.v("max_upper_shadow"),
|
spec.v("max_upper_shadow"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
"volume_spike" | "volume_down" => {
|
"volume_spike" | "volume_down" | "mean_volume_spike" => {
|
||||||
let high = prices[len - 1 - spec.n("volume_window")..len - 1]
|
let reference = &prices[len - 1 - spec.n("volume_window")..len - 1];
|
||||||
|
let high = if spec.template=="mean_volume_spike" {mean(reference.iter().map(|b|b.4))?} else {reference
|
||||||
.iter()
|
.iter()
|
||||||
.map(|b| b.4)
|
.map(|b| b.4)
|
||||||
.fold(0.0, f64::max);
|
.fold(0.0, f64::max)};
|
||||||
if high <= 0.0 {
|
if high <= 0.0 {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"pattern_input_invalid: symbol={}, reason=zero_reference_volume",
|
"pattern_input_invalid: symbol={}, reason=zero_reference_volume",
|
||||||
@@ -449,20 +506,20 @@ pub fn evaluate(
|
|||||||
result.values["volume_ratio"] = json!(v / high);
|
result.values["volume_ratio"] = json!(v / high);
|
||||||
check(
|
check(
|
||||||
&mut result.checks,
|
&mut result.checks,
|
||||||
"最大量倍数",
|
if spec.template=="mean_volume_spike" {"均量倍数"} else {"最大量倍数"},
|
||||||
v / high,
|
v / high,
|
||||||
">=",
|
">=",
|
||||||
spec.v("volume_multiple"),
|
spec.v("volume_multiple"),
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
&mut result.checks,
|
&mut result.checks,
|
||||||
if spec.template == "volume_spike" {
|
if spec.template != "volume_down" {
|
||||||
"当日上涨"
|
"当日上涨"
|
||||||
} else {
|
} else {
|
||||||
"当日下跌"
|
"当日下跌"
|
||||||
},
|
},
|
||||||
change,
|
change,
|
||||||
if spec.template == "volume_spike" {
|
if spec.template != "volume_down" {
|
||||||
">"
|
">"
|
||||||
} else {
|
} else {
|
||||||
"<"
|
"<"
|
||||||
@@ -476,14 +533,15 @@ pub fn evaluate(
|
|||||||
result.values["ma"] = json!(avg);
|
result.values["ma"] = json!(avg);
|
||||||
check(&mut result.checks, "收盘低于均线", c, "<", avg);
|
check(&mut result.checks, "收盘低于均线", c, "<", avg);
|
||||||
}
|
}
|
||||||
"shrink_breakout" => {
|
"shrink_breakout" | "mean_shrink_breakout" => {
|
||||||
let mut spikes = Vec::new();
|
let mut spikes = Vec::new();
|
||||||
let mut eligible = Vec::new();
|
let mut eligible = Vec::new();
|
||||||
for i in len - 1 - spec.n("spike_lookback")..len - 1 {
|
for i in len - 1 - spec.n("spike_lookback")..len - 1 {
|
||||||
let prior = prices[i - spec.n("volume_window")..i]
|
let reference=&prices[i - spec.n("volume_window")..i];
|
||||||
|
let prior = if spec.template=="mean_shrink_breakout"{mean(reference.iter().map(|b|b.4))?}else{reference
|
||||||
.iter()
|
.iter()
|
||||||
.map(|b| b.4)
|
.map(|b| b.4)
|
||||||
.fold(0.0, f64::max);
|
.fold(0.0, f64::max)};
|
||||||
if prior <= 0.0 {
|
if prior <= 0.0 {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"pattern_input_invalid: symbol={}, date={}, reason=zero_reference_volume",
|
"pattern_input_invalid: symbol={}, date={}, reason=zero_reference_volume",
|
||||||
@@ -523,6 +581,44 @@ pub fn evaluate(
|
|||||||
spec.v("shrink_ratio"),
|
spec.v("shrink_ratio"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if spec.template=="mean_shrink_breakout" {check(&mut result.checks,"当前为阳线",c,">",o);}
|
||||||
|
}
|
||||||
|
"breakout_retest" => {
|
||||||
|
let mut anchors=Vec::new();let mut eligible=Vec::new();
|
||||||
|
for i in len-1-spec.n("retest_lookback")..len-1 {
|
||||||
|
let level=prices[i-spec.n("high_window")..i].iter().map(|b|b.1).fold(f64::NEG_INFINITY,f64::max);
|
||||||
|
if prices[i].3<=level {continue;}
|
||||||
|
let retraced=prices[i+1..].iter().any(|b| b.2 <= level*(1.0+spec.v("price_tolerance")));
|
||||||
|
anchors.push((i,level,retraced));
|
||||||
|
if retraced&&c>=level&&c>=prices[len-2].3&&prices[i].4>0.0&&v<=prices[i].4*spec.v("shrink_ratio") {eligible.push((i,level,retraced));}
|
||||||
|
}
|
||||||
|
check(&mut result.checks,"观察窗存在先前突破",anchors.len() as f64,">",0.0);
|
||||||
|
if let Some(&(i,level,retraced))=eligible.last().or_else(||anchors.last()) {
|
||||||
|
result.values["breakout_date"]=json!(days[i]);result.values["breakout_level"]=json!(level);result.values["days_since_breakout"]=json!(len-1-i);
|
||||||
|
check(&mut result.checks,"突破后曾回踩",if retraced{1.0}else{0.0},">",0.0);
|
||||||
|
check(&mut result.checks,"收盘重新站回突破位",c,">=",level);
|
||||||
|
check(&mut result.checks,"收盘不低于昨日",c,">=",prices[len-2].3);
|
||||||
|
if prices[i].4<=0.0{return Err("突破锚日成交量为零,不能计算缩量比例".into());}
|
||||||
|
check(&mut result.checks,"相对突破日缩量",v/prices[i].4,"<=",spec.v("shrink_ratio"));
|
||||||
|
score=Some(c/level-1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"limit_consolidation" => {
|
||||||
|
let i=len-1-spec.n("anchor_lag");let anchor=by_day[&days[i]];
|
||||||
|
let is_limit=if anchor.no_limit==Some(true){false}else{
|
||||||
|
let upper=number(anchor.upper_limit,&series.symbol,days[i],"upper_limit")?;
|
||||||
|
if upper<=0.0||upper>=99999.0{return Err("涨停事件缺少有效源涨停价或无涨跌幅限制证据,禁止按比例推算".into());}
|
||||||
|
(anchor.close.unwrap()/upper-1.0).abs()<=1e-8
|
||||||
|
};
|
||||||
|
if prices[i].4<=0.0{return Err("涨停锚日成交量为零".into());}
|
||||||
|
let price_gap=prices[i+1..].iter().map(|b|(b.3/prices[i].3-1.0).abs()).fold(0.0,f64::max);
|
||||||
|
let volume_gap=prices[i+1..].iter().map(|b|(b.4/prices[i].4-1.0).abs()).fold(0.0,f64::max);
|
||||||
|
let avg=mean(prices[len-spec.n("ma_window")..].iter().map(|b|b.3))?;
|
||||||
|
result.values["limit_date"]=json!(days[i]);result.values["price_deviation"]=json!(price_gap);result.values["volume_deviation"]=json!(volume_gap);
|
||||||
|
check(&mut result.checks,"锚日真实涨停收盘",if is_limit{1.0}else{0.0},">",0.0);
|
||||||
|
check(&mut result.checks,"后续收盘最大偏离",price_gap,"<=",spec.v("price_band"));
|
||||||
|
check(&mut result.checks,"后续成交量最大偏离",volume_gap,"<=",spec.v("volume_band"));
|
||||||
|
check(&mut result.checks,"收盘低于日线均线",c,"<",avg);score=Some(avg/c-1.0);
|
||||||
}
|
}
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
@@ -540,43 +636,51 @@ pub fn evaluate_dataset(
|
|||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
) -> Result<PatternResult, String> {
|
) -> Result<PatternResult, String> {
|
||||||
|
let context = crate::pattern_context::build_dataset_context(spec, data, date)?;
|
||||||
|
evaluate_dataset_context(spec, data, date, symbol, &context)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dataset_series(data: &DataSet, days: &[NaiveDate], symbol: &str) -> PatternSeries {
|
||||||
|
let bars = days.iter().filter_map(|&d| data.market(d, symbol).map(|b| PatternBar {
|
||||||
|
date:d, open:Some(b.open), high:Some(b.high), low:Some(b.low), close:Some(b.close),
|
||||||
|
volume:Some(b.volume as f64), prev_close:Some(b.prev_close),
|
||||||
|
amount:data.factor_numeric_value(d,symbol,"amount"),upper_limit:Some(b.upper_limit),
|
||||||
|
no_limit:data.factor_numeric_value(d,symbol,"no_limit").map(|v|v==1.0),
|
||||||
|
adjustment_factor_backward1:data.factor(d,symbol).and_then(|f|f.adjustment_factor_backward1),
|
||||||
|
paused:Some(b.paused), source_path:None,
|
||||||
|
})).collect();
|
||||||
|
PatternSeries{symbol:symbol.into(),name:data.instrument(symbol).map(|i|i.name.clone()),
|
||||||
|
listed_at:data.instrument(symbol).and_then(|i|i.listed_at),bars}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn evaluate_dataset_context(
|
||||||
|
spec: &PatternSpec, data: &DataSet, date: NaiveDate, symbol: &str, context: &ResearchContext,
|
||||||
|
) -> Result<PatternResult,String> {
|
||||||
let days = data.calendar().trailing_days(date, spec.history_len());
|
let days = data.calendar().trailing_days(date, spec.history_len());
|
||||||
let bars = days
|
let mut fields = context.common.clone();
|
||||||
.iter()
|
fields.extend(context.by_symbol.get(symbol).cloned().unwrap_or_default());
|
||||||
.filter_map(|&d| {
|
let outside = spec.execution_context.as_ref().is_some_and(|c| c.rank_expression.is_some() && !c.rank_universe.iter().any(|s|s==symbol));
|
||||||
data.market(d, symbol).map(|b| PatternBar {
|
if outside {
|
||||||
date: d,
|
for name in ["scope_rank","scope_percentile"] {fields.insert(name.into(),vec![None;days.len()]);}
|
||||||
open: Some(b.open),
|
fields.insert("scope_size".into(),vec![Some(spec.execution_context.as_ref().unwrap().rank_universe.len() as f64);days.len()]);
|
||||||
high: Some(b.high),
|
}
|
||||||
low: Some(b.low),
|
let mut result = evaluate_with_context(spec,&days,&dataset_series(data,&days,symbol),&fields,false)?;
|
||||||
close: Some(b.close),
|
if outside && result.score.is_none() { result.exclusion=Some(json!({"reason":"outside_frozen_rank_universe","symbol":symbol,"signal_date":date})); }
|
||||||
volume: Some(b.volume as f64),
|
result.values["execution_context_latest"]=json!(fields.iter().map(|(k,v)|(k,v.last().copied().flatten())).collect::<BTreeMap<_,_>>());
|
||||||
prev_close: data.factor_numeric_value(d, symbol, "pre_close"),
|
Ok(result)
|
||||||
amount: data.factor_numeric_value(d, symbol, "amount"),
|
|
||||||
adjustment_factor_backward1: data
|
|
||||||
.factor(d, symbol)
|
|
||||||
.and_then(|f| f.adjustment_factor_backward1),
|
|
||||||
paused: Some(b.paused),
|
|
||||||
source_path: None,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
evaluate(
|
|
||||||
spec,
|
|
||||||
&days,
|
|
||||||
&PatternSeries {
|
|
||||||
symbol: symbol.into(),
|
|
||||||
name: None,
|
|
||||||
listed_at: data.instrument(symbol).and_then(|i| i.listed_at),
|
|
||||||
bars,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn evaluate_batch(
|
pub fn evaluate_batch(
|
||||||
spec: PatternSpec,
|
spec: PatternSpec,
|
||||||
days: &[NaiveDate],
|
days: &[NaiveDate],
|
||||||
series: &[PatternSeries],
|
series: &[PatternSeries],
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
evaluate_batch_with_policy(spec, days, series, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Partial results are research diagnostics, never strategy execution inputs.
|
||||||
|
pub fn evaluate_batch_with_policy(
|
||||||
|
spec: PatternSpec, days: &[NaiveDate], series: &[PatternSeries], isolate_data_errors: bool,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
let spec = spec.validate()?;
|
let spec = spec.validate()?;
|
||||||
if series.is_empty()
|
if series.is_empty()
|
||||||
@@ -592,13 +696,91 @@ pub fn evaluate_batch(
|
|||||||
}
|
}
|
||||||
let rows = series
|
let rows = series
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| evaluate(&spec, days, s))
|
.map(|s| research_row(evaluate(&spec, days, s), s, isolate_data_errors))
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
Ok(
|
Ok(
|
||||||
json!({"contract":CONTRACT,"spec":spec,"required_history":spec.history_len(),"rows":rows,"read_only":true}),
|
json!({"contract":CONTRACT,"spec":spec,"required_history":spec.history_len(),"rows":rows,"read_only":true}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn research_row(result: Result<PatternResult, String>, series: &PatternSeries, isolate: bool) -> Result<Value, String> {
|
||||||
|
match result {
|
||||||
|
Ok(row) => Ok(json!(row)),
|
||||||
|
Err(detail) if isolate && detail.starts_with(&format!("pattern_input_invalid: symbol={},", series.symbol)) => {
|
||||||
|
let fields = detail.split(", ").filter_map(|p| p.split_once('=')).collect::<BTreeMap<_,_>>();
|
||||||
|
Ok(json!({"symbol":series.symbol,"name":series.name,"matched":null,"score":null,
|
||||||
|
"checks":[],"values":{},"anchor":null,"exclusion":null,
|
||||||
|
"data_issue":{"reason":fields.get("reason"),"date":fields.get("date"),"field":fields.get("field"),"detail":detail}}))
|
||||||
|
},
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Values are supplied only by the verified research transport or dataset context builder.
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ResearchContext {
|
||||||
|
#[serde(default)]
|
||||||
|
pub common: BTreeMap<String, Vec<Option<f64>>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub by_symbol: BTreeMap<String, BTreeMap<String, Vec<Option<f64>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn evaluate_research_batch(
|
||||||
|
spec: PatternSpec, days: &[NaiveDate], series: &[PatternSeries],
|
||||||
|
context: &ResearchContext, numeric_output: bool,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
evaluate_research_batch_with_policy(spec, days, series, context, numeric_output, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn evaluate_research_batch_with_policy(
|
||||||
|
spec: PatternSpec, days: &[NaiveDate], series: &[PatternSeries],
|
||||||
|
context: &ResearchContext, numeric_output: bool, isolate_data_errors: bool,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let common_fields = ["index_open", "index_high", "index_low", "index_close"].into_iter()
|
||||||
|
.chain(crate::market_event_context::COMMON_FIELDS.iter().copied()).collect::<Vec<_>>();
|
||||||
|
let symbol_fields = ["scope_rank", "scope_percentile", "scope_size"].into_iter()
|
||||||
|
.chain(crate::market_event_context::INDUSTRY_FIELDS.iter().copied()).collect::<Vec<_>>();
|
||||||
|
if spec.template != "expression" || series.is_empty() || series.len() > 200
|
||||||
|
|| series.iter().map(|s| &s.symbol).collect::<BTreeSet<_>>().len() != series.len()
|
||||||
|
|| context.common.keys().any(|k| !common_fields.contains(&k.as_str()))
|
||||||
|
|| context.by_symbol.iter().any(|(s, fields)| !series.iter().any(|row| &row.symbol == s)
|
||||||
|
|| fields.keys().any(|k| !symbol_fields.contains(&k.as_str()))) {
|
||||||
|
return Err("research_context_scope_or_fields_invalid".into());
|
||||||
|
}
|
||||||
|
for (name, values) in &context.common {
|
||||||
|
if values.len() != days.len() || values.iter().any(|v| if name.starts_with("index_") {
|
||||||
|
!v.is_some_and(|x| x.is_finite() && x > 0.0)
|
||||||
|
} else { v.is_some_and(|x| !x.is_finite()) }) {
|
||||||
|
return Err(format!("research_index_window_incomplete: {name}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let allowed = common_fields.into_iter().chain(symbol_fields).collect::<Vec<_>>();
|
||||||
|
let spec = spec.validate_with_context(&allowed)?;
|
||||||
|
let dependencies = crate::factor_events::field_dependencies(spec.expression.as_ref().unwrap());
|
||||||
|
let mut rows = Vec::with_capacity(series.len());
|
||||||
|
for item in series {
|
||||||
|
let mut fields = context.common.clone();
|
||||||
|
fields.extend(context.by_symbol.get(&item.symbol).cloned().unwrap_or_default());
|
||||||
|
if allowed.iter().any(|f| dependencies.contains(*f) && !fields.contains_key(*f)) {
|
||||||
|
return Err(format!("research_context_missing: {}", item.symbol));
|
||||||
|
}
|
||||||
|
for (name, values) in &fields {
|
||||||
|
if values.len() != days.len() || values.iter().flatten().any(|v| !v.is_finite()
|
||||||
|
|| (name == "scope_percentile" && !(0.0..=1.0).contains(v))
|
||||||
|
|| (matches!(name.as_str(), "scope_rank" | "scope_size") && *v < 1.0)) {
|
||||||
|
return Err(format!("research_context_invalid: {} {name}", item.symbol));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut result = research_row(evaluate_with_context(&spec, days, item, &fields, numeric_output), item, isolate_data_errors)?;
|
||||||
|
result["values"]["research_context_latest"] = json!(fields.iter().map(|(k,v)|(k,v.last().copied().flatten())).collect::<BTreeMap<_,_>>());
|
||||||
|
rows.push(result);
|
||||||
|
}
|
||||||
|
Ok(json!({"contract":CONTRACT,"context_contract":"fidc_research_event_context_v1","spec":spec,
|
||||||
|
"required_history":spec.history_len(),"rows":rows,"read_only":true,
|
||||||
|
"source_evidence_verified":false,"live_routing":false,"rule_backtest_supported":false}))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn expression_specs(expression: &str) -> Result<Vec<PatternSpec>, String> {
|
pub fn expression_specs(expression: &str) -> Result<Vec<PatternSpec>, String> {
|
||||||
let mut specs = Vec::new();
|
let mut specs = Vec::new();
|
||||||
for helper in ["pattern_signal", "pattern_score"] {
|
for helper in ["pattern_signal", "pattern_score"] {
|
||||||
@@ -635,6 +817,53 @@ pub fn expression_specs(expression: &str) -> Result<Vec<PatternSpec>, String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
#[test]
|
#[test]
|
||||||
|
fn research_isolates_missing_listing_day_without_weakening_execution() {
|
||||||
|
let days = ["2026-06-11", "2026-06-12"].map(|d|d.parse::<NaiveDate>().unwrap());
|
||||||
|
let spec: PatternSpec = serde_json::from_value(json!({"template":"ma_below","parameters":{"ma_window":2}})).unwrap();
|
||||||
|
let make = |symbol: &str| -> PatternSeries { serde_json::from_value(json!({"symbol":symbol,"listed_at":"2026-06-11","bars":days.map(|d|json!({"date":d,"open":10.,"high":11.,"low":9.,"close":10.,"volume":100.,"adjustment_factor_backward1":1.,"paused":false,"source_path":"/controlled/source.parquet"}))})).unwrap() };
|
||||||
|
let complete=make("300395.SZ");let mut missing=make("920083.BJ");missing.bars.remove(0);
|
||||||
|
let members=[complete.clone(),missing];
|
||||||
|
assert!(evaluate_batch(spec.clone(),&days,&members).unwrap_err().contains("missing_market_row"));
|
||||||
|
let partial=evaluate_batch_with_policy(spec.clone(),&days,&members,true).unwrap();
|
||||||
|
assert_eq!(partial["rows"][0],json!(evaluate(&spec.validate().unwrap(),&days,&complete).unwrap()));
|
||||||
|
assert!(partial["rows"][1]["matched"].is_null());
|
||||||
|
assert_eq!(partial["rows"][1]["data_issue"]["date"],"2026-06-11");
|
||||||
|
assert_eq!(partial["rows"][1]["data_issue"]["reason"],"missing_market_row");
|
||||||
|
let invalid:PatternSpec=serde_json::from_value(json!({"template":"not-a-template"})).unwrap();
|
||||||
|
assert!(evaluate_batch_with_policy(invalid,&days,&members,true).is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn research_index_and_ranking_context_never_unlock_strategy_mapping() {
|
||||||
|
let days=["2026-09-04","2026-09-07","2026-09-08"].map(|s|s.parse::<NaiveDate>().unwrap());
|
||||||
|
let spec:PatternSpec=serde_json::from_value(json!({"template":"expression","parameters":{"history_window":3},
|
||||||
|
"expression":{"kind":"operator","name":"CROSS_ABOVE","args":[{"kind":"field","name":"close"},{"kind":"field","name":"index_close"}]}})).unwrap();
|
||||||
|
assert!(spec.clone().validate().unwrap_err().contains("mapping_required"));
|
||||||
|
let series:PatternSeries=serde_json::from_value(json!({"symbol":"TEST","bars":days.iter().zip([9.0,10.0,11.0]).map(|(d,c)|json!({"date":d,"open":c,"high":c,"low":c,"close":c,"volume":100.0,"adjustment_factor_backward1":1.0,"paused":false})).collect::<Vec<_>>()})).unwrap();
|
||||||
|
let mut context=ResearchContext{common:BTreeMap::from([("index_close".into(),vec![Some(10.0);3])]),..Default::default()};
|
||||||
|
let result=evaluate_research_batch(spec.clone(),&days,&[series.clone()],&context,false).unwrap();
|
||||||
|
assert_eq!(result["rows"][0]["matched"],true);
|
||||||
|
assert_eq!(result["source_evidence_verified"],false);
|
||||||
|
assert_eq!(result["rule_backtest_supported"],false);
|
||||||
|
context.common.get_mut("index_close").unwrap()[1]=None;
|
||||||
|
assert!(evaluate_research_batch(spec.clone(),&days,&[series.clone()],&context,false).unwrap_err().contains("index_window_incomplete"));
|
||||||
|
context.common=BTreeMap::from([("close".into(),vec![Some(10.0);3])]);
|
||||||
|
assert!(evaluate_research_batch(spec,&days,&[series],&context,false).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn research_numeric_output_keeps_warmup_unknown_without_a_false_signal() {
|
||||||
|
let days=["2026-09-04","2026-09-07","2026-09-08"].map(|s|s.parse::<NaiveDate>().unwrap());
|
||||||
|
let spec:PatternSpec=serde_json::from_value(json!({"template":"expression","parameters":{"history_window":3},
|
||||||
|
"expression":{"kind":"operator","name":"PCT_CHANGE","window":2,"args":[{"kind":"field","name":"close"}]}})).unwrap();
|
||||||
|
let series:PatternSeries=serde_json::from_value(json!({"symbol":"TEST","bars":days.iter().zip([10.0,10.5,11.0]).map(|(d,c)|json!({"date":d,"open":c,"high":c,"low":c,"close":c,"volume":100.0,"adjustment_factor_backward1":1.0,"paused":false})).collect::<Vec<_>>()})).unwrap();
|
||||||
|
assert!(evaluate_batch(spec.clone(),&days,&[series.clone()]).is_err());
|
||||||
|
let result=evaluate_research_batch(spec,&days,&[series],&ResearchContext::default(),true).unwrap();
|
||||||
|
let values=&result["rows"][0]["values"]["expression"]["values"];
|
||||||
|
assert!(values[0].is_null() && values[1].is_null());
|
||||||
|
assert!((values[2].as_f64().unwrap()-0.1).abs()<1e-12);
|
||||||
|
assert_eq!(result["rows"][0]["matched"],false);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
fn expression_condition_preserves_native_types_and_rejects_numeric_as_signal() {
|
fn expression_condition_preserves_native_types_and_rejects_numeric_as_signal() {
|
||||||
let make = |expression: Value| {
|
let make = |expression: Value| {
|
||||||
serde_json::from_value::<PatternSpec>(json!({"template":"expression","parameters":{"history_window":3},"expression":expression})).unwrap().validate().unwrap()
|
serde_json::from_value::<PatternSpec>(json!({"template":"expression","parameters":{"history_window":3},"expression":expression})).unwrap().validate().unwrap()
|
||||||
@@ -662,6 +891,8 @@ mod tests {
|
|||||||
volume: Some(100.0),
|
volume: Some(100.0),
|
||||||
prev_close: Some(p - 1.0),
|
prev_close: Some(p - 1.0),
|
||||||
amount: Some(p * 100.0),
|
amount: Some(p * 100.0),
|
||||||
|
upper_limit: None,
|
||||||
|
no_limit: None,
|
||||||
adjustment_factor_backward1: Some(1.0),
|
adjustment_factor_backward1: Some(1.0),
|
||||||
paused: Some(false),
|
paused: Some(false),
|
||||||
source_path: None,
|
source_path: None,
|
||||||
@@ -703,6 +934,8 @@ mod tests {
|
|||||||
template: template.into(),
|
template: template.into(),
|
||||||
parameters: BTreeMap::new(),
|
parameters: BTreeMap::new(),
|
||||||
expression: None,
|
expression: None,
|
||||||
|
execution_context: None,
|
||||||
|
session_event: None,
|
||||||
}
|
}
|
||||||
.validate()
|
.validate()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -725,6 +958,8 @@ mod tests {
|
|||||||
volume: Some(1000.0),
|
volume: Some(1000.0),
|
||||||
prev_close: Some(c - 1.0),
|
prev_close: Some(c - 1.0),
|
||||||
amount: Some(c * 1000.0),
|
amount: Some(c * 1000.0),
|
||||||
|
upper_limit: None,
|
||||||
|
no_limit: None,
|
||||||
adjustment_factor_backward1: Some(1.0),
|
adjustment_factor_backward1: Some(1.0),
|
||||||
paused: Some(false),
|
paused: Some(false),
|
||||||
source_path: Some("fixture.parquet".into()),
|
source_path: Some("fixture.parquet".into()),
|
||||||
@@ -775,6 +1010,51 @@ mod tests {
|
|||||||
assert_eq!(a.checks, b.checks);
|
assert_eq!(a.checks, b.checks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mean_volume_is_not_prior_max_and_excludes_current_bar() {
|
||||||
|
let (spec,days,mut series)=fixture("mean_volume_spike");
|
||||||
|
for (bar,volume) in series.bars.iter_mut().zip([10.,10.,10.,10.,100.,100.]) {bar.volume=Some(volume);}
|
||||||
|
assert!(evaluate(&spec,&days,&series).unwrap().matched);
|
||||||
|
let mut old=spec.clone();old.template="volume_spike".into();
|
||||||
|
assert!(!evaluate(&old,&days,&series).unwrap().matched);
|
||||||
|
assert_eq!(evaluate(&spec,&days,&series).unwrap().values["volume_ratio"],json!(100./28.));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mean_volume_followup_requires_bullish_breakout_and_shrink() {
|
||||||
|
let (spec,days,mut series)=fixture("mean_shrink_breakout");
|
||||||
|
series.bars[6].volume=Some(4000.);
|
||||||
|
let last=series.bars.last_mut().unwrap();last.open=Some(19.);last.low=Some(19.);
|
||||||
|
assert!(evaluate(&spec,&days,&series).unwrap().matched);
|
||||||
|
series.bars.last_mut().unwrap().volume=Some(3000.);
|
||||||
|
assert!(!evaluate(&spec,&days,&series).unwrap().matched);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn breakout_retest_needs_a_later_retest_not_the_breakout_candle_itself() {
|
||||||
|
let (spec,days,mut series)=fixture("breakout_retest");
|
||||||
|
for b in &mut series.bars {b.open=Some(10.);b.high=Some(10.);b.low=Some(10.);b.close=Some(10.);}
|
||||||
|
let anchor=series.bars.len()-11;
|
||||||
|
let b=&mut series.bars[anchor];b.open=Some(11.);b.high=Some(12.1);b.low=Some(9.9);b.close=Some(12.);b.volume=Some(2000.);
|
||||||
|
for b in &mut series.bars[anchor+1..] {b.open=Some(10.3);b.high=Some(10.4);b.low=Some(10.3);b.close=Some(10.4);}
|
||||||
|
assert!(!evaluate(&spec,&days,&series).unwrap().matched);
|
||||||
|
series.bars[anchor+1].low=Some(9.95);
|
||||||
|
assert!(evaluate(&spec,&days,&series).unwrap().matched);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn limit_consolidation_requires_real_limit_and_never_infers_ten_percent() {
|
||||||
|
let (spec,days,mut series)=fixture("limit_consolidation");
|
||||||
|
for (b,c) in series.bars.iter_mut().zip([10.,10.1,10.2,10.1,9.9]) {b.open=Some(c);b.high=Some(c);b.low=Some(c);b.close=Some(c);}
|
||||||
|
assert!(evaluate(&spec,&days,&series).unwrap_err().contains("upper_limit"));
|
||||||
|
series.bars[0].upper_limit=Some(10.);
|
||||||
|
assert!(evaluate(&spec,&days,&series).unwrap().matched);
|
||||||
|
series.bars[0].no_limit=Some(true);
|
||||||
|
assert!(!evaluate(&spec,&days,&series).unwrap().matched);
|
||||||
|
series.bars[0].no_limit=Some(false);series.bars[0].upper_limit=Some(0.);
|
||||||
|
assert!(evaluate(&spec,&days,&series).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn daily_patterns_flat_decimal_prices_do_not_create_a_sell_signal() {
|
fn daily_patterns_flat_decimal_prices_do_not_create_a_sell_signal() {
|
||||||
let (mut spec, _, mut series) = fixture("strength");
|
let (mut spec, _, mut series) = fixture("strength");
|
||||||
|
|||||||
@@ -491,6 +491,7 @@ pub struct DataSetSnapshotComponents {
|
|||||||
pub benchmarks: Vec<BenchmarkSnapshot>,
|
pub benchmarks: Vec<BenchmarkSnapshot>,
|
||||||
pub corporate_actions: Vec<CorporateAction>,
|
pub corporate_actions: Vec<CorporateAction>,
|
||||||
pub execution_quotes: Vec<IntradayExecutionQuote>,
|
pub execution_quotes: Vec<IntradayExecutionQuote>,
|
||||||
|
pub completed_minute_bars: Vec<crate::session_events::MinuteBar>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -1418,6 +1419,7 @@ pub struct DataSet {
|
|||||||
eligible_universe_by_date: Arc<OnceLock<BTreeMap<NaiveDate, Vec<EligibleUniverseSnapshot>>>>,
|
eligible_universe_by_date: Arc<OnceLock<BTreeMap<NaiveDate, Vec<EligibleUniverseSnapshot>>>>,
|
||||||
benchmark_code: String,
|
benchmark_code: String,
|
||||||
futures_params_by_symbol: Arc<HashMap<String, Vec<FuturesTradingParameter>>>,
|
futures_params_by_symbol: Arc<HashMap<String, Vec<FuturesTradingParameter>>>,
|
||||||
|
completed_minute_bars: Arc<BTreeMap<(NaiveDate,String),Vec<crate::session_events::MinuteBar>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DailySymbolRows<'a, T> {
|
struct DailySymbolRows<'a, T> {
|
||||||
@@ -1954,6 +1956,7 @@ impl DataSet {
|
|||||||
eligible_universe_by_date: Arc::new(OnceLock::new()),
|
eligible_universe_by_date: Arc::new(OnceLock::new()),
|
||||||
benchmark_code,
|
benchmark_code,
|
||||||
futures_params_by_symbol: Arc::new(futures_params_by_symbol),
|
futures_params_by_symbol: Arc::new(futures_params_by_symbol),
|
||||||
|
completed_minute_bars: Arc::new(BTreeMap::new()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2627,9 +2630,21 @@ impl DataSet {
|
|||||||
benchmarks,
|
benchmarks,
|
||||||
corporate_actions,
|
corporate_actions,
|
||||||
execution_quotes,
|
execution_quotes,
|
||||||
|
completed_minute_bars:self.completed_minute_bars.values().flatten().cloned().collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_completed_minute_bars(mut self,bars:Vec<crate::session_events::MinuteBar>)->Result<Self,String> {
|
||||||
|
self.completed_minute_bars=crate::session_events::bar_store(bars)?;Ok(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_shared_completed_minute_bars(mut self,bars:crate::session_events::BarStore)->Self {self.completed_minute_bars=bars;self}
|
||||||
|
pub fn completed_minute_bar_count(&self)->usize {self.completed_minute_bars.values().map(Vec::len).sum()}
|
||||||
|
|
||||||
|
pub fn completed_minute_bars_on(&self,date:NaiveDate,symbol:&str)->&[crate::session_events::MinuteBar] {
|
||||||
|
self.completed_minute_bars.get(&(date,symbol.into())).map(Vec::as_slice).unwrap_or(&[])
|
||||||
|
}
|
||||||
|
|
||||||
pub fn benchmark_series(&self) -> Vec<BenchmarkSnapshot> {
|
pub fn benchmark_series(&self) -> Vec<BenchmarkSnapshot> {
|
||||||
self.benchmark_by_date.values().cloned().collect()
|
self.benchmark_by_date.values().cloned().collect()
|
||||||
}
|
}
|
||||||
@@ -3360,6 +3375,12 @@ impl DataSet {
|
|||||||
.unwrap_or(&[])
|
.unwrap_or(&[])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_reference_only_benchmark(&self, symbol: &str) -> bool {
|
||||||
|
if symbol != self.benchmark_code() { return false; }
|
||||||
|
let Some(symbol_id) = self.symbol_id(symbol) else { return true; };
|
||||||
|
!self.candidate_symbol_ids_by_date.values().any(|ids| ids.contains(&symbol_id))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn bundle_on(&self, date: NaiveDate) -> Result<DailySnapshotBundle, DataSetError> {
|
pub fn bundle_on(&self, date: NaiveDate) -> Result<DailySnapshotBundle, DataSetError> {
|
||||||
let benchmark = self
|
let benchmark = self
|
||||||
.benchmark(date)
|
.benchmark(date)
|
||||||
@@ -6214,7 +6235,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn baseline_selection_uses_structured_instrument_dates_and_status_only() {
|
fn baseline_selection_uses_dated_lifecycle_not_latest_undated_status() {
|
||||||
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
|
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
|
||||||
let instrument = |name: &str, status: &str, delisted_at: Option<NaiveDate>| Instrument {
|
let instrument = |name: &str, status: &str, delisted_at: Option<NaiveDate>| Instrument {
|
||||||
symbol: "000001.SZ".to_string(),
|
symbol: "000001.SZ".to_string(),
|
||||||
@@ -6242,7 +6263,7 @@ mod tests {
|
|||||||
Some(&instrument("退市测试", "active", None)),
|
Some(&instrument("退市测试", "active", None)),
|
||||||
date
|
date
|
||||||
));
|
));
|
||||||
assert!(!instrument_passes_baseline_selection(
|
assert!(instrument_passes_baseline_selection(
|
||||||
Some(&instrument("正常名称", "delisted", None)),
|
Some(&instrument("正常名称", "delisted", None)),
|
||||||
date
|
date
|
||||||
));
|
));
|
||||||
|
|||||||
+131
-21
@@ -468,9 +468,17 @@ pub struct BacktestEngine<S, C, R> {
|
|||||||
preplanned_decision_quote_symbols_by_date: Option<Arc<BTreeMap<NaiveDate, BTreeSet<String>>>>,
|
preplanned_decision_quote_symbols_by_date: Option<Arc<BTreeMap<NaiveDate, BTreeSet<String>>>>,
|
||||||
execution_quote_request_cache:
|
execution_quote_request_cache:
|
||||||
BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>,
|
BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>,
|
||||||
|
execution_absence_notes: BTreeMap<NaiveDate, Vec<String>>,
|
||||||
|
execution_lifecycle_reported: BTreeSet<(String, String)>,
|
||||||
risk_free_rate_contract: Option<RiskFreeRateContract>,
|
risk_free_rate_contract: Option<RiskFreeRateContract>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn all_instruments_have_dated_absence(data: &DataSet, date: NaiveDate) -> bool {
|
||||||
|
let mut instruments = data.instruments().values()
|
||||||
|
.filter(|instrument| !data.is_reference_only_benchmark(&instrument.symbol)).peekable();
|
||||||
|
instruments.peek().is_some() && instruments.all(|instrument| instrument.dated_market_absence_reason(date).is_some())
|
||||||
|
}
|
||||||
|
|
||||||
fn backtest_execution_schedule(
|
fn backtest_execution_schedule(
|
||||||
data: &DataSet,
|
data: &DataSet,
|
||||||
start_date: Option<NaiveDate>,
|
start_date: Option<NaiveDate>,
|
||||||
@@ -493,10 +501,15 @@ fn backtest_execution_schedule(
|
|||||||
if decision_lag_trading_days == 0 {
|
if decision_lag_trading_days == 0 {
|
||||||
if has_decision_inputs(execution_date) {
|
if has_decision_inputs(execution_date) {
|
||||||
schedule.push((execution_date, Some((calendar_idx, execution_date))));
|
schedule.push((execution_date, Some((calendar_idx, execution_date))));
|
||||||
|
} else if all_instruments_have_dated_absence(data, execution_date) {
|
||||||
|
schedule.push((execution_date, None));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if !has_execution_market(execution_date) {
|
if !has_execution_market(execution_date) {
|
||||||
|
if all_instruments_have_dated_absence(data, execution_date) {
|
||||||
|
schedule.push((execution_date, None));
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let decision_slot = calendar_idx
|
let decision_slot = calendar_idx
|
||||||
@@ -507,6 +520,7 @@ fn backtest_execution_schedule(
|
|||||||
schedule.push((execution_date, decision_slot));
|
schedule.push((execution_date, decision_slot));
|
||||||
}
|
}
|
||||||
None => schedule.push((execution_date, None)),
|
None => schedule.push((execution_date, None)),
|
||||||
|
Some((_, decision_date)) if all_instruments_have_dated_absence(data, decision_date) => schedule.push((execution_date, None)),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -554,6 +568,8 @@ impl<S, C, R> BacktestEngine<S, C, R> {
|
|||||||
execution_quote_loader: None,
|
execution_quote_loader: None,
|
||||||
preplanned_decision_quote_symbols_by_date: None,
|
preplanned_decision_quote_symbols_by_date: None,
|
||||||
execution_quote_request_cache: BTreeSet::new(),
|
execution_quote_request_cache: BTreeSet::new(),
|
||||||
|
execution_absence_notes: BTreeMap::new(),
|
||||||
|
execution_lifecycle_reported: BTreeSet::new(),
|
||||||
risk_free_rate_contract: None,
|
risk_free_rate_contract: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -768,6 +784,31 @@ where
|
|||||||
end_time: Option<NaiveTime>,
|
end_time: Option<NaiveTime>,
|
||||||
symbols: &mut BTreeSet<String>,
|
symbols: &mut BTreeSet<String>,
|
||||||
) -> Result<(), BacktestError> {
|
) -> Result<(), BacktestError> {
|
||||||
|
let mut available = BTreeSet::new();
|
||||||
|
for symbol in symbols.iter() {
|
||||||
|
let instrument = self.data.instrument(symbol).ok_or_else(|| BacktestError::Execution(format!(
|
||||||
|
"execution_data_missing reason=instrument_metadata_or_code_mapping_missing symbol={symbol} execution_date={execution_date}"
|
||||||
|
)))?;
|
||||||
|
if let Some(reason) = instrument.dated_market_absence_reason(execution_date) {
|
||||||
|
if self.data.price(execution_date, symbol, PriceField::Close).is_some()
|
||||||
|
|| !self.data.execution_quotes_on(execution_date, symbol).is_empty()
|
||||||
|
{
|
||||||
|
return Err(BacktestError::Execution(format!(
|
||||||
|
"execution_data_conflict reason={reason} symbol={symbol} execution_date={execution_date} listed_at={:?} delisted_at={:?}",
|
||||||
|
instrument.listed_at, instrument.delisted_at
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if self.execution_lifecycle_reported.insert((symbol.clone(), reason.to_string())) {
|
||||||
|
self.execution_absence_notes.entry(execution_date).or_default().push(format!(
|
||||||
|
"execution_data_absence reason={reason} symbol={symbol} execution_date={execution_date} listed_at={:?} delisted_at={:?} no_price_fill=true",
|
||||||
|
instrument.listed_at, instrument.delisted_at
|
||||||
|
));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
available.insert(symbol.clone());
|
||||||
|
}
|
||||||
|
*symbols = available;
|
||||||
symbols.retain(|symbol| {
|
symbols.retain(|symbol| {
|
||||||
let request_key = (execution_date, symbol.clone(), start_time, end_time);
|
let request_key = (execution_date, symbol.clone(), start_time, end_time);
|
||||||
if self.execution_quote_request_cache.contains(&request_key) {
|
if self.execution_quote_request_cache.contains(&request_key) {
|
||||||
@@ -835,9 +876,6 @@ where
|
|||||||
let mut paused_with_quotes = Vec::new();
|
let mut paused_with_quotes = Vec::new();
|
||||||
let mut missing_daily_market = Vec::new();
|
let mut missing_daily_market = Vec::new();
|
||||||
for symbol in requested_symbols {
|
for symbol in requested_symbols {
|
||||||
let Some(_candidate) = self.data.candidate(execution_date, symbol) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(market) = self.data.market(execution_date, symbol) else {
|
let Some(market) = self.data.market(execution_date, symbol) else {
|
||||||
missing_daily_market.push(symbol.clone());
|
missing_daily_market.push(symbol.clone());
|
||||||
continue;
|
continue;
|
||||||
@@ -2191,12 +2229,13 @@ where
|
|||||||
date: execution_date,
|
date: execution_date,
|
||||||
})?;
|
})?;
|
||||||
let notes = join_text_parts(corporate_action_notes.into_iter());
|
let notes = join_text_parts(corporate_action_notes.into_iter());
|
||||||
|
let absence = all_instruments_have_dated_absence(&self.data, execution_date);
|
||||||
let diagnostics = join_text_parts(
|
let diagnostics = join_text_parts(
|
||||||
std::iter::once(format!(
|
std::iter::once(if absence {
|
||||||
"decision_lag_warmup lag_days={} execution_index={}",
|
format!("execution_data_absence reason=all_instruments_outside_dated_lifecycle execution_date={execution_date} cash_period_retained=true no_price_fill=true")
|
||||||
self.config.decision_lag_trading_days, execution_idx
|
} else { format!("decision_lag_warmup lag_days={} execution_index={}", self.config.decision_lag_trading_days, execution_idx) })
|
||||||
))
|
.chain(broker_diagnostics.into_iter())
|
||||||
.chain(broker_diagnostics.into_iter()),
|
.chain(self.execution_absence_notes.remove(&execution_date).unwrap_or_default()),
|
||||||
);
|
);
|
||||||
let holdings_for_day = portfolio.holdings_summary(execution_date);
|
let holdings_for_day = portfolio.holdings_summary(execution_date);
|
||||||
let holding_start = result.daily_holdings.len();
|
let holding_start = result.daily_holdings.len();
|
||||||
@@ -2213,7 +2252,7 @@ where
|
|||||||
previous_external_cash_flow_total = portfolio.external_cash_flow_total();
|
previous_external_cash_flow_total = portfolio.external_cash_flow_total();
|
||||||
|
|
||||||
result.equity_curve.push(DailyEquityPoint {
|
result.equity_curve.push(DailyEquityPoint {
|
||||||
signal_baseline: true,
|
signal_baseline: execution_idx == 0,
|
||||||
date: execution_date,
|
date: execution_date,
|
||||||
cash: aggregate_cash,
|
cash: aggregate_cash,
|
||||||
market_value: aggregate_market_value,
|
market_value: aggregate_market_value,
|
||||||
@@ -3364,7 +3403,8 @@ where
|
|||||||
decision
|
decision
|
||||||
.diagnostics
|
.diagnostics
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.chain(broker_diagnostics.into_iter()),
|
.chain(broker_diagnostics.into_iter())
|
||||||
|
.chain(self.execution_absence_notes.remove(&execution_date).unwrap_or_default()),
|
||||||
);
|
);
|
||||||
let holdings_for_day = portfolio.holdings_summary(execution_date);
|
let holdings_for_day = portfolio.holdings_summary(execution_date);
|
||||||
let holding_start = result.daily_holdings.len();
|
let holding_start = result.daily_holdings.len();
|
||||||
@@ -3964,17 +4004,11 @@ where
|
|||||||
let Some(instrument) = self.data.instrument(&symbol) else {
|
let Some(instrument) = self.data.instrument(&symbol) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let is_unresolved = instrument.is_delisted_on_or_before(date)
|
let is_unresolved = instrument.is_delisted_on_or_before(date);
|
||||||
|| (instrument.status.eq_ignore_ascii_case("delisted")
|
|
||||||
&& instrument.delisted_at.is_none()
|
|
||||||
&& self.data.market(date, &symbol).is_none());
|
|
||||||
if !is_unresolved {
|
if !is_unresolved {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let effective_delisted_at = instrument
|
let effective_delisted_at = instrument.delisted_at.expect("dated delisting checked");
|
||||||
.delisted_at
|
|
||||||
.or_else(|| self.data.calendar().previous_day(date))
|
|
||||||
.unwrap_or(date);
|
|
||||||
let reason = format!(
|
let reason = format!(
|
||||||
concat!(
|
concat!(
|
||||||
"unresolved_delisted_position symbol={} quantity={} effective_date={} status={} ",
|
"unresolved_delisted_position symbol={} quantity={} effective_date={} status={} ",
|
||||||
@@ -5543,6 +5577,34 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wholly_prelisting_universe_retains_cash_days_without_fabricating_prices() {
|
||||||
|
let dates = [d(2025, 1, 2), d(2025, 1, 3), d(2025, 1, 6)];
|
||||||
|
let mut engine = engine_with_matching(MatchingType::CurrentBarClose, PriceField::Close, 0);
|
||||||
|
engine.config.end_date = Some(dates[2]);
|
||||||
|
let mut markets = vec![market(dates[2], 10.0, 10.0)];
|
||||||
|
markets.extend(dates.iter().map(|date| DailyMarketSnapshot { symbol: "000852.SH".into(), ..market(*date, 1000.0, 1000.0) }));
|
||||||
|
engine.data = DataSet::from_components(
|
||||||
|
vec![Instrument { listed_at: Some(dates[2]), ..default_instrument() }, Instrument { symbol: "000852.SH".into(), listed_at: None, ..default_instrument() }],
|
||||||
|
markets, vec![factor(dates[2])], vec![candidate(dates[2])],
|
||||||
|
dates.iter().map(|date| benchmark(*date)).collect(),
|
||||||
|
).unwrap();
|
||||||
|
assert_eq!(super::backtest_execution_dates(&engine.data, Some(dates[0]), Some(dates[2]), 0), dates);
|
||||||
|
assert_eq!(super::backtest_execution_dates(&engine.data, Some(dates[0]), Some(dates[2]), 1), dates);
|
||||||
|
let result = engine.run().unwrap();
|
||||||
|
assert_eq!(result.equity_curve.len(), 3);
|
||||||
|
for point in &result.equity_curve[..2] {
|
||||||
|
assert_eq!(point.total_equity, 100_000.0);
|
||||||
|
assert_eq!(point.market_value, 0.0);
|
||||||
|
assert!(point.diagnostics.contains("cash_period_retained=true"));
|
||||||
|
}
|
||||||
|
assert!(result.order_events.is_empty());
|
||||||
|
assert!(engine.data.market(dates[0], SYMBOL).is_none());
|
||||||
|
assert!(result.equity_curve[0].signal_baseline);
|
||||||
|
assert!(!result.equity_curve[1].signal_baseline);
|
||||||
|
assert!(!super::all_instruments_have_dated_absence(&dataset(), dates[0]));
|
||||||
|
}
|
||||||
|
|
||||||
fn engine_with_matching(
|
fn engine_with_matching(
|
||||||
matching_type: MatchingType,
|
matching_type: MatchingType,
|
||||||
execution_price_field: PriceField,
|
execution_price_field: PriceField,
|
||||||
@@ -5996,6 +6058,38 @@ mod tests {
|
|||||||
.expect("zero-volume stock may have no minute bars");
|
.expect("zero-volume stock may have no minute bars");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lifecycle_quote_filter_skips_only_dated_legal_absence_before_loading() {
|
||||||
|
let date = d(2025, 9, 10);
|
||||||
|
for (symbol, listed_at, delisted_at, reason) in [
|
||||||
|
("920038.BJ", Some(d(2026, 8, 5)), None, "not_yet_listed"),
|
||||||
|
("563360.SH", Some(d(2026, 8, 5)), None, "not_yet_listed"),
|
||||||
|
("000001.SZ", Some(d(2010, 1, 1)), Some(d(2025, 9, 9)), "delisted"),
|
||||||
|
] {
|
||||||
|
let instrument = Instrument { symbol: symbol.into(), listed_at, delisted_at, ..default_instrument() };
|
||||||
|
let data = DataSet::from_components(vec![instrument], vec![], vec![], vec![], vec![benchmark(date)]).unwrap();
|
||||||
|
let mut engine = full_day_coverage_engine(data, date);
|
||||||
|
engine.execution_quote_loader = Some(Box::new(|_| panic!("legal lifecycle absence must not load prices")));
|
||||||
|
engine.load_missing_execution_quotes(date, None, None, &mut BTreeSet::from([symbol.to_string()])).unwrap();
|
||||||
|
let notes = engine.execution_absence_notes.get(&date).unwrap();
|
||||||
|
assert!(notes[0].contains(reason));
|
||||||
|
assert!(notes[0].contains(symbol));
|
||||||
|
engine.load_missing_execution_quotes(date, None, None, &mut BTreeSet::from([symbol.to_string()])).unwrap();
|
||||||
|
assert_eq!(engine.execution_absence_notes[&date].len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_identity_or_missing_candidate_does_not_waive_quote_coverage() {
|
||||||
|
let date = d(2025, 9, 10);
|
||||||
|
let data = DataSet::from_components(vec![default_instrument()], vec![], vec![], vec![], vec![benchmark(date)]).unwrap();
|
||||||
|
let mut engine = full_day_coverage_engine(data, date);
|
||||||
|
let error = engine.load_missing_execution_quotes(date, None, None, &mut BTreeSet::from(["unmapped".to_string()])).unwrap_err();
|
||||||
|
assert!(error.to_string().contains("instrument_metadata_or_code_mapping_missing"));
|
||||||
|
let error = engine.validate_full_day_execution_quote_coverage(date, &[SYMBOL.to_string()]).unwrap_err();
|
||||||
|
assert!(error.to_string().contains("missing_daily_market"));
|
||||||
|
}
|
||||||
|
|
||||||
fn run_scheduled_next_open_with_dataset(dataset: DataSet) -> super::BacktestResult {
|
fn run_scheduled_next_open_with_dataset(dataset: DataSet) -> super::BacktestResult {
|
||||||
run_scheduled_next_open_with_dataset_and_broker(
|
run_scheduled_next_open_with_dataset_and_broker(
|
||||||
dataset,
|
dataset,
|
||||||
@@ -6605,17 +6699,33 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn next_bar_open_execution_risk_rejects_execution_day_one_yuan_state() {
|
fn next_bar_open_execution_risk_rejects_one_yuan_open_despite_higher_close() {
|
||||||
let first = d(2025, 1, 2);
|
let first = d(2025, 1, 2);
|
||||||
let second = d(2025, 1, 3);
|
let second = d(2025, 1, 3);
|
||||||
let result = run_scheduled_next_open_with_dataset(dataset_with(
|
let result = run_scheduled_next_open_with_dataset(dataset_with(
|
||||||
market(first, 10.0, 11.5),
|
market(first, 10.0, 11.5),
|
||||||
market(second, 12.0, 99.0),
|
market(second, 0.9, 1.2),
|
||||||
|
candidate(first),
|
||||||
|
candidate(second),
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_next_open_canceled_with_reason(&result, "one_yuan");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_bar_open_execution_risk_ignores_later_one_yuan_close() {
|
||||||
|
let first = d(2025, 1, 2);
|
||||||
|
let second = d(2025, 1, 3);
|
||||||
|
let result = run_scheduled_next_open_with_dataset(dataset_with(
|
||||||
|
market(first, 10.0, 11.5),
|
||||||
|
market(second, 1.2, 0.9),
|
||||||
candidate(first),
|
candidate(first),
|
||||||
one_yuan_candidate(second),
|
one_yuan_candidate(second),
|
||||||
));
|
));
|
||||||
|
|
||||||
assert_next_open_canceled_with_reason(&result, "one_yuan");
|
assert_eq!(result.fills.len(), 1);
|
||||||
|
assert_eq!(result.fills[0].date, second);
|
||||||
|
assert_eq!(result.fills[0].price, 1.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -28,6 +28,36 @@ pub struct Output {
|
|||||||
pub value: f64,
|
pub value: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every date ranks the same frozen research universe; unknown inputs invalidate the whole date.
|
||||||
|
pub fn rank_history(
|
||||||
|
dates: &[chrono::NaiveDate], universe: &[String], values: &BTreeMap<String, Vec<Option<f64>>>,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
use serde_json::json;
|
||||||
|
if dates.is_empty() || dates.windows(2).any(|w| w[0] >= w[1]) || universe.len() < 2
|
||||||
|
|| universe.len() > 20_000 || dates.len().saturating_mul(universe.len()) > 2_000_000
|
||||||
|
|| universe.iter().collect::<BTreeSet<_>>().len() != universe.len()
|
||||||
|
|| values.keys().collect::<BTreeSet<_>>() != universe.iter().collect::<BTreeSet<_>>()
|
||||||
|
|| values.values().any(|v| v.len() != dates.len() || v.iter().flatten().any(|v| !v.is_finite())) {
|
||||||
|
return Err("research_rank_history_incomplete_or_invalid_universe".into());
|
||||||
|
}
|
||||||
|
let mut rank = universe.iter().map(|s|(s.clone(),vec![None;dates.len()])).collect::<BTreeMap<_,_>>();
|
||||||
|
let mut percentile = rank.clone();
|
||||||
|
let mut unknown_dates = Vec::new();
|
||||||
|
for (i, date) in dates.iter().enumerate() {
|
||||||
|
let missing = universe.iter().filter(|s|values[*s][i].is_none()).collect::<Vec<_>>();
|
||||||
|
if !missing.is_empty() {
|
||||||
|
unknown_dates.push(json!({"date":date,"missing_count":missing.len(),"missing_symbol_sample":missing.iter().take(20).collect::<Vec<_>>(),"sample_limit":20}));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let observations = universe.iter().map(|s|Observation{symbol:s.clone(),value:values[s][i].unwrap(),industry:None,market_cap:None}).collect::<Vec<_>>();
|
||||||
|
for item in evaluate("RANK", universe, &observations, 0.0)? {rank.get_mut(&item.symbol).unwrap()[i]=Some(item.value);}
|
||||||
|
for item in evaluate("PERCENTILE", universe, &observations, 0.0)? {percentile.get_mut(&item.symbol).unwrap()[i]=Some(item.value);}
|
||||||
|
}
|
||||||
|
Ok(json!({"rank":rank,"percentile":percentile,"unknown_dates":unknown_dates,
|
||||||
|
"universe":universe,"dates":dates,"tie_policy":"average_rank_descending",
|
||||||
|
"membership_policy":"fixed_research_scope_not_historical_index_membership"}))
|
||||||
|
}
|
||||||
|
|
||||||
fn mean(values: &[f64]) -> f64 {
|
fn mean(values: &[f64]) -> f64 {
|
||||||
let base = values[0];
|
let base = values[0];
|
||||||
base + values
|
base + values
|
||||||
@@ -153,6 +183,20 @@ pub fn evaluate(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn historical_ranks_keep_ties_and_unknown_full_cross_sections() {
|
||||||
|
let dates=["2026-09-07","2026-09-08","2026-09-09"].map(|d|d.parse().unwrap());
|
||||||
|
let universe=vec!["A".into(),"B".into(),"C".into()];
|
||||||
|
let values=BTreeMap::from([("A".into(),vec![None,Some(10.0),Some(20.0)]),("B".into(),vec![Some(10.0),Some(10.0),Some(10.0)]),("C".into(),vec![Some(20.0),Some(5.0),Some(15.0)])]);
|
||||||
|
let out=rank_history(&dates,&universe,&values).unwrap();
|
||||||
|
assert_eq!(out["rank"]["A"],serde_json::json!([null,1.5,1.0]));
|
||||||
|
assert_eq!(out["rank"]["C"],serde_json::json!([null,3.0,2.0]));
|
||||||
|
assert_eq!(out["unknown_dates"][0]["missing_count"],1);
|
||||||
|
let earlier=values.iter().map(|(s,v)|(s.clone(),v[..2].to_vec())).collect();
|
||||||
|
let first=rank_history(&dates[..2],&universe,&earlier).unwrap();
|
||||||
|
assert_eq!(&out["rank"]["A"].as_array().unwrap()[..2],first["rank"]["A"].as_array().unwrap());
|
||||||
|
assert!(rank_history(&dates,&universe[..2],&values).is_err());
|
||||||
|
}
|
||||||
fn rows() -> Vec<Observation> {
|
fn rows() -> Vec<Observation> {
|
||||||
[1.0, 3.0, 3.0, 4.0]
|
[1.0, 3.0, 3.0, 4.0]
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ pub enum Expr {
|
|||||||
Operator {
|
Operator {
|
||||||
name: String,
|
name: String,
|
||||||
args: Vec<Expr>,
|
args: Vec<Expr>,
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
window: Option<usize>,
|
window: Option<usize>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -169,6 +169,12 @@ const OPERATORS: &[&str] = &[
|
|||||||
];
|
];
|
||||||
|
|
||||||
pub fn catalog() -> Value {
|
pub fn catalog() -> Value {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let mut implementation = Sha256::new();
|
||||||
|
for file in [include_bytes!("factor_events.rs").as_slice(), include_bytes!("factor_cross_section.rs").as_slice(),
|
||||||
|
include_bytes!("daily_patterns.rs").as_slice(),include_bytes!("market_event_context.rs").as_slice(),
|
||||||
|
include_bytes!("session_events.rs").as_slice(),include_bytes!("pattern_context.rs").as_slice(),TA_REV.as_bytes()] {implementation.update(file);}
|
||||||
|
let implementation_sha256=format!("{:x}",implementation.finalize());
|
||||||
let indicators: Vec<Value> = abstract_api::funcs().map(|f| json!({
|
let indicators: Vec<Value> = abstract_api::funcs().map(|f| json!({
|
||||||
"name":f.name, "group":format!("{:?}",f.group), "description":f.hint,
|
"name":f.name, "group":format!("{:?}",f.group), "description":f.hint,
|
||||||
"inputs":f.inputs.iter().map(|p|json!({"name":p.param_name,"kind":format!("{:?}",p.kind),"flags":p.flags.0})).collect::<Vec<_>>(),
|
"inputs":f.inputs.iter().map(|p|json!({"name":p.param_name,"kind":format!("{:?}",p.kind),"flags":p.flags.0})).collect::<Vec<_>>(),
|
||||||
@@ -176,7 +182,14 @@ pub fn catalog() -> Value {
|
|||||||
"outputs":f.outputs.iter().enumerate().map(|(i,p)|json!({"index":i,"name":p.param_name,"kind":format!("{:?}",p.kind)})).collect::<Vec<_>>(),
|
"outputs":f.outputs.iter().enumerate().map(|(i,p)|json!({"index":i,"name":p.param_name,"kind":format!("{:?}",p.kind)})).collect::<Vec<_>>(),
|
||||||
"unstable_period":format!("{:?}",f.unst_id), "production_eligible":false,
|
"unstable_period":format!("{:?}",f.unst_id), "production_eligible":false,
|
||||||
})).collect();
|
})).collect();
|
||||||
json!({"contract":CONTRACT,"library":{"name":"TA-Lib native Rust","revision":TA_REV,"license":"BSD-3-Clause"},
|
json!({"contract":CONTRACT,"expression_kernel_sha256":implementation_sha256,"library":{"name":"TA-Lib native Rust","revision":TA_REV,"license":"BSD-3-Clause"},
|
||||||
|
"execution_context_contract":crate::pattern_context::CONTRACT,
|
||||||
|
"execution_context_fields":crate::pattern_context::CONTEXT_FIELDS,
|
||||||
|
"market_event_context_contract":crate::market_event_context::CONTRACT,
|
||||||
|
"market_event_kernel_sha256":crate::market_event_context::implementation_sha256(),
|
||||||
|
"market_event_common_fields":crate::market_event_context::COMMON_FIELDS,
|
||||||
|
"market_event_industry_fields":crate::market_event_context::INDUSTRY_FIELDS,
|
||||||
|
"session_events":crate::session_events::EVENTS,"session_event_contract":crate::session_events::CONTRACT,
|
||||||
"indicators":indicators,"operators":OPERATORS,"cross_section_operators":crate::factor_cross_section::OPERATORS,"read_only":true,"live_routing":false,
|
"indicators":indicators,"operators":OPERATORS,"cross_section_operators":crate::factor_cross_section::OPERATORS,"read_only":true,"live_routing":false,
|
||||||
"policies":{"null":"unknown_not_false","warmup":"null_until_full_history","recursive_seed":"frozen_input_start",
|
"policies":{"null":"unknown_not_false","warmup":"null_until_full_history","recursive_seed":"frozen_input_start",
|
||||||
"breakout":"previous_window_excludes_current","boolean":"three_valued_logic","daily_execution":"next_completed_session",
|
"breakout":"previous_window_excludes_current","boolean":"three_valued_logic","daily_execution":"next_completed_session",
|
||||||
|
|||||||
@@ -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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,8 +70,17 @@ impl Instrument {
|
|||||||
|
|
||||||
pub fn is_active_on(&self, date: NaiveDate) -> bool {
|
pub fn is_active_on(&self, date: NaiveDate) -> bool {
|
||||||
self.listed_at.is_none_or(|listed_at| listed_at <= date)
|
self.listed_at.is_none_or(|listed_at| listed_at <= date)
|
||||||
&& !self.is_delisted_before(date)
|
&& !self.is_delisted_on_or_before(date)
|
||||||
&& !(self.status.eq_ignore_ascii_case("inactive") && self.delisted_at.is_none())
|
}
|
||||||
|
|
||||||
|
pub fn dated_market_absence_reason(&self, date: NaiveDate) -> Option<&'static str> {
|
||||||
|
if self.listed_at.is_some_and(|listed| date < listed) {
|
||||||
|
Some("not_yet_listed")
|
||||||
|
} else if self.is_delisted_on_or_before(date) {
|
||||||
|
Some("delisted")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +116,25 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lifecycle_is_dated_and_latest_undated_terminal_status_is_not_historical_evidence() {
|
||||||
|
let mut item = instrument("BJS", 100);
|
||||||
|
let listing = chrono::NaiveDate::from_ymd_opt(2026, 8, 5).unwrap();
|
||||||
|
let removal = chrono::NaiveDate::from_ymd_opt(2026, 9, 10).unwrap();
|
||||||
|
item.listed_at = Some(listing);
|
||||||
|
item.delisted_at = Some(removal);
|
||||||
|
assert_eq!(item.dated_market_absence_reason(listing.pred_opt().unwrap()), Some("not_yet_listed"));
|
||||||
|
assert!(item.is_active_on(listing));
|
||||||
|
assert!(!item.is_active_on(removal));
|
||||||
|
assert_eq!(item.dated_market_absence_reason(removal), Some("delisted"));
|
||||||
|
item.delisted_at = None;
|
||||||
|
for status in ["delisting", "delisted", "inactive", "terminated"] {
|
||||||
|
item.status = status.into();
|
||||||
|
assert!(item.is_active_on(listing));
|
||||||
|
assert_eq!(item.dated_market_absence_reason(listing), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn order_quantity_rules_are_case_insensitive_without_allocating_normalized_boards() {
|
fn order_quantity_rules_are_case_insensitive_without_allocating_normalized_boards() {
|
||||||
let kcb = instrument(" kSh ", 100);
|
let kcb = instrument(" kSh ", 100);
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ pub mod calendar;
|
|||||||
pub mod cost;
|
pub mod cost;
|
||||||
pub mod data;
|
pub mod data;
|
||||||
pub mod daily_patterns;
|
pub mod daily_patterns;
|
||||||
|
pub mod pattern_context;
|
||||||
|
pub mod session_events;
|
||||||
pub mod factor_events;
|
pub mod factor_events;
|
||||||
pub mod factor_cross_section;
|
pub mod factor_cross_section;
|
||||||
|
pub mod market_event_context;
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
pub mod event_bus;
|
pub mod event_bus;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
@@ -22,6 +25,8 @@ pub mod risk_control;
|
|||||||
pub mod rules;
|
pub mod rules;
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
pub mod strategy;
|
pub mod strategy;
|
||||||
|
pub mod holding_policy;
|
||||||
|
pub mod signal_contract;
|
||||||
pub mod strategy_ai;
|
pub mod strategy_ai;
|
||||||
pub mod universe;
|
pub mod universe;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
//! Complete published daily cross sections, independent of trading candidates and accounts.
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
pub const CONTRACT: &str = "fidc_market_event_context_v1";
|
||||||
|
pub fn implementation_sha256() -> String {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
format!("{:x}", Sha256::digest(include_bytes!("market_event_context.rs")))
|
||||||
|
}
|
||||||
|
pub const COMMON_FIELDS: &[&str] = &[
|
||||||
|
"market_breadth", "market_return", "market_limit_up_count", "market_limit_down_count",
|
||||||
|
"market_limit_up_rate", "market_broken_limit_rate", "market_high_board", "market_profit_effect",
|
||||||
|
];
|
||||||
|
pub const INDUSTRY_FIELDS: &[&str] = &[
|
||||||
|
"industry_close", "industry_return_20", "industry_breadth", "industry_rank", "industry_size",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Observation {
|
||||||
|
pub symbol: String,
|
||||||
|
pub industry: Option<String>,
|
||||||
|
pub close: Option<f64>,
|
||||||
|
pub high: Option<f64>,
|
||||||
|
pub previous_close: Option<f64>,
|
||||||
|
pub upper_limit: Option<f64>,
|
||||||
|
pub lower_limit: Option<f64>,
|
||||||
|
pub no_limit: Option<bool>,
|
||||||
|
pub paused: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Day {
|
||||||
|
pub date: NaiveDate,
|
||||||
|
pub universe: Vec<String>,
|
||||||
|
pub rows: Vec<Observation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
pub struct State {
|
||||||
|
pub last_date: Option<NaiveDate>,
|
||||||
|
pub streaks: BTreeMap<String, Option<u32>>,
|
||||||
|
pub limit_ups: BTreeSet<String>,
|
||||||
|
pub industry_history: BTreeMap<String, Vec<f64>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Request {
|
||||||
|
pub days: Vec<Day>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub previous: State,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct OutputDay {
|
||||||
|
pub date: NaiveDate,
|
||||||
|
pub common: BTreeMap<String, Option<f64>>,
|
||||||
|
pub industries: BTreeMap<String, BTreeMap<String, Option<f64>>>,
|
||||||
|
pub members: BTreeMap<String, Option<String>>,
|
||||||
|
pub securities: usize,
|
||||||
|
pub active: usize,
|
||||||
|
pub paused: usize,
|
||||||
|
pub no_limit: usize,
|
||||||
|
pub profit_effect_members: Vec<String>,
|
||||||
|
pub profit_effect_missing: Vec<String>,
|
||||||
|
pub industry_missing: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Output {
|
||||||
|
pub contract: &'static str,
|
||||||
|
pub days: Vec<OutputDay>,
|
||||||
|
pub state: State,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn positive(value: Option<f64>, symbol: &str, field: &str) -> Result<f64, String> {
|
||||||
|
value.filter(|v| v.is_finite() && *v > 0.0)
|
||||||
|
.ok_or_else(|| format!("market_event_input_invalid: {symbol} {field}"))
|
||||||
|
}
|
||||||
|
fn average(values: impl Iterator<Item = f64>, n: usize) -> f64 {
|
||||||
|
values.map(|v| v / n as f64).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn aggregate(request: Request) -> Result<Output, String> {
|
||||||
|
let mut state = request.previous;
|
||||||
|
if request.days.is_empty() || request.days.len() > 30
|
||||||
|
|| request.days.iter().map(|d| d.rows.len()).sum::<usize>() > 60_000
|
||||||
|
|| state.streaks.len() > 20_000 || state.limit_ups.len() > 20_000
|
||||||
|
|| state.industry_history.len() > 2000
|
||||||
|
|| state.industry_history.values().any(|v| v.is_empty() || v.len() > 21
|
||||||
|
|| v.iter().any(|x| !x.is_finite() || *x <= 0.0))
|
||||||
|
|| state.last_date.is_none() && (!state.streaks.is_empty() || !state.limit_ups.is_empty() || !state.industry_history.is_empty()) {
|
||||||
|
return Err("market_event_history_budget_or_state_invalid".into());
|
||||||
|
}
|
||||||
|
let mut output = Vec::new();
|
||||||
|
for day in request.days {
|
||||||
|
if state.last_date.is_some_and(|d| d >= day.date)
|
||||||
|
|| day.universe.is_empty() || day.universe.len() > 20_000
|
||||||
|
|| day.universe.iter().collect::<BTreeSet<_>>().len() != day.universe.len()
|
||||||
|
|| day.rows.len() != day.universe.len()
|
||||||
|
|| day.rows.iter().map(|r| &r.symbol).collect::<BTreeSet<_>>() != day.universe.iter().collect::<BTreeSet<_>>() {
|
||||||
|
return Err(format!("market_event_incomplete_cross_section: {}", day.date));
|
||||||
|
}
|
||||||
|
let mut returns = BTreeMap::new();
|
||||||
|
let mut groups: BTreeMap<String, Vec<f64>> = BTreeMap::new();
|
||||||
|
let mut members = BTreeMap::new();
|
||||||
|
let mut streaks = BTreeMap::new();
|
||||||
|
let mut ups = BTreeSet::new();
|
||||||
|
let mut downs = 0; let mut touched = 0; let mut broken = 0; let mut paused = 0; let mut unlimited = 0;
|
||||||
|
for row in &day.rows {
|
||||||
|
let industry = row.industry.clone().filter(|s| !s.trim().is_empty());
|
||||||
|
members.insert(row.symbol.clone(), industry.clone());
|
||||||
|
match row.paused {
|
||||||
|
Some(true) => {
|
||||||
|
paused += 1;
|
||||||
|
streaks.insert(row.symbol.clone(), state.streaks.get(&row.symbol).copied().flatten());
|
||||||
|
continue;
|
||||||
|
},
|
||||||
|
Some(false) => {},
|
||||||
|
None => return Err(format!("market_event_pause_state_missing: {} {}", day.date, row.symbol)),
|
||||||
|
}
|
||||||
|
let c = positive(row.close, &row.symbol, "close")?;
|
||||||
|
let h = positive(row.high, &row.symbol, "high")?;
|
||||||
|
let p = positive(row.previous_close, &row.symbol, "previous_close")?;
|
||||||
|
if h + 1e-8 < c { return Err(format!("market_event_high_below_close: {}", row.symbol)); }
|
||||||
|
let change = c / p - 1.0;
|
||||||
|
returns.insert(row.symbol.clone(), change);
|
||||||
|
if let Some(industry) = industry { groups.entry(industry).or_default().push(change); }
|
||||||
|
let is_up = match row.no_limit {
|
||||||
|
Some(true) => { unlimited += 1; false },
|
||||||
|
Some(false) => {
|
||||||
|
let upper = positive(row.upper_limit, &row.symbol, "upper_limit")?;
|
||||||
|
let lower = positive(row.lower_limit, &row.symbol, "lower_limit")?;
|
||||||
|
if lower >= upper || c > upper + 1e-8 || c < lower - 1e-8 {
|
||||||
|
return Err(format!("market_event_limit_bounds_invalid: {} {}", day.date, row.symbol));
|
||||||
|
}
|
||||||
|
let at_up = (c - upper).abs() <= 1e-8;
|
||||||
|
if (c - lower).abs() <= 1e-8 { downs += 1; }
|
||||||
|
if h >= upper - 1e-8 { touched += 1; if !at_up { broken += 1; } }
|
||||||
|
at_up
|
||||||
|
},
|
||||||
|
None => return Err(format!("market_event_limit_policy_missing: {}", row.symbol)),
|
||||||
|
};
|
||||||
|
if is_up {
|
||||||
|
ups.insert(row.symbol.clone());
|
||||||
|
// The first observed limit-up may already be a continuing streak.
|
||||||
|
streaks.insert(row.symbol.clone(), state.streaks.get(&row.symbol).copied().flatten().map(|v| v + 1));
|
||||||
|
} else { streaks.insert(row.symbol.clone(), Some(0)); }
|
||||||
|
}
|
||||||
|
let active = returns.len();
|
||||||
|
if active == 0 { return Err(format!("market_event_no_active_market: {}", day.date)); }
|
||||||
|
let previous_ups = state.limit_ups.iter().cloned().collect::<Vec<_>>();
|
||||||
|
let profit_missing = previous_ups.iter().filter(|s| !returns.contains_key(*s)).cloned().collect::<Vec<_>>();
|
||||||
|
let profit = if previous_ups.is_empty() || !profit_missing.is_empty() { None }
|
||||||
|
else { Some(average(previous_ups.iter().map(|s| returns[s]), previous_ups.len())) };
|
||||||
|
let board = if ups.iter().any(|s| streaks[s].is_none()) { None }
|
||||||
|
else { Some(ups.iter().map(|s| streaks[s].unwrap()).max().unwrap_or(0) as f64) };
|
||||||
|
let common = BTreeMap::from([
|
||||||
|
("market_breadth".into(), Some(returns.values().filter(|v| **v > 0.0).count() as f64 / active as f64)),
|
||||||
|
("market_return".into(), Some(average(returns.values().copied(), active))),
|
||||||
|
("market_limit_up_count".into(), Some(ups.len() as f64)),
|
||||||
|
("market_limit_down_count".into(), Some(downs as f64)),
|
||||||
|
("market_limit_up_rate".into(), (touched > 0).then(|| ups.len() as f64 / touched as f64)),
|
||||||
|
("market_broken_limit_rate".into(), (touched > 0).then(|| broken as f64 / touched as f64)),
|
||||||
|
("market_high_board".into(), board),
|
||||||
|
("market_profit_effect".into(), profit),
|
||||||
|
]);
|
||||||
|
let mut industries = BTreeMap::new();
|
||||||
|
// A disappeared group breaks its continuous history; no stale NAV is carried forward.
|
||||||
|
state.industry_history.retain(|key, _| groups.contains_key(key));
|
||||||
|
for (industry, values) in groups {
|
||||||
|
let history = state.industry_history.entry(industry.clone()).or_default();
|
||||||
|
let nav = history.last().copied().unwrap_or(1.0) * (1.0 + average(values.iter().copied(), values.len()));
|
||||||
|
history.push(nav);
|
||||||
|
if history.len() > 21 { history.remove(0); }
|
||||||
|
let momentum = (history.len() == 21).then(|| nav / history[0] - 1.0);
|
||||||
|
industries.insert(industry, BTreeMap::from([
|
||||||
|
("industry_close".into(), Some(nav)), ("industry_return_20".into(), momentum),
|
||||||
|
("industry_breadth".into(), Some(values.iter().filter(|v| **v > 0.0).count() as f64 / values.len() as f64)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
let universe = industries.keys().cloned().collect::<Vec<_>>();
|
||||||
|
let known = industries.values().all(|g| g["industry_return_20"].is_some());
|
||||||
|
let ranks = if known && !universe.is_empty() {
|
||||||
|
crate::factor_cross_section::evaluate("RANK", &universe, &industries.iter().map(|(s,g)|
|
||||||
|
crate::factor_cross_section::Observation {symbol:s.clone(), value:g["industry_return_20"].unwrap(),industry:None,market_cap:None}).collect::<Vec<_>>(),0.0)?
|
||||||
|
.into_iter().map(|r|(r.symbol,r.value)).collect::<BTreeMap<_,_>>()
|
||||||
|
} else { BTreeMap::new() };
|
||||||
|
for (name, fields) in &mut industries {
|
||||||
|
fields.insert("industry_rank".into(), ranks.get(name).copied());
|
||||||
|
fields.insert("industry_size".into(), Some(universe.len() as f64));
|
||||||
|
}
|
||||||
|
let industry_missing=members.iter().filter(|(_,group)|group.is_none()).map(|(s,_)|s.clone()).collect::<Vec<_>>();
|
||||||
|
if !industry_missing.is_empty() {
|
||||||
|
// An unclassified member may belong to any group; never silently shrink a group.
|
||||||
|
state.industry_history.clear();
|
||||||
|
for fields in industries.values_mut() { for value in fields.values_mut() { *value=None; } }
|
||||||
|
}
|
||||||
|
output.push(OutputDay { date:day.date, common, industries, members, securities:day.rows.len(), active, paused,
|
||||||
|
no_limit:unlimited, profit_effect_members:previous_ups, profit_effect_missing:profit_missing, industry_missing });
|
||||||
|
state.last_date = Some(day.date); state.streaks = streaks; state.limit_ups = ups;
|
||||||
|
}
|
||||||
|
Ok(Output {contract:CONTRACT, days:output, state})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
fn day(n: u32, up: bool) -> Day {
|
||||||
|
Day {date:NaiveDate::from_ymd_opt(2026,9,n).unwrap(), universe:vec!["A".into(),"B".into()], rows:vec![
|
||||||
|
Observation{symbol:"A".into(),industry:Some("I".into()),close:Some(if up {11.0}else{10.0}),high:Some(11.0),previous_close:Some(10.0),upper_limit:Some(11.0),lower_limit:Some(9.0),no_limit:Some(false),paused:Some(false)},
|
||||||
|
Observation{symbol:"B".into(),industry:Some("J".into()),close:Some(9.0),high:Some(10.0),previous_close:Some(10.0),upper_limit:Some(11.0),lower_limit:Some(9.0),no_limit:Some(false),paused:Some(false)}]}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn formulas_use_real_limits_and_full_denominators() {
|
||||||
|
let r=aggregate(Request{days:vec![day(1,false),day(2,true),day(3,true)],previous:State::default()}).unwrap();
|
||||||
|
let d=&r.days[1];
|
||||||
|
assert_eq!(d.common["market_breadth"],Some(0.5));
|
||||||
|
assert_eq!(d.common["market_limit_down_count"],Some(1.0));
|
||||||
|
assert_eq!(d.common["market_limit_up_rate"],Some(1.0));
|
||||||
|
assert_eq!(r.days[0].common["market_limit_up_rate"],Some(0.0));
|
||||||
|
assert_eq!(r.days[0].common["market_broken_limit_rate"],Some(1.0));
|
||||||
|
assert_eq!(r.days[2].common["market_high_board"],Some(2.0));
|
||||||
|
assert!((r.days[2].common["market_profit_effect"].unwrap()-0.1).abs()<1e-12);
|
||||||
|
assert_eq!(r.days[0].common["market_profit_effect"],None);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn missing_duplicate_and_unproven_limit_states_fail() {
|
||||||
|
let mut d=day(1,true);d.rows.pop();assert!(aggregate(Request{days:vec![d],previous:State::default()}).is_err());
|
||||||
|
let mut d=day(1,true);d.rows[0].upper_limit=None;assert!(aggregate(Request{days:vec![d],previous:State::default()}).is_err());
|
||||||
|
let mut d=day(1,true);d.rows[0].no_limit=Some(true);d.rows[0].upper_limit=None;
|
||||||
|
assert_eq!(aggregate(Request{days:vec![d],previous:State::default()}).unwrap().days[0].no_limit,1);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn chunking_and_future_append_preserve_history() {
|
||||||
|
let first=aggregate(Request{days:vec![day(1,false),day(2,true)],previous:State::default()}).unwrap();
|
||||||
|
let next=aggregate(Request{days:vec![day(3,true)],previous:first.state}).unwrap();
|
||||||
|
let full=aggregate(Request{days:vec![day(1,false),day(2,true),day(3,true)],previous:State::default()}).unwrap();
|
||||||
|
assert_eq!(serde_json::to_value(&first.days).unwrap(),serde_json::to_value(&full.days[..2]).unwrap());
|
||||||
|
assert_eq!(serde_json::to_value(&next.days).unwrap(),serde_json::to_value(&full.days[2..]).unwrap());
|
||||||
|
let unknown=aggregate(Request{days:vec![day(1,true)],previous:State::default()}).unwrap();
|
||||||
|
assert_eq!(unknown.days[0].common["market_high_board"],None);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn missing_industry_does_not_invent_groups_or_disable_independent_market_facts() {
|
||||||
|
let mut missing=day(2,true);missing.rows[0].industry=None;
|
||||||
|
let r=aggregate(Request{days:vec![day(1,false),missing,day(3,true)],previous:State::default()}).unwrap();
|
||||||
|
assert_eq!(r.days[1].common["market_breadth"],Some(0.5));
|
||||||
|
assert_eq!(r.days[1].industry_missing,vec!["A"]);
|
||||||
|
assert!(r.days[1].industries.values().flat_map(|g|g.values()).all(Option::is_none));
|
||||||
|
assert_eq!(r.days[2].industries["I"]["industry_return_20"],None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
//! Explicit reference identities and frozen rank universes shared by all daily runtimes.
|
||||||
|
use crate::{
|
||||||
|
daily_patterns::{dataset_series, evaluate_with_context, PatternSpec, ResearchContext},
|
||||||
|
factor_events::{field_dependencies, Expr},
|
||||||
|
DataSet,
|
||||||
|
};
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
pub const CONTRACT: &str = "fidc_pattern_execution_context_v1";
|
||||||
|
pub const CONTEXT_FIELDS: &[&str] = &[
|
||||||
|
"index_open",
|
||||||
|
"index_high",
|
||||||
|
"index_low",
|
||||||
|
"index_close",
|
||||||
|
"scope_rank",
|
||||||
|
"scope_percentile",
|
||||||
|
"scope_size",
|
||||||
|
];
|
||||||
|
const STOCK_FIELDS: &[&str] = &[
|
||||||
|
"open",
|
||||||
|
"high",
|
||||||
|
"low",
|
||||||
|
"close",
|
||||||
|
"volume",
|
||||||
|
"raw_open",
|
||||||
|
"raw_high",
|
||||||
|
"raw_low",
|
||||||
|
"raw_close",
|
||||||
|
"prev_close",
|
||||||
|
"amount",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ExecutionContext {
|
||||||
|
pub contract: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub benchmark: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rank_expression: Option<Expr>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub rank_universe: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_symbol(s: &str) -> bool {
|
||||||
|
let Some((code, market)) = s.split_once('.') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
code.len() == 6
|
||||||
|
&& code.bytes().all(|c| c.is_ascii_digit())
|
||||||
|
&& matches!(market, "SH" | "SZ" | "BJ" | "CSI")
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExecutionContext {
|
||||||
|
pub fn fields(&self, expression: &Expr) -> BTreeSet<String> {
|
||||||
|
let mut fields = field_dependencies(expression);
|
||||||
|
if let Some(rank) = &self.rank_expression {
|
||||||
|
fields.extend(field_dependencies(rank));
|
||||||
|
}
|
||||||
|
fields
|
||||||
|
}
|
||||||
|
pub fn validate(&self, expression: &Expr) -> Result<(), String> {
|
||||||
|
if self.contract != CONTRACT {
|
||||||
|
return Err("pattern_context_contract_invalid".into());
|
||||||
|
}
|
||||||
|
let needed = field_dependencies(expression);
|
||||||
|
let ranked = needed.iter().any(|f| f.starts_with("scope_"));
|
||||||
|
if ranked != self.rank_expression.is_some() || !ranked && !self.rank_universe.is_empty() {
|
||||||
|
return Err("pattern_rank_expression_and_universe_required".into());
|
||||||
|
}
|
||||||
|
if ranked
|
||||||
|
&& (self.rank_universe.len() < 2
|
||||||
|
|| self.rank_universe.len() > 20_000
|
||||||
|
|| self.rank_universe.iter().any(|s| !valid_symbol(s))
|
||||||
|
|| self.rank_universe.iter().collect::<BTreeSet<_>>().len()
|
||||||
|
!= self.rank_universe.len())
|
||||||
|
{
|
||||||
|
return Err("pattern_rank_universe_invalid".into());
|
||||||
|
}
|
||||||
|
if let Some(rank) = &self.rank_expression {
|
||||||
|
let fields = field_dependencies(rank);
|
||||||
|
if fields
|
||||||
|
.iter()
|
||||||
|
.any(|f| !STOCK_FIELDS.contains(&f.as_str()) && !f.starts_with("index_"))
|
||||||
|
{
|
||||||
|
return Err("pattern_rank_expression_invalid_or_recursive".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let fields = self.fields(expression);
|
||||||
|
if fields
|
||||||
|
.iter()
|
||||||
|
.any(|f| !STOCK_FIELDS.contains(&f.as_str()) && !CONTEXT_FIELDS.contains(&f.as_str()))
|
||||||
|
{
|
||||||
|
return Err("pattern_context_unmapped_field".into());
|
||||||
|
}
|
||||||
|
let index = fields.iter().any(|f| f.starts_with("index_"));
|
||||||
|
if index != self.benchmark.is_some()
|
||||||
|
|| self
|
||||||
|
.benchmark
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|s| !valid_symbol(s) || s.ends_with(".BJ"))
|
||||||
|
{
|
||||||
|
return Err("pattern_reference_index_required".into());
|
||||||
|
}
|
||||||
|
if !index && !ranked {
|
||||||
|
return Err("pattern_unused_context".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_dataset_context(
|
||||||
|
spec: &PatternSpec,
|
||||||
|
data: &DataSet,
|
||||||
|
date: NaiveDate,
|
||||||
|
) -> Result<ResearchContext, String> {
|
||||||
|
let Some(config) = &spec.execution_context else {
|
||||||
|
return Ok(ResearchContext::default());
|
||||||
|
};
|
||||||
|
config.validate(
|
||||||
|
spec.expression
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("pattern_context_requires_expression")?,
|
||||||
|
)?;
|
||||||
|
let days = data.calendar().trailing_days(date, spec.history_len());
|
||||||
|
if days.len() != spec.history_len() || days.last() != Some(&date) {
|
||||||
|
return Err("pattern_context_calendar_incomplete".into());
|
||||||
|
}
|
||||||
|
let needed = config.fields(spec.expression.as_ref().unwrap());
|
||||||
|
let mut context = ResearchContext::default();
|
||||||
|
if let Some(symbol) = &config.benchmark {
|
||||||
|
for name in needed.iter().filter(|f| f.starts_with("index_")) {
|
||||||
|
let values = days
|
||||||
|
.iter()
|
||||||
|
.map(|d| {
|
||||||
|
let value = if let Some(b) = data.market(*d, symbol) {
|
||||||
|
match name.as_str() {
|
||||||
|
"index_open" => Some(b.open),
|
||||||
|
"index_high" => Some(b.high),
|
||||||
|
"index_low" => Some(b.low),
|
||||||
|
"index_close" => Some(b.close),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
} else if let Some(b) = data.benchmark(*d).filter(|b| &b.benchmark == symbol) {
|
||||||
|
match name.as_str() {
|
||||||
|
"index_open" => Some(b.open),
|
||||||
|
"index_close" => Some(b.close),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
value
|
||||||
|
.filter(|v| v.is_finite() && *v > 0.0)
|
||||||
|
.map(Some)
|
||||||
|
.ok_or_else(|| format!("pattern_reference_missing: {symbol} {d} {name}"))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
context.common.insert(name.clone(), values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(expression) = &config.rank_expression {
|
||||||
|
let mut input = spec.clone();
|
||||||
|
input.execution_context = None;
|
||||||
|
input.expression = Some(expression.clone());
|
||||||
|
let mut values = BTreeMap::new();
|
||||||
|
for symbol in &config.rank_universe {
|
||||||
|
let row = evaluate_with_context(
|
||||||
|
&input,
|
||||||
|
&days,
|
||||||
|
&dataset_series(data, &days, symbol),
|
||||||
|
&context.common,
|
||||||
|
true,
|
||||||
|
)?;
|
||||||
|
if let Some(reason) = row.exclusion {
|
||||||
|
return Err(format!("pattern_rank_member_incomplete: {symbol} {reason}"));
|
||||||
|
}
|
||||||
|
values.insert(
|
||||||
|
symbol.clone(),
|
||||||
|
serde_json::from_value::<Vec<Option<f64>>>(
|
||||||
|
row.values["expression"]["values"].clone(),
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let ranks =
|
||||||
|
crate::factor_cross_section::rank_history(&days, &config.rank_universe, &values)?;
|
||||||
|
for symbol in &config.rank_universe {
|
||||||
|
let decode = |value: &Value| {
|
||||||
|
serde_json::from_value::<Vec<Option<f64>>>(value.clone()).map_err(|e| e.to_string())
|
||||||
|
};
|
||||||
|
context.by_symbol.insert(
|
||||||
|
symbol.clone(),
|
||||||
|
BTreeMap::from([
|
||||||
|
("scope_rank".into(), decode(&ranks["rank"][symbol])?),
|
||||||
|
(
|
||||||
|
"scope_percentile".into(),
|
||||||
|
decode(&ranks["percentile"][symbol])?,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"scope_size".into(),
|
||||||
|
vec![Some(config.rank_universe.len() as f64); days.len()],
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn specs_in_value(value: &Value) -> Result<Vec<PatternSpec>, String> {
|
||||||
|
let mut specs = Vec::new();
|
||||||
|
match value {
|
||||||
|
Value::String(text) => specs.extend(crate::daily_patterns::expression_specs(text)?),
|
||||||
|
Value::Array(items) => {
|
||||||
|
for v in items {
|
||||||
|
specs.extend(specs_in_value(v)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Object(items) => {
|
||||||
|
for v in items.values() {
|
||||||
|
specs.extend(specs_in_value(v)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(specs)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn required_symbols(value: &Value) -> Result<(BTreeSet<String>, BTreeSet<String>), String> {
|
||||||
|
let (mut indices, mut stocks) = (BTreeSet::new(), BTreeSet::new());
|
||||||
|
for spec in specs_in_value(value)? {
|
||||||
|
if let Some(context) = spec.execution_context {
|
||||||
|
if let Some(index) = context.benchmark {
|
||||||
|
indices.insert(index);
|
||||||
|
}
|
||||||
|
stocks.extend(context.rank_universe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((indices, stocks))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{BenchmarkSnapshot, DailyFactorSnapshot, DailyMarketSnapshot, Instrument};
|
||||||
|
use serde_json::json;
|
||||||
|
#[test]
|
||||||
|
fn normalized_rule_does_not_turn_an_omitted_window_into_explicit_null() {
|
||||||
|
let expression:Expr=serde_json::from_value(json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":1}]})).unwrap();
|
||||||
|
assert!(serde_json::to_value(expression).unwrap().get("window").is_none());
|
||||||
|
}
|
||||||
|
fn data(future: bool, reference: bool) -> DataSet {
|
||||||
|
let mut days = vec![
|
||||||
|
NaiveDate::from_ymd_opt(2026, 9, 4).unwrap(),
|
||||||
|
NaiveDate::from_ymd_opt(2026, 9, 7).unwrap(),
|
||||||
|
NaiveDate::from_ymd_opt(2026, 9, 8).unwrap(),
|
||||||
|
];
|
||||||
|
if future {
|
||||||
|
days.push(NaiveDate::from_ymd_opt(2026, 9, 9).unwrap());
|
||||||
|
}
|
||||||
|
let symbols = vec!["000001.SZ", "000002.SZ", "000003.SZ"];
|
||||||
|
let mut instruments = symbols
|
||||||
|
.iter()
|
||||||
|
.map(|s| Instrument {
|
||||||
|
symbol: s.to_string(),
|
||||||
|
name: s.to_string(),
|
||||||
|
board: "SZ_MAIN".into(),
|
||||||
|
round_lot: 100,
|
||||||
|
listed_at: None,
|
||||||
|
delisted_at: None,
|
||||||
|
status: "active".into(),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if reference {
|
||||||
|
instruments.push(Instrument {
|
||||||
|
symbol: "399006.SZ".into(),
|
||||||
|
name: "reference".into(),
|
||||||
|
board: "INDEX".into(),
|
||||||
|
round_lot: 1,
|
||||||
|
listed_at: None,
|
||||||
|
delisted_at: None,
|
||||||
|
status: "active".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut market = vec![];
|
||||||
|
let mut factors = vec![];
|
||||||
|
let mut benchmark = vec![];
|
||||||
|
for (i, d) in days.iter().enumerate() {
|
||||||
|
for (n, s) in symbols.iter().enumerate() {
|
||||||
|
let c = [
|
||||||
|
[10., 12., 11., 1000.],
|
||||||
|
[10., 11., 12., 1.],
|
||||||
|
[10., 10., 13., 1.],
|
||||||
|
][n][i];
|
||||||
|
market.push(DailyMarketSnapshot {
|
||||||
|
date: *d,
|
||||||
|
symbol: s.to_string(),
|
||||||
|
timestamp: None,
|
||||||
|
day_open: c,
|
||||||
|
open: c,
|
||||||
|
high: c,
|
||||||
|
low: c,
|
||||||
|
close: c,
|
||||||
|
last_price: c,
|
||||||
|
bid1: c,
|
||||||
|
ask1: c,
|
||||||
|
prev_close: 10.,
|
||||||
|
volume: 100000,
|
||||||
|
minute_volume: 0,
|
||||||
|
bid1_volume: 10000,
|
||||||
|
ask1_volume: 10000,
|
||||||
|
trading_phase: None,
|
||||||
|
paused: false,
|
||||||
|
upper_limit: c * 2.,
|
||||||
|
lower_limit: c / 2.,
|
||||||
|
price_tick: 0.01,
|
||||||
|
});
|
||||||
|
factors.push(DailyFactorSnapshot {
|
||||||
|
date: *d,
|
||||||
|
symbol: s.to_string(),
|
||||||
|
market_cap_bn: 1.,
|
||||||
|
free_float_cap_bn: 1.,
|
||||||
|
pe_ttm: 10.,
|
||||||
|
turnover_ratio: None,
|
||||||
|
effective_turnover_ratio: None,
|
||||||
|
adjustment_factor_backward1: Some(1.),
|
||||||
|
extra_factors: Default::default(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if reference {
|
||||||
|
let mut row = market.last().unwrap().clone();
|
||||||
|
row.symbol = "399006.SZ".into();
|
||||||
|
row.open = 30.;
|
||||||
|
row.high = 30.;
|
||||||
|
row.low = 30.;
|
||||||
|
row.close = 30.;
|
||||||
|
market.push(row);
|
||||||
|
}
|
||||||
|
benchmark.push(BenchmarkSnapshot {
|
||||||
|
date: *d,
|
||||||
|
benchmark: "000300.SH".into(),
|
||||||
|
open: 4000.,
|
||||||
|
close: 4000.,
|
||||||
|
prev_close: 4000.,
|
||||||
|
volume: 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
DataSet::from_components(instruments, market, factors, vec![], benchmark).unwrap()
|
||||||
|
}
|
||||||
|
fn spec(rank: bool) -> PatternSpec {
|
||||||
|
let expression = if rank {
|
||||||
|
json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"scope_rank"},{"kind":"number","value":2}]})
|
||||||
|
} else {
|
||||||
|
json!({"kind":"operator","name":"LT","args":[{"kind":"field","name":"index_close"},{"kind":"number","value":100}]})
|
||||||
|
};
|
||||||
|
let context = if rank {
|
||||||
|
json!({"contract":CONTRACT,"rank_expression":{"kind":"operator","name":"PCT_CHANGE","window":1,"args":[{"kind":"field","name":"close"}]},"rank_universe":["000001.SZ","000002.SZ","000003.SZ"]})
|
||||||
|
} else {
|
||||||
|
json!({"contract":CONTRACT,"benchmark":"399006.SZ"})
|
||||||
|
};
|
||||||
|
serde_json::from_value::<PatternSpec>(json!({"template":"expression","parameters":{"history_window":3},"expression":expression,"execution_context":context})).unwrap().validate().unwrap()
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn dataset_rank_is_full_scope_causal_and_equal_to_pure_cross_section() {
|
||||||
|
let spec = spec(true);
|
||||||
|
let date = NaiveDate::from_ymd_opt(2026, 9, 8).unwrap();
|
||||||
|
let original = build_dataset_context(&spec, &data(false, true), date).unwrap();
|
||||||
|
let future = build_dataset_context(&spec, &data(true, true), date).unwrap();
|
||||||
|
assert_eq!(original.by_symbol, future.by_symbol);
|
||||||
|
assert_eq!(original.by_symbol["000001.SZ"]["scope_rank"][2], Some(3.));
|
||||||
|
assert_eq!(original.by_symbol["000002.SZ"]["scope_rank"][2], Some(2.));
|
||||||
|
assert_eq!(original.by_symbol["000003.SZ"]["scope_rank"][2], Some(1.));
|
||||||
|
assert!(
|
||||||
|
crate::daily_patterns::evaluate_dataset(&spec, &data(false, true), date, "000001.SZ")
|
||||||
|
.unwrap()
|
||||||
|
.matched
|
||||||
|
);
|
||||||
|
let mut incomplete = data(false, true).snapshot_components();
|
||||||
|
incomplete.market.retain(|r| r.symbol != "000003.SZ");
|
||||||
|
let broken = DataSet::from_components(
|
||||||
|
incomplete.instruments,
|
||||||
|
incomplete.market,
|
||||||
|
incomplete.factors,
|
||||||
|
incomplete.candidates,
|
||||||
|
incomplete.benchmarks,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(build_dataset_context(&spec, &broken, date).is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn reference_index_never_defaults_to_performance_benchmark() {
|
||||||
|
let spec = spec(false);
|
||||||
|
let date = NaiveDate::from_ymd_opt(2026, 9, 8).unwrap();
|
||||||
|
assert!(
|
||||||
|
crate::daily_patterns::evaluate_dataset(&spec, &data(false, true), date, "000001.SZ")
|
||||||
|
.unwrap()
|
||||||
|
.matched
|
||||||
|
);
|
||||||
|
assert!(build_dataset_context(&spec, &data(false, false), date)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("399006.SZ"));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn runtime_contract_rejects_missing_range_and_recursive_ranks() {
|
||||||
|
let mut missing = spec(true);
|
||||||
|
missing
|
||||||
|
.execution_context
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.rank_universe
|
||||||
|
.clear();
|
||||||
|
assert!(missing.validate().is_err());
|
||||||
|
let mut recursive = spec(true);
|
||||||
|
recursive
|
||||||
|
.execution_context
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.rank_expression = Some(Expr::Field {
|
||||||
|
name: "scope_rank".into(),
|
||||||
|
});
|
||||||
|
assert!(recursive.validate().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ use crate::numeric_expr_vm::{
|
|||||||
Scratch as NumericVmScratch, Value as NumericVmValue, ValueType as NumericVmValueType,
|
Scratch as NumericVmScratch, Value as NumericVmValue, ValueType as NumericVmValueType,
|
||||||
};
|
};
|
||||||
use crate::portfolio::PortfolioState;
|
use crate::portfolio::PortfolioState;
|
||||||
|
use crate::holding_policy::{AutomaticTradeProtection, AutomaticTradePermission, HoldingLifecycleEvidence};
|
||||||
use crate::portfolio_loss::{ClosedPortfolioSession, PortfolioLossConfig, PortfolioLossState};
|
use crate::portfolio_loss::{ClosedPortfolioSession, PortfolioLossConfig, PortfolioLossState};
|
||||||
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit};
|
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit};
|
||||||
use crate::scheduler::{ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler};
|
use crate::scheduler::{ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler};
|
||||||
@@ -525,6 +526,7 @@ pub enum PlatformAccountActionKind {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum PlatformTradeAction {
|
pub enum PlatformTradeAction {
|
||||||
|
ConsumeSignal,
|
||||||
Order {
|
Order {
|
||||||
kind: PlatformExplicitOrderKind,
|
kind: PlatformExplicitOrderKind,
|
||||||
symbol: String,
|
symbol: String,
|
||||||
@@ -607,6 +609,7 @@ pub struct PlatformPositionTargetRule {
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PlatformExprStrategyConfig {
|
pub struct PlatformExprStrategyConfig {
|
||||||
|
pub signal_book: Option<Arc<crate::signal_contract::ValidatedSignalBook>>,
|
||||||
pub strategy_name: String,
|
pub strategy_name: String,
|
||||||
pub market: String,
|
pub market: String,
|
||||||
pub benchmark_symbol: String,
|
pub benchmark_symbol: String,
|
||||||
@@ -655,6 +658,7 @@ pub struct PlatformExprStrategyConfig {
|
|||||||
pub retry_empty_rebalance: bool,
|
pub retry_empty_rebalance: bool,
|
||||||
pub calendar_rebalance_interval: bool,
|
pub calendar_rebalance_interval: bool,
|
||||||
pub max_holding_days: Option<i64>,
|
pub max_holding_days: Option<i64>,
|
||||||
|
pub automatic_trade_protection: AutomaticTradeProtection,
|
||||||
pub weak_market_shrink_overweight_threshold: Option<f64>,
|
pub weak_market_shrink_overweight_threshold: Option<f64>,
|
||||||
pub commission_rate: Option<f64>,
|
pub commission_rate: Option<f64>,
|
||||||
pub minimum_commission: Option<f64>,
|
pub minimum_commission: Option<f64>,
|
||||||
@@ -672,6 +676,7 @@ pub struct PlatformExprStrategyConfig {
|
|||||||
pub completed_session_factor_fields: BTreeSet<String>,
|
pub completed_session_factor_fields: BTreeSet<String>,
|
||||||
pub candidate_symbols_by_date: BTreeMap<NaiveDate, BTreeSet<String>>,
|
pub candidate_symbols_by_date: BTreeMap<NaiveDate, BTreeSet<String>>,
|
||||||
pub intraday_execution_time: Option<NaiveTime>,
|
pub intraday_execution_time: Option<NaiveTime>,
|
||||||
|
pub session_event_times: Vec<NaiveTime>,
|
||||||
pub explicit_action_times: Vec<NaiveTime>,
|
pub explicit_action_times: Vec<NaiveTime>,
|
||||||
pub delayed_limit_open_exit_enabled: bool,
|
pub delayed_limit_open_exit_enabled: bool,
|
||||||
pub delayed_limit_open_exit_time: Option<NaiveTime>,
|
pub delayed_limit_open_exit_time: Option<NaiveTime>,
|
||||||
@@ -688,6 +693,7 @@ pub struct PlatformExprStrategyConfig {
|
|||||||
impl PlatformExprStrategyConfig {
|
impl PlatformExprStrategyConfig {
|
||||||
pub fn generic() -> Self {
|
pub fn generic() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
signal_book: None,
|
||||||
strategy_name: "platform-expression".to_string(),
|
strategy_name: "platform-expression".to_string(),
|
||||||
market: "CN_A".to_string(),
|
market: "CN_A".to_string(),
|
||||||
benchmark_symbol: String::new(),
|
benchmark_symbol: String::new(),
|
||||||
@@ -736,6 +742,7 @@ impl PlatformExprStrategyConfig {
|
|||||||
retry_empty_rebalance: false,
|
retry_empty_rebalance: false,
|
||||||
calendar_rebalance_interval: false,
|
calendar_rebalance_interval: false,
|
||||||
max_holding_days: None,
|
max_holding_days: None,
|
||||||
|
automatic_trade_protection: AutomaticTradeProtection::default(),
|
||||||
weak_market_shrink_overweight_threshold: None,
|
weak_market_shrink_overweight_threshold: None,
|
||||||
commission_rate: None,
|
commission_rate: None,
|
||||||
minimum_commission: None,
|
minimum_commission: None,
|
||||||
@@ -753,6 +760,7 @@ impl PlatformExprStrategyConfig {
|
|||||||
completed_session_factor_fields: BTreeSet::new(),
|
completed_session_factor_fields: BTreeSet::new(),
|
||||||
candidate_symbols_by_date: BTreeMap::new(),
|
candidate_symbols_by_date: BTreeMap::new(),
|
||||||
intraday_execution_time: None,
|
intraday_execution_time: None,
|
||||||
|
session_event_times: Vec::new(),
|
||||||
explicit_action_times: Vec::new(),
|
explicit_action_times: Vec::new(),
|
||||||
delayed_limit_open_exit_enabled: false,
|
delayed_limit_open_exit_enabled: false,
|
||||||
delayed_limit_open_exit_time: None,
|
delayed_limit_open_exit_time: None,
|
||||||
@@ -1287,7 +1295,7 @@ struct RuntimeHelperBinding {
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
enum CompiledRuntimeHelperArgs {
|
enum CompiledRuntimeHelperArgs {
|
||||||
DailyPattern { spec: crate::daily_patterns::PatternSpec },
|
DailyPattern { spec: crate::daily_patterns::PatternSpec, identity: String },
|
||||||
RollingMean {
|
RollingMean {
|
||||||
field: String,
|
field: String,
|
||||||
lookback: usize,
|
lookback: usize,
|
||||||
@@ -1345,8 +1353,17 @@ enum RuntimeHelperResolution {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct PlatformExprStrategy {
|
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_date: RefCell<Option<NaiveDate>>,
|
||||||
pattern_results: RefCell<BTreeMap<(NaiveDate, String, String), crate::daily_patterns::PatternResult>>,
|
pattern_results: RefCell<BTreeMap<(NaiveDate, String, String), crate::daily_patterns::PatternResult>>,
|
||||||
|
pattern_contexts: RefCell<BTreeMap<String,crate::daily_patterns::ResearchContext>>,
|
||||||
|
pattern_specs: RefCell<BTreeMap<String,String>>,
|
||||||
|
pattern_frame_at:RefCell<Option<NaiveDateTime>>,
|
||||||
config: PlatformExprStrategyConfig,
|
config: PlatformExprStrategyConfig,
|
||||||
engine: Engine,
|
engine: Engine,
|
||||||
rebalance_day_counter: usize,
|
rebalance_day_counter: usize,
|
||||||
@@ -1582,13 +1599,7 @@ impl PlatformExprStrategy {
|
|||||||
.filter(|position| position.quantity > 0)
|
.filter(|position| position.quantity > 0)
|
||||||
.filter_map(|position| {
|
.filter_map(|position| {
|
||||||
let instrument = ctx.data.instrument(&position.symbol)?;
|
let instrument = ctx.data.instrument(&position.symbol)?;
|
||||||
let unresolved = instrument.is_delisted_on_or_before(ctx.execution_date)
|
let unresolved = instrument.is_delisted_on_or_before(ctx.execution_date);
|
||||||
|| (instrument.status.eq_ignore_ascii_case("delisted")
|
|
||||||
&& instrument.delisted_at.is_none()
|
|
||||||
&& ctx
|
|
||||||
.data
|
|
||||||
.market(ctx.execution_date, &position.symbol)
|
|
||||||
.is_none());
|
|
||||||
unresolved.then(|| position.symbol.clone())
|
unresolved.then(|| position.symbol.clone())
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -1612,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();
|
let mut engine = Engine::new();
|
||||||
engine.set_fast_operators(false);
|
engine.set_fast_operators(false);
|
||||||
engine.set_fail_on_invalid_map_property(true);
|
engine.set_fail_on_invalid_map_property(true);
|
||||||
@@ -1760,6 +1774,12 @@ impl PlatformExprStrategy {
|
|||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
engine,
|
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,
|
rebalance_day_counter: 0,
|
||||||
last_rebalance_date: None,
|
last_rebalance_date: None,
|
||||||
last_target_selection: None,
|
last_target_selection: None,
|
||||||
@@ -1796,6 +1816,9 @@ impl PlatformExprStrategy {
|
|||||||
stock_extra_factor_map_required,
|
stock_extra_factor_map_required,
|
||||||
stock_text_factors_required,
|
stock_text_factors_required,
|
||||||
pattern_results: RefCell::new(BTreeMap::new()),
|
pattern_results: RefCell::new(BTreeMap::new()),
|
||||||
|
pattern_contexts: RefCell::new(BTreeMap::new()),
|
||||||
|
pattern_specs: RefCell::new(BTreeMap::new()),
|
||||||
|
pattern_frame_at:RefCell::new(None),
|
||||||
pattern_results_date: RefCell::new(None),
|
pattern_results_date: RefCell::new(None),
|
||||||
stock_state_cache_date: RefCell::new(None),
|
stock_state_cache_date: RefCell::new(None),
|
||||||
stock_state_cache_calendar_index: RefCell::new(None),
|
stock_state_cache_calendar_index: RefCell::new(None),
|
||||||
@@ -1911,6 +1934,7 @@ impl PlatformExprStrategy {
|
|||||||
}
|
}
|
||||||
for (index, action) in self.config.explicit_actions.iter().enumerate() {
|
for (index, action) in self.config.explicit_actions.iter().enumerate() {
|
||||||
match action {
|
match action {
|
||||||
|
PlatformTradeAction::ConsumeSignal => {}
|
||||||
PlatformTradeAction::Order {
|
PlatformTradeAction::Order {
|
||||||
amount_expr,
|
amount_expr,
|
||||||
limit_price_expr,
|
limit_price_expr,
|
||||||
@@ -2191,7 +2215,37 @@ impl PlatformExprStrategy {
|
|||||||
}
|
}
|
||||||
matches!(
|
matches!(
|
||||||
name,
|
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_close"
|
||||||
| "benchmark_signal_close"
|
| "benchmark_signal_close"
|
||||||
| "signal_ma5"
|
| "signal_ma5"
|
||||||
@@ -2562,6 +2616,11 @@ impl PlatformExprStrategy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn max_holding_days_exceeded(&self, symbol: &str) -> Option<i64> {
|
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 max_days = self.config.max_holding_days.filter(|value| *value > 0)?;
|
||||||
let holding_days = *self.position_holding_days.get(symbol)?;
|
let holding_days = *self.position_holding_days.get(symbol)?;
|
||||||
(holding_days >= max_days).then_some(holding_days)
|
(holding_days >= max_days).then_some(holding_days)
|
||||||
@@ -3399,6 +3458,9 @@ impl PlatformExprStrategy {
|
|||||||
let position = projected.position(symbol)?;
|
let position = projected.position(symbol)?;
|
||||||
let current_qty = position.quantity;
|
let current_qty = position.quantity;
|
||||||
let sellable_qty = position.sellable_qty(date);
|
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);
|
let quantity = current_qty.min(sellable_qty);
|
||||||
if quantity == 0 {
|
if quantity == 0 {
|
||||||
return None;
|
return None;
|
||||||
@@ -3543,6 +3605,9 @@ impl PlatformExprStrategy {
|
|||||||
let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol);
|
let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol);
|
||||||
let order_step_size = self.projected_order_step_size(ctx, symbol);
|
let order_step_size = self.projected_order_step_size(ctx, symbol);
|
||||||
let sellable_qty = projected.position(symbol)?.sellable_qty(date);
|
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 {
|
if sellable_qty == 0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -3851,16 +3916,9 @@ impl PlatformExprStrategy {
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if !defer_execution_risk
|
if !defer_execution_risk && self.buy_rejection_reason(
|
||||||
&& self
|
ctx, execution_date, symbol, self.stock_state(ctx, execution_date, symbol)?.as_ref(),
|
||||||
.buy_rejection_reason(
|
)?.is_some() {
|
||||||
ctx,
|
|
||||||
execution_date,
|
|
||||||
symbol,
|
|
||||||
self.stock_state(ctx, execution_date, symbol)?.as_ref(),
|
|
||||||
)?
|
|
||||||
.is_some()
|
|
||||||
{
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let decision_stock = self.stock_state_with_factor_date(
|
let decision_stock = self.stock_state_with_factor_date(
|
||||||
@@ -5705,23 +5763,48 @@ impl PlatformExprStrategy {
|
|||||||
args: &CompiledRuntimeHelperArgs,
|
args: &CompiledRuntimeHelperArgs,
|
||||||
) -> Result<RuntimeHelperResolution, BacktestError> {
|
) -> Result<RuntimeHelperResolution, BacktestError> {
|
||||||
match args {
|
match args {
|
||||||
CompiledRuntimeHelperArgs::DailyPattern { spec } => {
|
CompiledRuntimeHelperArgs::DailyPattern { spec, identity } => {
|
||||||
if self.config.matching_type != MatchingType::NextBarOpen {
|
if *self.pattern_results_date.borrow()!=Some(ctx.execution_date) {
|
||||||
|
self.pattern_results.borrow_mut().clear();self.pattern_contexts.borrow_mut().clear();self.pattern_specs.borrow_mut().clear();
|
||||||
|
*self.pattern_results_date.borrow_mut()=Some(ctx.execution_date);
|
||||||
|
}
|
||||||
|
if *self.pattern_frame_at.borrow()!=ctx.active_datetime {
|
||||||
|
self.pattern_results.borrow_mut().clear();*self.pattern_frame_at.borrow_mut()=ctx.active_datetime;
|
||||||
|
}
|
||||||
|
if spec.template=="session_event" {
|
||||||
|
if self.config.matching_type!=MatchingType::MinuteLast {return Err(BacktestError::Execution("session_event_requires_minute_last".into()));}
|
||||||
|
let active=ctx.active_datetime.ok_or_else(||BacktestError::Execution("session_event_requires_explicit_clock".into()))?;
|
||||||
|
let clock=active.time();
|
||||||
|
if !((NaiveTime::from_hms_opt(9,31,0).unwrap()<=clock&&clock<=NaiveTime::from_hms_opt(11,30,0).unwrap())||(NaiveTime::from_hms_opt(13,1,0).unwrap()<=clock&&clock<=NaiveTime::from_hms_opt(15,0,0).unwrap())) {
|
||||||
|
return Ok(if helper=="pattern_signal"{RuntimeHelperResolution::Boolean(false)}else{RuntimeHelperResolution::Number(0.)});
|
||||||
|
}
|
||||||
|
let stock=stock.ok_or_else(||BacktestError::Execution("session_event_requires_stock".into()))?;
|
||||||
|
let key=(active.date(),stock.symbol.to_string(),identity.clone());
|
||||||
|
if !self.pattern_results.borrow().contains_key(&key) {
|
||||||
|
let result=crate::session_events::evaluate(spec,&stock.symbol,ctx.data.completed_minute_bars_on(active.date(),&stock.symbol),active).map_err(BacktestError::Execution)?;
|
||||||
|
self.pattern_results.borrow_mut().insert(key.clone(),result);
|
||||||
|
self.pattern_specs.borrow_mut().insert(identity.clone(),serde_json::to_string(spec).unwrap());
|
||||||
|
}
|
||||||
|
let rows=self.pattern_results.borrow();let result=&rows[&key];
|
||||||
|
return if helper=="pattern_signal" {Ok(RuntimeHelperResolution::Boolean(result.matched))}else{result.score.map(RuntimeHelperResolution::Number).ok_or_else(||BacktestError::Execution("session_score_unknown".into()))};
|
||||||
|
}
|
||||||
|
if !matches!(self.config.matching_type,MatchingType::NextBarOpen|MatchingType::MinuteLast) {
|
||||||
return Err(BacktestError::Execution("daily_pattern_requires_next_bar_open: 完整日线形态只能在下一交易日执行".into()));
|
return Err(BacktestError::Execution("daily_pattern_requires_next_bar_open: 完整日线形态只能在下一交易日执行".into()));
|
||||||
}
|
}
|
||||||
let date = day.date.min(ctx.decision_date);
|
let date = if self.config.matching_type==MatchingType::MinuteLast {ctx.data.previous_trading_date(ctx.execution_date,1).ok_or_else(||BacktestError::Execution("daily_pattern_previous_completed_date_missing".into()))?} else {day.date.min(ctx.decision_date)};
|
||||||
// Lagged replay retains the decision day's schedule label; execution is on a later session.
|
// Lagged replay retains the decision day's schedule label; execution is on a later session.
|
||||||
if !ctx.is_lagged_execution() && ctx.active_datetime.is_some_and(|t| t.date() == date && t.time() < NaiveTime::from_hms_opt(16, 0, 0).unwrap()) {
|
if !ctx.is_lagged_execution() && ctx.active_datetime.is_some_and(|t| t.date() == date && t.time() < NaiveTime::from_hms_opt(16, 0, 0).unwrap()) {
|
||||||
return Err(BacktestError::Execution(format!("daily_pattern_not_yet_visible: 不允许使用未完成的当日日线; decision_date={date}, execution_date={}, active_datetime={:?}",ctx.execution_date,ctx.active_datetime)));
|
return Err(BacktestError::Execution(format!("daily_pattern_not_yet_visible: 不允许使用未完成的当日日线; decision_date={date}, execution_date={}, active_datetime={:?}",ctx.execution_date,ctx.active_datetime)));
|
||||||
}
|
}
|
||||||
let stock = stock.ok_or_else(|| BacktestError::Execution("pattern_signal requires stock context".into()))?;
|
let stock = stock.ok_or_else(|| BacktestError::Execution("pattern_signal requires stock context".into()))?;
|
||||||
let key = (date, stock.symbol.to_string(), serde_json::to_string(spec).unwrap());
|
let key = (date, stock.symbol.to_string(), identity.clone());
|
||||||
if *self.pattern_results_date.borrow() != Some(date) {
|
|
||||||
self.pattern_results.borrow_mut().clear();
|
|
||||||
*self.pattern_results_date.borrow_mut() = Some(date);
|
|
||||||
}
|
|
||||||
if !self.pattern_results.borrow().contains_key(&key) {
|
if !self.pattern_results.borrow().contains_key(&key) {
|
||||||
let result = crate::daily_patterns::evaluate_dataset(spec,ctx.data,date,&stock.symbol).map_err(BacktestError::Execution)?;
|
if !self.pattern_contexts.borrow().contains_key(&key.2) {
|
||||||
|
let context=crate::pattern_context::build_dataset_context(spec,ctx.data,date).map_err(BacktestError::Execution)?;
|
||||||
|
self.pattern_contexts.borrow_mut().insert(key.2.clone(),context);
|
||||||
|
self.pattern_specs.borrow_mut().insert(key.2.clone(),serde_json::to_string(spec).unwrap());
|
||||||
|
}
|
||||||
|
let result = crate::daily_patterns::evaluate_dataset_context(spec,ctx.data,date,&stock.symbol,&self.pattern_contexts.borrow()[&key.2]).map_err(BacktestError::Execution)?;
|
||||||
self.pattern_results.borrow_mut().insert(key.clone(),result);
|
self.pattern_results.borrow_mut().insert(key.clone(),result);
|
||||||
}
|
}
|
||||||
let results = self.pattern_results.borrow();
|
let results = self.pattern_results.borrow();
|
||||||
@@ -6162,7 +6245,8 @@ impl PlatformExprStrategy {
|
|||||||
.filter(|identifier| !normalized_identifiers.contains(*identifier)),
|
.filter(|identifier| !normalized_identifiers.contains(*identifier)),
|
||||||
);
|
);
|
||||||
for identifier in factor_identifiers {
|
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.prelude_declared_identifiers.contains(identifier)
|
||||||
|| (!self.stock_extra_factor_identifiers.contains(identifier)
|
|| (!self.stock_extra_factor_identifiers.contains(identifier)
|
||||||
&& !item.extra_factors.contains_key(identifier.as_str())
|
&& !item.extra_factors.contains_key(identifier.as_str())
|
||||||
@@ -6983,7 +7067,10 @@ impl PlatformExprStrategy {
|
|||||||
"pattern_signal" | "pattern_score" if args.len() == 1 => {
|
"pattern_signal" | "pattern_score" if args.len() == 1 => {
|
||||||
let text: String = serde_json::from_str(&args[0]).ok()?;
|
let text: String = serde_json::from_str(&args[0]).ok()?;
|
||||||
let spec: crate::daily_patterns::PatternSpec = serde_json::from_str(&text).ok()?;
|
let spec: crate::daily_patterns::PatternSpec = serde_json::from_str(&text).ok()?;
|
||||||
Some(CompiledRuntimeHelperArgs::DailyPattern { spec: spec.validate().ok()? })
|
let spec=spec.validate().ok()?;
|
||||||
|
use sha2::Digest;
|
||||||
|
let identity=format!("{:x}",sha2::Sha256::digest(serde_json::to_vec(&spec).ok()?));
|
||||||
|
Some(CompiledRuntimeHelperArgs::DailyPattern { spec, identity })
|
||||||
}
|
}
|
||||||
"rolling_mean" | "sma" | "ma" => {
|
"rolling_mean" | "sma" | "ma" => {
|
||||||
let (field, lookback) = field_lookback()?;
|
let (field, lookback) = field_lookback()?;
|
||||||
@@ -9088,7 +9175,10 @@ impl PlatformExprStrategy {
|
|||||||
self.stock_state(ctx, date, symbol).map(Some)
|
self.stock_state(ctx, date, symbol).map(Some)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn unscheduled_explicit_actions_are_due(&self, decision_date: NaiveDate) -> bool {
|
fn unscheduled_explicit_actions_are_due(&self, decision_date: NaiveDate, execution_date: NaiveDate) -> bool {
|
||||||
|
if let Some(book) = &self.config.signal_book {
|
||||||
|
return book.is_due_on(execution_date);
|
||||||
|
}
|
||||||
self.config.signal_rebalance_dates.is_empty()
|
self.config.signal_rebalance_dates.is_empty()
|
||||||
|| self.config.signal_rebalance_dates.contains(&decision_date)
|
|| self.config.signal_rebalance_dates.contains(&decision_date)
|
||||||
}
|
}
|
||||||
@@ -9116,6 +9206,12 @@ impl PlatformExprStrategy {
|
|||||||
let mut diagnostics = Vec::new();
|
let mut diagnostics = Vec::new();
|
||||||
for action in &self.config.explicit_actions {
|
for action in &self.config.explicit_actions {
|
||||||
match action {
|
match action {
|
||||||
|
PlatformTradeAction::ConsumeSignal => {
|
||||||
|
let book = self.config.signal_book.as_ref().ok_or_else(||
|
||||||
|
BacktestError::Execution("signal_book_not_loaded".into()))?;
|
||||||
|
intents.extend(book.intents(ctx).map_err(BacktestError::Execution)?);
|
||||||
|
diagnostics.push(format!("signal_book_consumed version={} decision_date={}", book.version_sha256(), ctx.decision_date));
|
||||||
|
}
|
||||||
PlatformTradeAction::Order {
|
PlatformTradeAction::Order {
|
||||||
kind,
|
kind,
|
||||||
symbol,
|
symbol,
|
||||||
@@ -10057,7 +10153,7 @@ impl PlatformExprStrategy {
|
|||||||
buy_denials: Default::default(),
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: if self.config.signal_book.is_some() { exit_symbols } else { BTreeSet::new() },
|
||||||
order_intents,
|
order_intents,
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
diagnostics,
|
diagnostics,
|
||||||
@@ -10693,6 +10789,9 @@ impl PlatformExprStrategy {
|
|||||||
symbol: &str,
|
symbol: &str,
|
||||||
execution_time: Option<NaiveTime>,
|
execution_time: Option<NaiveTime>,
|
||||||
) -> bool {
|
) -> 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 {
|
let Some(position) = ctx.portfolio.position(symbol) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
@@ -10732,6 +10831,9 @@ impl PlatformExprStrategy {
|
|||||||
symbol: &str,
|
symbol: &str,
|
||||||
_stock: &StockExpressionState,
|
_stock: &StockExpressionState,
|
||||||
) -> Result<Option<String>, BacktestError> {
|
) -> 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 market = ctx.data.require_market(date, symbol)?;
|
||||||
let candidate = ctx.data.require_candidate(date, symbol)?;
|
let candidate = ctx.data.require_candidate(date, symbol)?;
|
||||||
|
|
||||||
@@ -11362,6 +11464,7 @@ impl PlatformExprStrategy {
|
|||||||
matches!(
|
matches!(
|
||||||
action,
|
action,
|
||||||
PlatformTradeAction::Order { .. }
|
PlatformTradeAction::Order { .. }
|
||||||
|
| PlatformTradeAction::ConsumeSignal
|
||||||
| PlatformTradeAction::TargetPortfolioSmart { .. }
|
| PlatformTradeAction::TargetPortfolioSmart { .. }
|
||||||
| PlatformTradeAction::Modify { .. }
|
| PlatformTradeAction::Modify { .. }
|
||||||
)
|
)
|
||||||
@@ -12190,6 +12293,7 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn before_trading(&mut self, ctx: &StrategyContext<'_>) -> Result<(), BacktestError> {
|
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(()); };
|
let Some(config) = self.config.portfolio_loss_control.clone() else { return Ok(()); };
|
||||||
if ctx.futures_account.is_some() {
|
if ctx.futures_account.is_some() {
|
||||||
return Err(BacktestError::Execution("portfolio loss control currently requires equity-only accounting".to_owned()));
|
return Err(BacktestError::Execution("portfolio loss control currently requires equity-only accounting".to_owned()));
|
||||||
@@ -12260,13 +12364,15 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
.is_some()
|
.is_some()
|
||||||
&& self.config.rotation_enabled
|
&& self.config.rotation_enabled
|
||||||
{
|
{
|
||||||
rules.push(
|
let schedule=self.config.rebalance_schedule.as_ref().expect("checked timed rebalance schedule");
|
||||||
self.config
|
if self.config.session_event_times.is_empty() {
|
||||||
.rebalance_schedule
|
rules.push(schedule.as_schedule_rule(ScheduleStage::OnDay));
|
||||||
.as_ref()
|
} else {
|
||||||
.expect("checked timed rebalance schedule")
|
for time in &self.config.session_event_times {
|
||||||
.as_schedule_rule(ScheduleStage::OnDay),
|
let mut timed=schedule.clone();timed.time_rule=Some(ScheduleTimeRule::physical_time(time.hour(),time.minute()));
|
||||||
);
|
rules.push(timed.as_schedule_rule(ScheduleStage::OnDay));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if self.config.explicit_actions.is_empty() {
|
if self.config.explicit_actions.is_empty() {
|
||||||
return rules;
|
return rules;
|
||||||
@@ -12303,6 +12409,7 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
ctx: &StrategyContext<'_>,
|
ctx: &StrategyContext<'_>,
|
||||||
rule: &ScheduleRule,
|
rule: &ScheduleRule,
|
||||||
) -> Result<StrategyDecision, BacktestError> {
|
) -> Result<StrategyDecision, BacktestError> {
|
||||||
|
self.sync_automatic_trade_protection(ctx)?;
|
||||||
let mut decision = if self.config.explicit_actions.is_empty() {
|
let mut decision = if self.config.explicit_actions.is_empty() {
|
||||||
StrategyDecision::default()
|
StrategyDecision::default()
|
||||||
} else {
|
} else {
|
||||||
@@ -12322,13 +12429,14 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
self.executing_scheduled_rotation = false;
|
self.executing_scheduled_rotation = false;
|
||||||
decision.merge_from(rotation?);
|
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);
|
self.append_pattern_diagnostics(&mut decision);
|
||||||
Ok(decision)
|
Ok(decision)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decision_quote_times(&self) -> Vec<NaiveTime> {
|
fn decision_quote_times(&self) -> Vec<NaiveTime> {
|
||||||
let mut times = BTreeSet::new();
|
let mut times = BTreeSet::new();
|
||||||
|
times.extend(self.config.session_event_times.iter().copied());
|
||||||
if self.uses_intraday_execution_quotes() {
|
if self.uses_intraday_execution_quotes() {
|
||||||
if self.config.explicit_action_times.is_empty() {
|
if self.config.explicit_action_times.is_empty() {
|
||||||
times.insert(self.intraday_execution_start_time());
|
times.insert(self.intraday_execution_start_time());
|
||||||
@@ -12348,6 +12456,7 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
&mut self,
|
&mut self,
|
||||||
ctx: &StrategyContext<'_>,
|
ctx: &StrategyContext<'_>,
|
||||||
) -> Result<BTreeSet<String>, BacktestError> {
|
) -> Result<BTreeSet<String>, BacktestError> {
|
||||||
|
self.sync_automatic_trade_protection(ctx)?;
|
||||||
let mut symbols = ctx
|
let mut symbols = ctx
|
||||||
.portfolio
|
.portfolio
|
||||||
.positions()
|
.positions()
|
||||||
@@ -12367,13 +12476,14 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
&mut self,
|
&mut self,
|
||||||
ctx: &StrategyContext<'_>,
|
ctx: &StrategyContext<'_>,
|
||||||
) -> Result<StrategyDecision, BacktestError> {
|
) -> Result<StrategyDecision, BacktestError> {
|
||||||
|
self.sync_automatic_trade_protection(ctx)?;
|
||||||
if self.config.explicit_action_stage == PlatformExplicitActionStage::OpenAuction
|
if self.config.explicit_action_stage == PlatformExplicitActionStage::OpenAuction
|
||||||
&& !self.config.explicit_actions.is_empty()
|
&& !self.config.explicit_actions.is_empty()
|
||||||
&& self.config.explicit_action_schedule.is_none()
|
&& self.config.explicit_action_schedule.is_none()
|
||||||
&& self.unscheduled_explicit_actions_are_due(ctx.decision_date)
|
&& self.unscheduled_explicit_actions_are_due(ctx.decision_date, ctx.execution_date)
|
||||||
{
|
{
|
||||||
let mut decision = self.explicit_action_decision(ctx)?;
|
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);
|
self.append_pattern_diagnostics(&mut decision);
|
||||||
return Ok(decision);
|
return Ok(decision);
|
||||||
}
|
}
|
||||||
@@ -12381,22 +12491,65 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
|
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
|
||||||
|
self.sync_automatic_trade_protection(ctx)?;
|
||||||
let mut decision = self.compute_day_decision(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);
|
self.append_pattern_diagnostics(&mut decision);
|
||||||
Ok(decision)
|
Ok(decision)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlatformExprStrategy {
|
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> {
|
||||||
if self.config.buy_filter_expr.trim().is_empty() {
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let symbols = decision.potential_buy_symbols(ctx.open_orders);
|
let symbols = decision.potential_buy_symbols(ctx.open_orders);
|
||||||
if symbols.is_empty() {
|
if symbols.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
if let Some(book) = &self.config.signal_book {
|
||||||
|
decision.buy_denials.extend(book.buy_denials(ctx).map_err(BacktestError::Execution)?);
|
||||||
|
}
|
||||||
|
if self.config.buy_filter_expr.trim().is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let day = self.day_state(ctx, ctx.decision_date)?;
|
let day = self.day_state(ctx, ctx.decision_date)?;
|
||||||
let (market_date, _, factor_date) = self.selection_dates(ctx);
|
let (market_date, _, factor_date) = self.selection_dates(ctx);
|
||||||
let execution_time = ctx.active_datetime.filter(|value| value.date() == market_date)
|
let execution_time = ctx.active_datetime.filter(|value| value.date() == market_date)
|
||||||
@@ -12422,16 +12575,118 @@ impl PlatformExprStrategy {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_pattern_diagnostics(&self, decision: &mut StrategyDecision) {
|
fn apply_maximum_holding_target(intent: &mut OrderIntent, expired: &BTreeSet<String>) -> (bool, bool) {
|
||||||
for ((date, symbol, spec), result) in self.pattern_results.borrow().iter() {
|
match intent {
|
||||||
if let Some(evidence) = &result.exclusion {
|
OrderIntent::WithTimeInForce { intent, .. } => Self::apply_maximum_holding_target(intent, expired),
|
||||||
let record = serde_json::json!({"event":"daily_pattern_excluded","date":date,"symbol":symbol,"spec":spec,"evidence":evidence}).to_string();
|
OrderIntent::TargetPortfolioSmart { target_weights, .. } => {
|
||||||
if !decision.diagnostics.contains(&record) { decision.diagnostics.push(record); }
|
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() {
|
||||||
|
if result.values["session_contract"]==crate::session_events::CONTRACT {
|
||||||
|
let record=serde_json::json!({"event":"session_event_decision","date":date,"symbol":symbol,"spec_sha256":spec,"matched":result.matched,"signal_bar_end":result.values["signal_bar_end"],"decision_at":result.values["decision_at"],"exclusion":result.exclusion}).to_string();
|
||||||
|
if !decision.diagnostics.contains(&record){decision.diagnostics.push(record)}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if result.values["execution_context_latest"].as_object().is_some_and(|v|!v.is_empty()) {
|
||||||
|
let group=contexts.entry(spec.clone()).or_insert_with(||serde_json::json!({"event":"daily_pattern_context_decisions","date":date,"spec_sha256":spec,"evaluated":0,"matched":0,"excluded":0,"sample_limit":20,"samples":[]}));
|
||||||
|
group["evaluated"]=serde_json::json!(group["evaluated"].as_u64().unwrap()+1);
|
||||||
|
group["matched"]=serde_json::json!(group["matched"].as_u64().unwrap()+u64::from(result.matched));
|
||||||
|
group["excluded"]=serde_json::json!(group["excluded"].as_u64().unwrap()+u64::from(result.exclusion.is_some()));
|
||||||
|
let samples=group["samples"].as_array_mut().unwrap();
|
||||||
|
if samples.len()<20 {samples.push(serde_json::json!({"symbol":symbol,"matched":result.matched,"context":result.values["execution_context_latest"],"exclusion":result.exclusion}));}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(evidence) = &result.exclusion {
|
||||||
|
let record = serde_json::json!({"event":"daily_pattern_excluded","date":date,"symbol":symbol,"spec":self.pattern_specs.borrow().get(spec),"spec_sha256":spec,"evidence":evidence}).to_string();
|
||||||
|
if !decision.diagnostics.contains(&record) { decision.diagnostics.push(record); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for value in contexts.values() {let record=value.to_string();if !decision.diagnostics.contains(&record){decision.diagnostics.push(record);}}
|
||||||
|
}
|
||||||
|
|
||||||
fn compute_day_decision(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
|
fn compute_day_decision(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
|
||||||
|
if self.config.signal_book.is_some() && self.config.explicit_action_schedule.is_some() {
|
||||||
|
return Ok(StrategyDecision::default());
|
||||||
|
}
|
||||||
if self.config.rotation_enabled
|
if self.config.rotation_enabled
|
||||||
&& self
|
&& self
|
||||||
.config
|
.config
|
||||||
@@ -12492,7 +12747,7 @@ impl PlatformExprStrategy {
|
|||||||
let (explicit_action_intents, mut explicit_action_diagnostics) = if !in_skip_window
|
let (explicit_action_intents, mut explicit_action_diagnostics) = if !in_skip_window
|
||||||
&& self.config.explicit_action_stage == PlatformExplicitActionStage::OnDay
|
&& self.config.explicit_action_stage == PlatformExplicitActionStage::OnDay
|
||||||
&& self.config.explicit_action_schedule.is_none()
|
&& self.config.explicit_action_schedule.is_none()
|
||||||
&& self.unscheduled_explicit_actions_are_due(decision_date)
|
&& self.unscheduled_explicit_actions_are_due(decision_date, execution_date)
|
||||||
{
|
{
|
||||||
self.explicit_action_intents(ctx, decision_date, &day)?
|
self.explicit_action_intents(ctx, decision_date, &day)?
|
||||||
} else {
|
} else {
|
||||||
@@ -12890,7 +13145,7 @@ impl PlatformExprStrategy {
|
|||||||
.iter()
|
.iter()
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect::<BTreeSet<_>>();
|
.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)
|
&& let Some(max_holding_days) = self.config.max_holding_days.filter(|value| *value > 0)
|
||||||
{
|
{
|
||||||
for position in ctx.portfolio.positions().values() {
|
for position in ctx.portfolio.positions().values() {
|
||||||
@@ -13964,16 +14219,10 @@ impl PlatformExprStrategy {
|
|||||||
if target_value <= 0.0 {
|
if target_value <= 0.0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if !defer_execution_risk
|
if !defer_execution_risk && let Some(reason) = self.buy_rejection_reason(
|
||||||
&& self
|
ctx, execution_date, symbol, self.stock_state(ctx, execution_date, symbol)?.as_ref(),
|
||||||
.buy_rejection_reason(
|
)? {
|
||||||
ctx,
|
risk_decisions.push(FidcRiskDecisionAudit::rejected_buy_plan(execution_date, symbol, &reason));
|
||||||
execution_date,
|
|
||||||
symbol,
|
|
||||||
self.stock_state(ctx, execution_date, symbol)?.as_ref(),
|
|
||||||
)?
|
|
||||||
.is_some()
|
|
||||||
{
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if !self.stock_passes_expr(ctx, &day, &decision_stock)? {
|
if !self.stock_passes_expr(ctx, &day, &decision_stock)? {
|
||||||
@@ -14250,6 +14499,37 @@ mod tests {
|
|||||||
assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly);
|
assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn periodic_selected_bjse_buy_rejection_is_audited_without_creating_an_order() {
|
||||||
|
let dates = [d(2026, 8, 5), d(2026, 8, 6)];
|
||||||
|
let symbol = "920038.BJ";
|
||||||
|
let data = single_symbol_platform_data(&dates, symbol);
|
||||||
|
let portfolio = PortfolioState::new(100_000.0);
|
||||||
|
let subscriptions = BTreeSet::new();
|
||||||
|
let ctx = StrategyContext {
|
||||||
|
execution_date: dates[1], decision_date: dates[1], decision_index: 1, data: &data,
|
||||||
|
portfolio: &portfolio, futures_account: None, open_orders: &[], dynamic_universe: None,
|
||||||
|
subscriptions: &subscriptions, process_events: &[], active_process_event: None,
|
||||||
|
active_datetime: None, order_events: &[], fills: &[],
|
||||||
|
};
|
||||||
|
let mut cfg = PlatformExprStrategyConfig::generic();
|
||||||
|
cfg.signal_symbol = symbol.into();
|
||||||
|
cfg.stock_filter_expr = "close > 0".into();
|
||||||
|
cfg.hold_until_exit_enabled = true;
|
||||||
|
cfg.target_portfolio_daily_enabled = true;
|
||||||
|
cfg.daily_top_up_enabled = true;
|
||||||
|
cfg.daily_position_target_adjust_enabled = true;
|
||||||
|
cfg.rebalance_existing_positions = true;
|
||||||
|
cfg.risk_config.static_rules.reject_bjse_selection = false;
|
||||||
|
cfg.risk_config.static_rules.reject_bjse_buy = true;
|
||||||
|
let decision = PlatformExprStrategy::new(cfg.clone()).on_day(&ctx).unwrap();
|
||||||
|
assert!(decision.order_intents.is_empty());
|
||||||
|
assert!(decision.risk_decisions.iter().any(|audit| audit.symbol == symbol && audit.stage == "buy_planning" && audit.rule_code == "bjse" && !audit.accepted));
|
||||||
|
cfg.risk_config.static_rules.reject_bjse_buy = false;
|
||||||
|
let allowed = PlatformExprStrategy::new(cfg).on_day(&ctx).unwrap();
|
||||||
|
assert!(!allowed.order_intents.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn daily_pattern_runtime_uses_the_shared_kernel_and_rejects_early_visibility() {
|
fn daily_pattern_runtime_uses_the_shared_kernel_and_rejects_early_visibility() {
|
||||||
let dates=(0..21).map(|n|d(2025,1,1)+chrono::Duration::days(n)).collect::<Vec<_>>();
|
let dates=(0..21).map(|n|d(2025,1,1)+chrono::Duration::days(n)).collect::<Vec<_>>();
|
||||||
@@ -14294,11 +14574,33 @@ mod tests {
|
|||||||
let strategy = PlatformExprStrategy::new(cfg);
|
let strategy = PlatformExprStrategy::new(cfg);
|
||||||
let mut decision = crate::StrategyDecision::default();
|
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() });
|
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!(error.to_string().contains("buy condition quote unavailable"), "{error}");
|
||||||
assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly);
|
assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_uses_previous_bar_and_recomputes_at_each_minute_without_becoming_a_quote() {
|
||||||
|
let date = d(2026,9,8); let symbol = "000001.SZ";
|
||||||
|
let bars = (0..=33).map(|i| {
|
||||||
|
let timestamp = date.and_hms_opt(9,30,0).unwrap() + chrono::Duration::minutes(i);
|
||||||
|
let close = if i==31 {11.} else {10.};
|
||||||
|
crate::session_events::MinuteBar {symbol:symbol.into(),timestamp,available_at:timestamp,open:close,high:close,low:close,close,volume:100.,amount:close*100.}
|
||||||
|
}).collect();
|
||||||
|
let data = single_symbol_platform_data(&[date],symbol).with_completed_minute_bars(bars).unwrap();
|
||||||
|
let portfolio=PortfolioState::new(100_000.); let subscriptions=BTreeSet::new();
|
||||||
|
let mut ctx=StrategyContext {execution_date:date,decision_date:date,decision_index:0,data:&data,portfolio:&portfolio,futures_account:None,open_orders:&[],dynamic_universe:None,subscriptions:&subscriptions,process_events:&[],active_process_event:None,active_datetime:None,order_events:&[],fills:&[]};
|
||||||
|
let mut config=PlatformExprStrategyConfig::generic();config.signal_symbol=symbol.into();config.matching_type=MatchingType::MinuteLast;
|
||||||
|
let strategy=PlatformExprStrategy::new(config);
|
||||||
|
let expression=r#"pattern_signal("{\"template\":\"session_event\",\"session_event\":\"OPENING_RANGE_BREAKOUT_UP\",\"parameters\":{}}")"#;
|
||||||
|
let day=strategy.day_state(&ctx,date).unwrap();let stock=strategy.stock_state(&ctx,date,symbol).unwrap();
|
||||||
|
for (minute,expected) in [(1,false),(2,true),(3,false)] {
|
||||||
|
ctx.active_datetime=date.and_hms_opt(10,minute,0);
|
||||||
|
assert_eq!(strategy.eval_bool(&ctx,expression,&day,Some(&stock),None).unwrap(),expected);
|
||||||
|
}
|
||||||
|
assert!(data.snapshot_components().execution_quotes.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn buy_filter_uses_active_schedule_time_instead_of_first_configured_time() {
|
fn buy_filter_uses_active_schedule_time_instead_of_first_configured_time() {
|
||||||
let date = d(2025, 1, 2);
|
let date = d(2025, 1, 2);
|
||||||
@@ -14328,7 +14630,7 @@ mod tests {
|
|||||||
ctx.active_datetime = Some(date.and_hms_opt(hour, minute, 0).unwrap());
|
ctx.active_datetime = Some(date.and_hms_opt(hour, minute, 0).unwrap());
|
||||||
let mut decision = crate::StrategyDecision::default();
|
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() });
|
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);
|
assert_eq!(decision.buy_denials.contains_key(symbol), denied);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14637,6 +14939,71 @@ mod tests {
|
|||||||
.expect("single-symbol platform dataset")
|
.expect("single-symbol platform dataset")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_signal_reduction_and_stops_use_each_accounts_cost_quantity_and_sold_state() {
|
||||||
|
use crate::{BrokerSimulator, ChinaAShareCostModel, ChinaEquityRuleHooks, PriceField};
|
||||||
|
use crate::signal_contract::{SignalBook, ValidatedSignalBook};
|
||||||
|
use serde_json::json;
|
||||||
|
let previous=d(2025,1,6);
|
||||||
|
let current=d(2025,1,7);
|
||||||
|
let symbol="000001.SZ";
|
||||||
|
let data=single_symbol_platform_data(&[previous,current],symbol);
|
||||||
|
let make_book=|action| -> Arc<ValidatedSignalBook> {
|
||||||
|
let mut book:SignalBook=serde_json::from_value(json!({
|
||||||
|
"schema":"fidc.signal-book/v2","versionSha256":"0".repeat(64),"generatorSha256":"a".repeat(64),
|
||||||
|
"modelSha256":null,"knowledgeCutoff":null,"provenance":"reconstructed","frequency":"daily",
|
||||||
|
"expectedDecisions":["2025-01-07T15:00:00+08:00"],"snapshots":[{
|
||||||
|
"signalAt":"2025-01-06T16:00:00+08:00","decisionAt":"2025-01-07T15:00:00+08:00",
|
||||||
|
"inputAsOf":"2025-01-06T16:00:00+08:00","inputAvailableAt":"2025-01-06T16:00:00+08:00",
|
||||||
|
"generatedAt":"2026-09-11T08:00:00+08:00","publishedAt":"2026-09-11T08:00:00+08:00",
|
||||||
|
"inputSha256":"b".repeat(64),"completeTargets":false,"actions":[action]}]
|
||||||
|
})).unwrap();
|
||||||
|
book.version_sha256=book.content_sha256().unwrap();
|
||||||
|
Arc::new(book.validate().unwrap())
|
||||||
|
};
|
||||||
|
let shared=make_book(json!({"kind":"reduce","symbol":symbol,"remaining_ratio":0.5}));
|
||||||
|
let shared_version=shared.version_sha256().to_owned();
|
||||||
|
let buy=make_book(json!({"kind":"target_weight","symbol":symbol,"weight":0.5}));
|
||||||
|
let subscriptions=BTreeSet::new();
|
||||||
|
let plan=|portfolio:&PortfolioState,book:Arc<ValidatedSignalBook>,stops:bool| {
|
||||||
|
let ctx=StrategyContext {
|
||||||
|
execution_date:current,decision_date:current,decision_index:1,data:&data,portfolio,
|
||||||
|
futures_account:None,open_orders:&[],dynamic_universe:None,subscriptions:&subscriptions,
|
||||||
|
process_events:&[],active_process_event:None,active_datetime:Some(current.and_hms_opt(15,0,0).unwrap()),
|
||||||
|
order_events:&[],fills:&[],
|
||||||
|
};
|
||||||
|
let mut cfg=PlatformExprStrategyConfig::generic();
|
||||||
|
cfg.signal_symbol=symbol.into();
|
||||||
|
cfg.rotation_enabled=false;
|
||||||
|
cfg.signal_book=Some(book);
|
||||||
|
cfg.explicit_actions=vec![PlatformTradeAction::ConsumeSignal];
|
||||||
|
if stops { cfg.stop_loss_expr="0.1".into();cfg.take_profit_expr="0.2".into(); }
|
||||||
|
PlatformExprStrategy::new(cfg).on_day(&ctx).unwrap()
|
||||||
|
};
|
||||||
|
let broker=|| BrokerSimulator::new_with_execution_price(ChinaAShareCostModel::default(),ChinaEquityRuleHooks::default(),PriceField::Close)
|
||||||
|
.with_matching_type(MatchingType::CurrentBarClose).with_volume_limit(false).with_liquidity_limit(false);
|
||||||
|
for (quantity,entry,fees,expected) in [(1000,8.0,0.0,0),(1000,10.0,0.0,500),(3000,10.0,0.0,1500),
|
||||||
|
(1000,12.0,0.0,0),(1000,11.11,0.0,500),(1000,11.11,2.0,0)] {
|
||||||
|
let mut account=PortfolioState::new(100_000.0);
|
||||||
|
account.position_mut(symbol).buy(previous,quantity,entry);
|
||||||
|
account.position_mut(symbol).record_buy_trade_cost(quantity,fees);
|
||||||
|
let decision=plan(&account,shared.clone(),true);
|
||||||
|
assert_eq!(account.position(symbol).unwrap().quantity,quantity);
|
||||||
|
let executor=broker();
|
||||||
|
let report=executor.execute(current,&mut account,&data,&decision).unwrap();
|
||||||
|
assert_eq!(account.position(symbol).map_or(0,|p|p.quantity),expected,"entry={entry} fees={fees} decision={decision:?} report={report:?}");
|
||||||
|
assert!(!report.fill_events.is_empty());
|
||||||
|
let attempted_rebuy=plan(&account,buy.clone(),false);
|
||||||
|
let rejected=executor.execute(current,&mut account,&data,&attempted_rebuy).unwrap();
|
||||||
|
assert!(rejected.fill_events.iter().all(|fill|fill.side!=OrderSide::Buy),"{rejected:?}");
|
||||||
|
}
|
||||||
|
let mut untouched=PortfolioState::new(100_000.0);
|
||||||
|
let allowed=plan(&untouched,buy,false);
|
||||||
|
let result=broker().execute(current,&mut untouched,&data,&allowed).unwrap();
|
||||||
|
assert!(result.fill_events.iter().any(|fill|fill.side==OrderSide::Buy));
|
||||||
|
assert_eq!(shared.version_sha256(),shared_version);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn portfolio_loss_observes_finalized_nav_after_fees_and_cash_flows() {
|
fn portfolio_loss_observes_finalized_nav_after_fees_and_cash_flows() {
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
@@ -25437,9 +25804,9 @@ mod tests {
|
|||||||
}];
|
}];
|
||||||
let mut strategy = PlatformExprStrategy::new(config);
|
let mut strategy = PlatformExprStrategy::new(config);
|
||||||
|
|
||||||
assert!(strategy.unscheduled_explicit_actions_are_due(first));
|
assert!(strategy.unscheduled_explicit_actions_are_due(first, first));
|
||||||
assert!(!strategy.unscheduled_explicit_actions_are_due(between));
|
assert!(!strategy.unscheduled_explicit_actions_are_due(between, between));
|
||||||
assert!(strategy.unscheduled_explicit_actions_are_due(second));
|
assert!(strategy.unscheduled_explicit_actions_are_due(second, second));
|
||||||
|
|
||||||
let mut decide = |date, decision_index| {
|
let mut decide = |date, decision_index| {
|
||||||
let ctx = StrategyContext {
|
let ctx = StrategyContext {
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ use crate::{
|
|||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct StrategyRuntimeSpec {
|
pub struct StrategyRuntimeSpec {
|
||||||
|
#[serde(default)]
|
||||||
|
pub signal_book: Option<crate::signal_contract::SignalBook>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub signal_book_ref: Option<crate::signal_contract::SignalBookReference>,
|
||||||
#[serde(default, alias = "strategy_id")]
|
#[serde(default, alias = "strategy_id")]
|
||||||
pub strategy_id: Option<String>,
|
pub strategy_id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -644,7 +648,7 @@ fn normalize_risk_policy_aliases_in_value(value: &mut Value) -> Result<(), Strin
|
|||||||
/// contract can legitimately arrive with both spellings. Canonicalise those
|
/// contract can legitimately arrive with both spellings. Canonicalise those
|
||||||
/// pairs once at the boundary, while rejecting conflicting values instead of
|
/// pairs once at the boundary, while rejecting conflicting values instead of
|
||||||
/// silently choosing one.
|
/// silently choosing one.
|
||||||
fn normalize_strategy_aliases_in_value(value: &mut Value) -> Result<(), String> {
|
pub fn normalize_strategy_aliases_in_value(value: &mut Value) -> Result<(), String> {
|
||||||
normalize_strategy_aliases_in_value_inner(value, false)
|
normalize_strategy_aliases_in_value_inner(value, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -660,7 +664,7 @@ fn normalize_strategy_aliases_in_value_inner(
|
|||||||
for (key, child) in object.iter_mut() {
|
for (key, child) in object.iter_mut() {
|
||||||
normalize_strategy_aliases_in_value_inner(
|
normalize_strategy_aliases_in_value_inner(
|
||||||
child,
|
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"),
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -675,11 +679,14 @@ fn normalize_strategy_aliases_in_value_inner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STRATEGY_ALIAS_GROUPS: &[(&str, &[&str])] = &[
|
const STRATEGY_ALIAS_GROUPS: &[(&str, &[&str])] = &[
|
||||||
|
("signalBook", &["signal_book"]),
|
||||||
|
("signalBookRef", &["signal_book_ref"]),
|
||||||
("strategyId", &["strategy_id"]),
|
("strategyId", &["strategy_id"]),
|
||||||
("tradeTimes", &["trade_times"]),
|
("tradeTimes", &["trade_times"]),
|
||||||
("signalSymbol", &["signal_symbol"]),
|
("signalSymbol", &["signal_symbol"]),
|
||||||
("engineConfig", &["engine_config"]),
|
("engineConfig", &["engine_config"]),
|
||||||
("runtimeExpressions", &["runtime_expressions"]),
|
("runtimeExpressions", &["runtime_expressions"]),
|
||||||
|
("automaticTradeProtection", &["automatic_trade_protection"]),
|
||||||
("rebalanceSchedule", &["rebalance_schedule"]),
|
("rebalanceSchedule", &["rebalance_schedule"]),
|
||||||
("skipWindows", &["skip_windows"]),
|
("skipWindows", &["skip_windows"]),
|
||||||
("dynamicRange", &["dynamic_range"]),
|
("dynamicRange", &["dynamic_range"]),
|
||||||
@@ -714,10 +721,8 @@ const STRATEGY_ALIAS_GROUPS: &[(&str, &[&str])] = &[
|
|||||||
),
|
),
|
||||||
("stampTaxRateAfterChange", &["stamp_tax_rate_after_change"]),
|
("stampTaxRateAfterChange", &["stamp_tax_rate_after_change"]),
|
||||||
("stampTaxChangeDate", &["stamp_tax_change_date"]),
|
("stampTaxChangeDate", &["stamp_tax_change_date"]),
|
||||||
("volumeLimit", &["volume_limit"]),
|
("volumeLimit", &["volume_limit", "volumeLimitEnabled", "volume_limit_enabled"]),
|
||||||
("volumeLimitEnabled", &["volume_limit_enabled"]),
|
("liquidityLimit", &["liquidity_limit", "liquidityLimitEnabled", "liquidity_limit_enabled"]),
|
||||||
("liquidityLimit", &["liquidity_limit"]),
|
|
||||||
("liquidityLimitEnabled", &["liquidity_limit_enabled"]),
|
|
||||||
("volumePercent", &["volume_percent"]),
|
("volumePercent", &["volume_percent"]),
|
||||||
("riskPolicy", &["risk_policy"]),
|
("riskPolicy", &["risk_policy"]),
|
||||||
("strictValueBudget", &["strict_value_budget"]),
|
("strictValueBudget", &["strict_value_budget"]),
|
||||||
@@ -741,6 +746,16 @@ fn strategy_alias_values_semantically_equal(left: &Value, right: &Value) -> bool
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
match (left, right) {
|
match (left, right) {
|
||||||
|
(Value::Number(left), Value::Number(right)) => {
|
||||||
|
const MAX_EXACT: i64 = 9_007_199_254_740_992;
|
||||||
|
let exact_integer = |value: &serde_json::Number| {
|
||||||
|
value.as_i64().filter(|v| (-MAX_EXACT..=MAX_EXACT).contains(v)).map(|v| v as f64)
|
||||||
|
.or_else(|| value.as_u64().filter(|v| *v <= MAX_EXACT as u64).map(|v| v as f64))
|
||||||
|
};
|
||||||
|
if left.is_f64() && !right.is_f64() { exact_integer(right).zip(left.as_f64()).is_some_and(|(a,b)| a==b) }
|
||||||
|
else if right.is_f64() && !left.is_f64() { exact_integer(left).zip(right.as_f64()).is_some_and(|(a,b)| a==b) }
|
||||||
|
else { false }
|
||||||
|
}
|
||||||
(Value::String(left), Value::String(right)) => left.trim() == right.trim(),
|
(Value::String(left), Value::String(right)) => left.trim() == right.trim(),
|
||||||
(Value::String(left), Value::Number(right))
|
(Value::String(left), Value::Number(right))
|
||||||
| (Value::Number(right), Value::String(left)) => left
|
| (Value::Number(right), Value::String(left)) => left
|
||||||
@@ -990,6 +1005,8 @@ pub struct StrategyExpressionOrderingConfig {
|
|||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct StrategyExpressionTradingConfig {
|
pub struct StrategyExpressionTradingConfig {
|
||||||
|
#[serde(default, alias = "automatic_trade_protection")]
|
||||||
|
pub automatic_trade_protection: Option<crate::holding_policy::AutomaticTradeProtection>,
|
||||||
#[serde(default, alias = "buy_filter_expr")]
|
#[serde(default, alias = "buy_filter_expr")]
|
||||||
pub buy_filter_expr: Option<String>,
|
pub buy_filter_expr: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -2316,6 +2333,10 @@ pub fn platform_expr_config_from_spec(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(trading) = runtime_expr.trading.as_ref() {
|
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() {
|
if let Some(expr) = trading.buy_filter_expr.as_ref() {
|
||||||
cfg.buy_filter_expr = expr.clone();
|
cfg.buy_filter_expr = expr.clone();
|
||||||
}
|
}
|
||||||
@@ -2538,6 +2559,10 @@ pub fn platform_expr_config_from_spec(
|
|||||||
cfg.benchmark_symbol = normalize_symbol(&cfg.benchmark_symbol, None);
|
cfg.benchmark_symbol = normalize_symbol(&cfg.benchmark_symbol, None);
|
||||||
}
|
}
|
||||||
let trade_times = spec_trade_times(spec);
|
let trade_times = spec_trade_times(spec);
|
||||||
|
if crate::pattern_context::specs_in_value(&serde_json::to_value(spec).map_err(|e|e.to_string())?)?.iter().any(|p|p.template=="session_event") {
|
||||||
|
if trade_times.is_empty() {return Err("session_event_requires_explicit_trade_times".into());}
|
||||||
|
cfg.session_event_times=trade_times.clone();
|
||||||
|
}
|
||||||
let explicit_trading_schedule = spec
|
let explicit_trading_schedule = spec
|
||||||
.runtime_expressions
|
.runtime_expressions
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -2595,6 +2620,40 @@ pub fn platform_expr_config_from_spec(
|
|||||||
}
|
}
|
||||||
cfg.strict_value_budget = true;
|
cfg.strict_value_budget = true;
|
||||||
|
|
||||||
|
let signal_book = match (&spec.signal_book,&spec.signal_book_ref) {
|
||||||
|
(Some(_),Some(_)) => return Err("inline_and_registered_signal_book_are_mutually_exclusive".into()),
|
||||||
|
(Some(raw),None) => Some(std::sync::Arc::new(raw.clone().validate()?)),
|
||||||
|
(None,Some(reference)) => crate::signal_contract::cached_signal_book(reference)?,
|
||||||
|
(None,None) => None,
|
||||||
|
};
|
||||||
|
if let Some(book) = signal_book {
|
||||||
|
if cfg.explicit_actions.len() != 1 || !matches!(cfg.explicit_actions[0], PlatformTradeAction::ConsumeSignal) {
|
||||||
|
return Err("signal_book_requires_one_consume_signal_action".into());
|
||||||
|
}
|
||||||
|
if !cfg.signal_rebalance_dates.is_empty() && cfg.signal_rebalance_dates != book.decision_dates() {
|
||||||
|
return Err("signal_book_schedule_does_not_match_strategy".into());
|
||||||
|
}
|
||||||
|
cfg.rotation_enabled = false;
|
||||||
|
cfg.signal_rebalance_dates = book.decision_dates();
|
||||||
|
cfg.initial_subscriptions.extend(book.symbols());
|
||||||
|
cfg.signal_book = Some(book);
|
||||||
|
} else if spec.signal_book_ref.is_some() {
|
||||||
|
if cfg.explicit_actions.len()!=1 || !matches!(cfg.explicit_actions[0],PlatformTradeAction::ConsumeSignal) {
|
||||||
|
return Err("signal_book_requires_one_consume_signal_action".into());
|
||||||
|
}
|
||||||
|
cfg.rotation_enabled=false;
|
||||||
|
} else if cfg.explicit_actions.iter().any(|action| matches!(action, PlatformTradeAction::ConsumeSignal)) {
|
||||||
|
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)
|
Ok(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2747,6 +2806,7 @@ fn parse_platform_trade_action(
|
|||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
match kind.as_str() {
|
match kind.as_str() {
|
||||||
|
"consume_signal" if when_expr.is_none() && time_in_force.is_none() => Some(PlatformTradeAction::ConsumeSignal),
|
||||||
"target_portfolio_smart" => Some(PlatformTradeAction::TargetPortfolioSmart {
|
"target_portfolio_smart" => Some(PlatformTradeAction::TargetPortfolioSmart {
|
||||||
target_weights_expr: action
|
target_weights_expr: action
|
||||||
.target_weights_expr
|
.target_weights_expr
|
||||||
@@ -3145,6 +3205,16 @@ fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn numeric_strategy_aliases_accept_exact_zero_but_never_hide_rounding_or_conflicts() {
|
||||||
|
let cfg = platform_expr_config_from_value("fees", "000001.SZ", &serde_json::json!({
|
||||||
|
"execution":{"minimumCommission":0.0,"minimum_commission":0}
|
||||||
|
})).unwrap();
|
||||||
|
assert_eq!(cfg.minimum_commission, Some(0.0));
|
||||||
|
assert!(!strategy_alias_values_semantically_equal(&serde_json::json!(9007199254740992u64), &serde_json::json!(9007199254740993u64)));
|
||||||
|
assert!(!strategy_alias_values_semantically_equal(&serde_json::json!(0.0), &serde_json::json!(1)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_buy_filter_as_a_separate_trading_condition() {
|
fn parses_buy_filter_as_a_separate_trading_condition() {
|
||||||
let cfg = platform_expr_config_from_value("buy-guard", "000001.SZ", &serde_json::json!({
|
let cfg = platform_expr_config_from_value("buy-guard", "000001.SZ", &serde_json::json!({
|
||||||
@@ -4062,6 +4132,28 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execution_limit_aliases_normalize_to_one_serde_field_without_touching_policy() {
|
||||||
|
for section in ["execution", "engineConfig"] {
|
||||||
|
let mut spec = serde_json::json!({});
|
||||||
|
spec[section] = serde_json::json!({
|
||||||
|
"volumeLimit": false, "volumeLimitEnabled": false, "volume_limit_enabled": false,
|
||||||
|
"liquidityLimit": true, "liquidityLimitEnabled": true, "liquidity_limit_enabled": true,
|
||||||
|
"riskPolicy": {"volumeLimitEnabled": false, "liquidityLimitEnabled": true}
|
||||||
|
});
|
||||||
|
let cfg = platform_expr_config_from_value("test", "000300.SH", &spec).unwrap();
|
||||||
|
assert!(!cfg.risk_config.trading_constraints.volume_limit_enabled);
|
||||||
|
assert!(cfg.risk_config.trading_constraints.liquidity_limit_enabled);
|
||||||
|
super::normalize_strategy_aliases_in_value(&mut spec).unwrap();
|
||||||
|
assert!(spec[section].get("volumeLimitEnabled").is_none());
|
||||||
|
assert!(spec[section].get("liquidity_limit_enabled").is_none());
|
||||||
|
assert_eq!(spec[section]["riskPolicy"]["liquidityLimitEnabled"], true);
|
||||||
|
spec[section]["liquidity_limit_enabled"] = serde_json::json!(false);
|
||||||
|
assert!(platform_expr_config_from_value("test", "000300.SH", &spec)
|
||||||
|
.unwrap_err().to_string().contains("conflicting alias values"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn canonicalizes_duplicate_execution_aliases_without_changing_strategy_intent() {
|
fn canonicalizes_duplicate_execution_aliases_without_changing_strategy_intent() {
|
||||||
let spec = serde_json::json!({
|
let spec = serde_json::json!({
|
||||||
@@ -4458,6 +4550,20 @@ mod tests {
|
|||||||
assert_eq!(cfg.delayed_limit_open_exit_time, None);
|
assert_eq!(cfg.delayed_limit_open_exit_time, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_rotation_keeps_every_declared_clock_not_only_the_last_one() {
|
||||||
|
use crate::Strategy;
|
||||||
|
let literal=serde_json::to_string(&serde_json::json!({"template":"session_event","session_event":"INTRADAY_VOLUME_SPIKE","parameters":{}}).to_string()).unwrap();
|
||||||
|
let mut spec=serde_json::json!({"rebalance":{"tradeTimes":["09:35","10:40","14:59"]},"runtimeExpressions":{"schedule":{"frequency":"daily","time":"14:59"},"trading":{"rotationEnabled":true,"buyFilterExpr":format!("pattern_signal({literal})")}},"execution":{"matchingType":"minute_last"}});
|
||||||
|
let config=platform_expr_config_from_value("session","000300.SH",&spec).unwrap();
|
||||||
|
assert_eq!(config.session_event_times.len(),3);
|
||||||
|
let strategy=crate::PlatformExprStrategy::new(config);
|
||||||
|
assert_eq!(strategy.schedule_rules().len(),3);
|
||||||
|
assert_eq!(strategy.decision_quote_times().len(),3);
|
||||||
|
spec["rebalance"]["tradeTimes"]=serde_json::json!([]);
|
||||||
|
assert!(platform_expr_config_from_value("session","000300.SH",&spec).unwrap_err().to_string().contains("explicit_trade_times"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn explicit_trading_schedule_overrides_rebalance_trade_times() {
|
fn explicit_trading_schedule_overrides_rebalance_trade_times() {
|
||||||
let spec = serde_json::json!({
|
let spec = serde_json::json!({
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ pub struct PositionLot {
|
|||||||
pub struct Position {
|
pub struct Position {
|
||||||
pub symbol: String,
|
pub symbol: String,
|
||||||
pub quantity: u32,
|
pub quantity: u32,
|
||||||
|
opened_date: Option<NaiveDate>,
|
||||||
|
last_buy_date: Option<NaiveDate>,
|
||||||
// ALV-compatible moving average execution price; partial sells do not rebase it.
|
// ALV-compatible moving average execution price; partial sells do not rebase it.
|
||||||
pub average_price: f64,
|
pub average_price: f64,
|
||||||
// ALV-compatible moving average including buy costs; partial sells do not rebase it.
|
// ALV-compatible moving average including buy costs; partial sells do not rebase it.
|
||||||
@@ -88,6 +90,8 @@ impl Position {
|
|||||||
Self {
|
Self {
|
||||||
symbol: symbol.into(),
|
symbol: symbol.into(),
|
||||||
quantity: 0,
|
quantity: 0,
|
||||||
|
opened_date: None,
|
||||||
|
last_buy_date: None,
|
||||||
average_price: 0.0,
|
average_price: 0.0,
|
||||||
average_cost: 0.0,
|
average_cost: 0.0,
|
||||||
last_price: 0.0,
|
last_price: 0.0,
|
||||||
@@ -114,6 +118,12 @@ impl Position {
|
|||||||
self.quantity == 0
|
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) {
|
pub fn buy(&mut self, date: NaiveDate, quantity: u32, price: f64) {
|
||||||
self.buy_with_mark_price(date, quantity, price, price);
|
self.buy_with_mark_price(date, quantity, price, price);
|
||||||
}
|
}
|
||||||
@@ -130,6 +140,10 @@ impl Position {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let previous_quantity = self.quantity;
|
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_price = self.average_price;
|
||||||
let previous_average_cost = self.average_cost;
|
let previous_average_cost = self.average_cost;
|
||||||
let gross_amount = fixed_money_or_panic(
|
let gross_amount = fixed_money_or_panic(
|
||||||
@@ -267,6 +281,7 @@ impl Position {
|
|||||||
.checked_add(total_proceeds)
|
.checked_add(total_proceeds)
|
||||||
.ok_or_else(|| "fixed-point day sell value overflow".to_string())?;
|
.ok_or_else(|| "fixed-point day sell value overflow".to_string())?;
|
||||||
if self.quantity == 0 {
|
if self.quantity == 0 {
|
||||||
|
self.opened_date = None;
|
||||||
self.average_price = 0.0;
|
self.average_price = 0.0;
|
||||||
self.recalculate_average_cost();
|
self.recalculate_average_cost();
|
||||||
} else {
|
} else {
|
||||||
@@ -1047,8 +1062,6 @@ impl PortfolioState {
|
|||||||
let unresolved_delisting = current_market_missing
|
let unresolved_delisting = current_market_missing
|
||||||
&& data.instrument(&position.symbol).is_some_and(|instrument| {
|
&& data.instrument(&position.symbol).is_some_and(|instrument| {
|
||||||
instrument.is_delisted_on_or_before(date)
|
instrument.is_delisted_on_or_before(date)
|
||||||
|| (instrument.status.eq_ignore_ascii_case("delisted")
|
|
||||||
&& instrument.delisted_at.is_none())
|
|
||||||
});
|
});
|
||||||
if unresolved_delisting {
|
if unresolved_delisting {
|
||||||
position.last_price = 0.0;
|
position.last_price = 0.0;
|
||||||
@@ -1068,11 +1081,13 @@ impl PortfolioState {
|
|||||||
position.refresh_day_pnl();
|
position.refresh_day_pnl();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let confirmed_pause = data.market(date, &position.symbol).is_some_and(|row| row.paused)
|
||||||
|
|| data.candidate(date, &position.symbol).is_some_and(|row| row.is_paused);
|
||||||
let price = data
|
let price = data
|
||||||
.price(date, &position.symbol, field)
|
.price(date, &position.symbol, field)
|
||||||
.or_else(|| data.price_on_or_before(date, &position.symbol, field))
|
.or_else(|| confirmed_pause.then(|| data.price_on_or_before(date, &position.symbol, field)).flatten())
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
(position.last_price.is_finite() && position.last_price > 0.0)
|
(confirmed_pause && position.last_price.is_finite() && position.last_price > 0.0)
|
||||||
.then_some(position.last_price)
|
.then_some(position.last_price)
|
||||||
})
|
})
|
||||||
.ok_or_else(|| DataSetError::MissingSnapshot {
|
.ok_or_else(|| DataSetError::MissingSnapshot {
|
||||||
@@ -1224,6 +1239,8 @@ impl PortfolioState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let old_quantity = old_position.quantity;
|
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 last_price = old_position.last_price;
|
||||||
let old_average_price = old_position.average_price;
|
let old_average_price = old_position.average_price;
|
||||||
let old_average_cost = old_position.average_cost;
|
let old_average_cost = old_position.average_cost;
|
||||||
@@ -1263,6 +1280,14 @@ impl PortfolioState {
|
|||||||
.entry(new_symbol.to_string())
|
.entry(new_symbol.to_string())
|
||||||
.or_insert_with(|| Position::new(new_symbol));
|
.or_insert_with(|| Position::new(new_symbol));
|
||||||
let successor_quantity_before = successor.quantity;
|
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_price_before = successor.average_price;
|
||||||
let successor_average_cost_before = successor.average_cost;
|
let successor_average_cost_before = successor.average_cost;
|
||||||
successor.lots.extend(converted_lots);
|
successor.lots.extend(converted_lots);
|
||||||
@@ -1774,7 +1799,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn portfolio_carries_last_price_when_position_market_row_is_missing() {
|
fn portfolio_missing_market_requires_formal_suspension_before_carrying_price() {
|
||||||
let prev_date = NaiveDate::from_ymd_opt(2025, 5, 26).unwrap();
|
let prev_date = NaiveDate::from_ymd_opt(2025, 5, 26).unwrap();
|
||||||
let missing_date = NaiveDate::from_ymd_opt(2025, 5, 27).unwrap();
|
let missing_date = NaiveDate::from_ymd_opt(2025, 5, 27).unwrap();
|
||||||
let mut portfolio = PortfolioState::new(10_000.0);
|
let mut portfolio = PortfolioState::new(10_000.0);
|
||||||
@@ -1832,9 +1857,23 @@ mod tests {
|
|||||||
.update_prices(prev_date, &dataset, PriceField::Close)
|
.update_prices(prev_date, &dataset, PriceField::Close)
|
||||||
.expect("previous close");
|
.expect("previous close");
|
||||||
portfolio.begin_trading_day();
|
portfolio.begin_trading_day();
|
||||||
portfolio
|
let error = portfolio
|
||||||
.update_prices(missing_date, &dataset, PriceField::Close)
|
.update_prices(missing_date, &dataset, PriceField::Close)
|
||||||
.expect("missing current row should carry previous close");
|
.expect_err("unclassified missing current price must not be filled from history");
|
||||||
|
assert!(error.to_string().contains("601028.SH"));
|
||||||
|
let paused_dataset = DataSet::from_components(
|
||||||
|
vec![dataset.instrument("601028.SH").unwrap().clone()],
|
||||||
|
vec![dataset.market(prev_date, "601028.SH").unwrap().clone()],
|
||||||
|
Vec::new(),
|
||||||
|
vec![crate::data::CandidateEligibility {
|
||||||
|
date: missing_date, symbol: "601028.SH".into(), is_st: false, is_star_st: false,
|
||||||
|
is_new_listing: false, is_paused: true, allow_buy: false, allow_sell: false,
|
||||||
|
is_kcb: false, is_one_yuan: false, risk_level_code: None,
|
||||||
|
}],
|
||||||
|
vec![dataset.benchmark(prev_date).unwrap().clone()],
|
||||||
|
).unwrap();
|
||||||
|
portfolio.update_prices(missing_date, &paused_dataset, PriceField::Close)
|
||||||
|
.expect("dated suspension permits keeping the last known valuation, not creating a fill");
|
||||||
|
|
||||||
let position = portfolio.position("601028.SH").expect("position");
|
let position = portfolio.position("601028.SH").expect("position");
|
||||||
assert!((position.last_price - 10.3).abs() < 1e-6);
|
assert!((position.last_price - 10.3).abs() < 1e-6);
|
||||||
|
|||||||
@@ -138,6 +138,16 @@ pub struct FidcRiskDecisionAudit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FidcRiskDecisionAudit {
|
impl FidcRiskDecisionAudit {
|
||||||
|
pub fn rejected_buy_plan(date: NaiveDate, symbol: &str, reason: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
date, symbol: symbol.into(), scope: RiskCheckScope::Buy,
|
||||||
|
stage: "buy_planning".into(), accepted: false,
|
||||||
|
rule_code: reason.into(), reason: reason.into(),
|
||||||
|
config_version: Some("inline_risk_policy".into()), data_epoch: date.to_string(),
|
||||||
|
selection_batch_id: None, order_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn rejected_selection(
|
pub fn rejected_selection(
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
symbol: impl Into<String>,
|
symbol: impl Into<String>,
|
||||||
@@ -208,14 +218,8 @@ impl ChinaAShareRiskControl {
|
|||||||
{
|
{
|
||||||
return Some("inactive_or_delisted");
|
return Some("inactive_or_delisted");
|
||||||
}
|
}
|
||||||
let status = instrument.status.trim().to_ascii_lowercase();
|
// Latest reference status has no historical as-of date. Execution-day
|
||||||
let terminal_status = matches!(
|
// risk snapshots remain authoritative; missing quotes are not waived.
|
||||||
status.as_str(),
|
|
||||||
"inactive" | "delisted" | "terminated" | "expired"
|
|
||||||
);
|
|
||||||
if terminal_status && instrument.delisted_at.is_none() {
|
|
||||||
return Some("inactive_or_delisted");
|
|
||||||
}
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,7 +414,7 @@ impl ChinaAShareRiskControl {
|
|||||||
}
|
}
|
||||||
let reject_one_yuan = match scope {
|
let reject_one_yuan = match scope {
|
||||||
RiskCheckScope::Selection => config.static_rules.reject_one_yuan_selection,
|
RiskCheckScope::Selection => config.static_rules.reject_one_yuan_selection,
|
||||||
RiskCheckScope::Buy => config.static_rules.reject_one_yuan_buy,
|
RiskCheckScope::Buy => false,
|
||||||
RiskCheckScope::Sell => false,
|
RiskCheckScope::Sell => false,
|
||||||
};
|
};
|
||||||
if reject_one_yuan
|
if reject_one_yuan
|
||||||
@@ -483,6 +487,14 @@ impl ChinaAShareRiskControl {
|
|||||||
) {
|
) {
|
||||||
return Some(reason);
|
return Some(reason);
|
||||||
}
|
}
|
||||||
|
if !check_price.is_finite() || check_price <= 0.0 {
|
||||||
|
return Some("invalid execution price");
|
||||||
|
}
|
||||||
|
// Daily candidate flags can describe the later close. Execution
|
||||||
|
// price constraints must use this order's actual pricing clock.
|
||||||
|
if config.static_rules.reject_one_yuan_buy && check_price <= 1.0 {
|
||||||
|
return Some("one_yuan");
|
||||||
|
}
|
||||||
if config.static_rules.respect_allow_buy_sell && !candidate.allow_buy {
|
if config.static_rules.respect_allow_buy_sell && !candidate.allow_buy {
|
||||||
return Some("buy_disabled");
|
return Some("buy_disabled");
|
||||||
}
|
}
|
||||||
@@ -664,7 +676,6 @@ fn missing_buy_risk_state_rejected(code: &str, config: &FidcRiskControlConfig) -
|
|||||||
|| config.static_rules.reject_new_listing_buy
|
|| config.static_rules.reject_new_listing_buy
|
||||||
|| config.static_rules.reject_kcb_buy
|
|| config.static_rules.reject_kcb_buy
|
||||||
|| config.static_rules.reject_bjse_buy
|
|| config.static_rules.reject_bjse_buy
|
||||||
|| config.static_rules.reject_one_yuan_buy
|
|
||||||
|| config.static_rules.reject_upper_limit_buy
|
|| config.static_rules.reject_upper_limit_buy
|
||||||
|| config.static_rules.respect_allow_buy_sell;
|
|| config.static_rules.respect_allow_buy_sell;
|
||||||
}
|
}
|
||||||
@@ -741,7 +752,7 @@ fn missing_single_field_rejected(
|
|||||||
},
|
},
|
||||||
"is_one_yuan" | "one_yuan" => match scope {
|
"is_one_yuan" | "one_yuan" => match scope {
|
||||||
RiskCheckScope::Selection => config.static_rules.reject_one_yuan_selection,
|
RiskCheckScope::Selection => config.static_rules.reject_one_yuan_selection,
|
||||||
RiskCheckScope::Buy => config.static_rules.reject_one_yuan_buy,
|
RiskCheckScope::Buy => false,
|
||||||
RiskCheckScope::Sell => false,
|
RiskCheckScope::Sell => false,
|
||||||
},
|
},
|
||||||
"allow_buy" => match scope {
|
"allow_buy" => match scope {
|
||||||
@@ -785,7 +796,6 @@ fn missing_single_field_rejected(
|
|||||||
|| config.static_rules.reject_new_listing_buy
|
|| config.static_rules.reject_new_listing_buy
|
||||||
|| config.static_rules.reject_kcb_buy
|
|| config.static_rules.reject_kcb_buy
|
||||||
|| config.static_rules.reject_bjse_buy
|
|| config.static_rules.reject_bjse_buy
|
||||||
|| config.static_rules.reject_one_yuan_buy
|
|
||||||
|| config.static_rules.reject_upper_limit_buy
|
|| config.static_rules.reject_upper_limit_buy
|
||||||
|| config.static_rules.respect_allow_buy_sell
|
|| config.static_rules.respect_allow_buy_sell
|
||||||
}
|
}
|
||||||
@@ -843,7 +853,7 @@ mod tests {
|
|||||||
Some(&instrument("delisted", None)),
|
Some(&instrument("delisted", None)),
|
||||||
date,
|
date,
|
||||||
),
|
),
|
||||||
Some("inactive_or_delisted")
|
None
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ChinaAShareRiskControl::instrument_rejection_reason(
|
ChinaAShareRiskControl::instrument_rejection_reason(
|
||||||
@@ -902,6 +912,61 @@ mod tests {
|
|||||||
position
|
position
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_yuan_buy_rule_uses_execution_price_not_later_close_or_earlier_open() {
|
||||||
|
let day = d(2025, 2, 6);
|
||||||
|
let mut candidate = candidate(day);
|
||||||
|
let mut snapshot = market(day, 1.2, 0.5);
|
||||||
|
let config = FidcRiskControlConfig::default();
|
||||||
|
candidate.is_one_yuan = true;
|
||||||
|
snapshot.day_open = 0.9;
|
||||||
|
snapshot.close = 0.8;
|
||||||
|
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||||
|
day, &candidate, &snapshot, None, 1.2, &config), None);
|
||||||
|
candidate.is_one_yuan = false;
|
||||||
|
snapshot.day_open = 1.2;
|
||||||
|
snapshot.close = 1.3;
|
||||||
|
for price in [0.9, 1.0] {
|
||||||
|
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||||
|
day, &candidate, &snapshot, None, price, &config), Some("one_yuan"));
|
||||||
|
}
|
||||||
|
let mut relaxed = config;
|
||||||
|
relaxed.static_rules.reject_one_yuan_buy = false;
|
||||||
|
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||||
|
day, &candidate, &snapshot, None, 0.9, &relaxed), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execution_quote_covers_missing_one_yuan_flag_but_not_other_risk_facts() {
|
||||||
|
let day = d(2025, 2, 6);
|
||||||
|
let mut candidate = candidate(day);
|
||||||
|
let snapshot = market(day, 1.2, 0.5);
|
||||||
|
let config = FidcRiskControlConfig::default();
|
||||||
|
candidate.risk_level_code = Some("missing_risk_state:is_one_yuan".into());
|
||||||
|
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||||
|
day, &candidate, &snapshot, None, 1.2, &config), None);
|
||||||
|
candidate.risk_level_code = Some("missing_risk_state:is_st".into());
|
||||||
|
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||||
|
day, &candidate, &snapshot, None, 1.2, &config), Some("missing_risk_state"));
|
||||||
|
candidate.risk_level_code = None;
|
||||||
|
for price in [0.0, f64::NAN, f64::INFINITY] {
|
||||||
|
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||||
|
day, &candidate, &snapshot, None, price, &config), Some("invalid execution price"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_one_yuan_selection_policy_still_uses_selection_facts() {
|
||||||
|
let day = d(2025, 2, 6);
|
||||||
|
let mut candidate = candidate(day);
|
||||||
|
candidate.is_one_yuan = true;
|
||||||
|
let snapshot = market(day, 1.2, 0.5);
|
||||||
|
let mut config = FidcRiskControlConfig::default();
|
||||||
|
config.static_rules.reject_one_yuan_selection = true;
|
||||||
|
assert_eq!(ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||||
|
day, &candidate, &snapshot, None, &config), Some("one_yuan"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sell_rejection_respects_allow_sell_policy_on_execution_day() {
|
fn sell_rejection_respects_allow_sell_policy_on_execution_day() {
|
||||||
let prev_date = d(2024, 4, 16);
|
let prev_date = d(2024, 4, 16);
|
||||||
|
|||||||
@@ -0,0 +1,398 @@
|
|||||||
|
//! Completed, same-session minute events. These bars never become execution quotes.
|
||||||
|
use crate::{
|
||||||
|
daily_patterns::{PatternResult, PatternSpec},
|
||||||
|
factor_events::{Expr, Frame},
|
||||||
|
};
|
||||||
|
use chrono::{FixedOffset, NaiveDateTime, NaiveTime, TimeZone, Timelike};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub const CONTRACT: &str = "fidc_completed_session_events_v1";
|
||||||
|
pub const EVENTS: &[&str] = &[
|
||||||
|
"PRICE_CROSS_VWAP_UP",
|
||||||
|
"PRICE_CROSS_VWAP_DOWN",
|
||||||
|
"INTRADAY_HIGH_BREAKOUT",
|
||||||
|
"INTRADAY_LOW_BREAKDOWN",
|
||||||
|
"OPENING_RANGE_BREAKOUT_UP",
|
||||||
|
"OPENING_RANGE_BREAKOUT_DOWN",
|
||||||
|
"INTRADAY_VOLUME_SPIKE",
|
||||||
|
"MORNING_HIGH_BREAKOUT",
|
||||||
|
"MORNING_LOW_BREAKDOWN",
|
||||||
|
"AFTERNOON_MOMENTUM_UP",
|
||||||
|
"AFTERNOON_MOMENTUM_DOWN",
|
||||||
|
"LATE_SESSION_STRENGTH",
|
||||||
|
"LATE_SESSION_WEAKNESS",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct MinuteBar {
|
||||||
|
pub symbol: String,
|
||||||
|
pub timestamp: NaiveDateTime,
|
||||||
|
pub available_at: NaiveDateTime,
|
||||||
|
pub open: f64,
|
||||||
|
pub high: f64,
|
||||||
|
pub low: f64,
|
||||||
|
pub close: f64,
|
||||||
|
pub volume: f64,
|
||||||
|
pub amount: f64,
|
||||||
|
}
|
||||||
|
pub type BarStore = Arc<BTreeMap<(chrono::NaiveDate, String), Vec<MinuteBar>>>;
|
||||||
|
pub fn bar_store(bars: Vec<MinuteBar>) -> Result<BarStore, String> {
|
||||||
|
let mut groups = BTreeMap::<(chrono::NaiveDate, String), Vec<MinuteBar>>::new();
|
||||||
|
for bar in bars {
|
||||||
|
groups
|
||||||
|
.entry((bar.timestamp.date(), bar.symbol.clone()))
|
||||||
|
.or_default()
|
||||||
|
.push(bar);
|
||||||
|
}
|
||||||
|
for rows in groups.values_mut() {
|
||||||
|
rows.sort_by_key(|r| r.timestamp);
|
||||||
|
if rows
|
||||||
|
.windows(2)
|
||||||
|
.any(|pair| pair[0].timestamp == pair[1].timestamp)
|
||||||
|
{
|
||||||
|
return Err("duplicate_completed_minute_bar".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Arc::new(groups))
|
||||||
|
}
|
||||||
|
fn f(name: &str) -> Expr {
|
||||||
|
Expr::Field { name: name.into() }
|
||||||
|
}
|
||||||
|
fn n(value: f64) -> Expr {
|
||||||
|
Expr::Number { value }
|
||||||
|
}
|
||||||
|
fn op(name: &str, args: Vec<Expr>, window: Option<usize>) -> Expr {
|
||||||
|
Expr::Operator {
|
||||||
|
name: name.into(),
|
||||||
|
args,
|
||||||
|
window,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn time(minutes: u32) -> NaiveTime {
|
||||||
|
NaiveTime::from_hms_opt(minutes / 60, minutes % 60, 0).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_regular_label(t: NaiveTime) -> bool {
|
||||||
|
t.second() == 0 && (time(570) <= t && t <= time(690) || time(780) < t && t <= time(900))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expression(event: &str, p: &BTreeMap<String, Value>) -> Result<Expr, String> {
|
||||||
|
let cross = |up: bool, a: Expr, b: Expr| {
|
||||||
|
op(
|
||||||
|
if up { "CROSS_ABOVE" } else { "CROSS_BELOW" },
|
||||||
|
vec![a, b],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
Ok(match event {
|
||||||
|
"PRICE_CROSS_VWAP_UP" => cross(true, f("close"), f("session_vwap")),
|
||||||
|
"PRICE_CROSS_VWAP_DOWN" => cross(false, f("close"), f("session_vwap")),
|
||||||
|
"INTRADAY_HIGH_BREAKOUT" => op(
|
||||||
|
"GT",
|
||||||
|
vec![
|
||||||
|
f("close"),
|
||||||
|
op("LAG", vec![op("CUMMAX", vec![f("high")], None)], Some(1)),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"INTRADAY_LOW_BREAKDOWN" => op(
|
||||||
|
"LT",
|
||||||
|
vec![
|
||||||
|
f("close"),
|
||||||
|
op("LAG", vec![op("CUMMIN", vec![f("low")], None)], Some(1)),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"OPENING_RANGE_BREAKOUT_UP" => cross(true, f("close"), f("opening_high")),
|
||||||
|
"OPENING_RANGE_BREAKOUT_DOWN" => cross(false, f("close"), f("opening_low")),
|
||||||
|
"MORNING_HIGH_BREAKOUT" => cross(true, f("close"), f("morning_high")),
|
||||||
|
"MORNING_LOW_BREAKDOWN" => cross(false, f("close"), f("morning_low")),
|
||||||
|
"AFTERNOON_MOMENTUM_UP" => cross(true, f("afternoon_return"), n(0.)),
|
||||||
|
"AFTERNOON_MOMENTUM_DOWN" => cross(false, f("afternoon_return"), n(0.)),
|
||||||
|
"LATE_SESSION_STRENGTH" => cross(true, f("late_return"), n(0.)),
|
||||||
|
"LATE_SESSION_WEAKNESS" => cross(false, f("late_return"), n(0.)),
|
||||||
|
"INTRADAY_VOLUME_SPIKE" => op(
|
||||||
|
"GTE",
|
||||||
|
vec![
|
||||||
|
f("volume"),
|
||||||
|
op(
|
||||||
|
"MUL",
|
||||||
|
vec![
|
||||||
|
op(
|
||||||
|
"LAG",
|
||||||
|
vec![op(
|
||||||
|
"ROLLING_MEAN",
|
||||||
|
vec![f("volume")],
|
||||||
|
Some(p["volume_window"].as_u64().unwrap() as usize),
|
||||||
|
)],
|
||||||
|
Some(1),
|
||||||
|
),
|
||||||
|
n(p["volume_multiple"].as_f64().unwrap()),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
_ => return Err("session_event_not_registered".into()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn evaluate(
|
||||||
|
spec: &PatternSpec,
|
||||||
|
symbol: &str,
|
||||||
|
bars: &[MinuteBar],
|
||||||
|
decision: NaiveDateTime,
|
||||||
|
) -> Result<PatternResult, String> {
|
||||||
|
let mut result = PatternResult {
|
||||||
|
symbol: symbol.into(),
|
||||||
|
name: None,
|
||||||
|
matched: false,
|
||||||
|
score: None,
|
||||||
|
checks: vec![],
|
||||||
|
values: json!({}),
|
||||||
|
anchor: Value::Null,
|
||||||
|
exclusion: None,
|
||||||
|
};
|
||||||
|
if bars.is_empty() {
|
||||||
|
return Err(format!(
|
||||||
|
"session_source_missing: {symbol} {}",
|
||||||
|
decision.date()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let visible = bars
|
||||||
|
.iter()
|
||||||
|
.filter(|b| {
|
||||||
|
b.timestamp.date() == decision.date()
|
||||||
|
&& b.timestamp < decision
|
||||||
|
&& b.available_at <= decision
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if visible.is_empty() {
|
||||||
|
result.exclusion = Some(json!({"reason":"session_before_first_completed_bar"}));
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
let last = visible.last().unwrap().timestamp;
|
||||||
|
let expected = (570..=690)
|
||||||
|
.chain(781..=900)
|
||||||
|
.map(|m| decision.date().and_time(time(m)))
|
||||||
|
.filter(|t| *t < decision)
|
||||||
|
.last();
|
||||||
|
if expected != Some(last) {
|
||||||
|
return Err(format!(
|
||||||
|
"session_latest_bar_missing: {symbol} expected={expected:?} actual={last}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut indexed = BTreeMap::new();
|
||||||
|
for b in &visible {
|
||||||
|
if b.symbol != symbol
|
||||||
|
|| !is_regular_label(b.timestamp.time())
|
||||||
|
|| b.available_at < b.timestamp
|
||||||
|
|| [b.open, b.high, b.low, b.close, b.volume, b.amount]
|
||||||
|
.iter()
|
||||||
|
.any(|v| !v.is_finite())
|
||||||
|
|| b.low <= 0.
|
||||||
|
|| b.open <= 0.
|
||||||
|
|| b.close <= 0.
|
||||||
|
|| b.high < b.open.max(b.close)
|
||||||
|
|| b.low > b.open.min(b.close)
|
||||||
|
|| b.volume < 0.
|
||||||
|
|| b.amount < 0.
|
||||||
|
|| indexed.insert(b.timestamp, b).is_some()
|
||||||
|
{
|
||||||
|
return Err(format!("session_bar_invalid: {symbol} {}", b.timestamp));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for minute in (571..=690).chain(781..=900) {
|
||||||
|
let stamp = decision.date().and_time(time(minute));
|
||||||
|
if stamp <= last && !indexed.contains_key(&stamp) {
|
||||||
|
return Err(format!(
|
||||||
|
"session_bar_gap: {symbol} {stamp}; no filling or calendar compression"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let opening_end = time(570 + spec.n("opening_minutes") as u32);
|
||||||
|
let (mut volume, mut amount) = (0., 0.);
|
||||||
|
let (mut opening_high, mut opening_low) = (f64::NEG_INFINITY, f64::INFINITY);
|
||||||
|
let (mut morning_high, mut morning_low) = (f64::NEG_INFINITY, f64::INFINITY);
|
||||||
|
let (mut morning_close, mut late_close) = (None, None);
|
||||||
|
let mut fields: BTreeMap<String, Vec<Option<f64>>> = [
|
||||||
|
"open",
|
||||||
|
"high",
|
||||||
|
"low",
|
||||||
|
"close",
|
||||||
|
"volume",
|
||||||
|
"amount",
|
||||||
|
"session_vwap",
|
||||||
|
"opening_high",
|
||||||
|
"opening_low",
|
||||||
|
"morning_high",
|
||||||
|
"morning_low",
|
||||||
|
"afternoon_return",
|
||||||
|
"late_return",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| (s.into(), vec![]))
|
||||||
|
.collect();
|
||||||
|
let mut timestamps = vec![];
|
||||||
|
let mut available_at = vec![];
|
||||||
|
let zone = FixedOffset::east_opt(8 * 3600).unwrap();
|
||||||
|
for b in indexed.values() {
|
||||||
|
let t = b.timestamp.time();
|
||||||
|
volume += b.volume;
|
||||||
|
amount += b.amount;
|
||||||
|
if t <= opening_end {
|
||||||
|
opening_high = opening_high.max(b.high);
|
||||||
|
opening_low = opening_low.min(b.low);
|
||||||
|
}
|
||||||
|
if t <= time(690) {
|
||||||
|
morning_high = morning_high.max(b.high);
|
||||||
|
morning_low = morning_low.min(b.low);
|
||||||
|
}
|
||||||
|
if t == time(690) {
|
||||||
|
morning_close = Some(b.close);
|
||||||
|
}
|
||||||
|
if t == time(870) {
|
||||||
|
late_close = Some(b.close);
|
||||||
|
}
|
||||||
|
for (name, value) in [
|
||||||
|
("open", Some(b.open)),
|
||||||
|
("high", Some(b.high)),
|
||||||
|
("low", Some(b.low)),
|
||||||
|
("close", Some(b.close)),
|
||||||
|
("volume", Some(b.volume)),
|
||||||
|
("amount", Some(b.amount)),
|
||||||
|
("session_vwap", (volume > 0.).then_some(amount / volume)),
|
||||||
|
("opening_high", (t >= opening_end).then_some(opening_high)),
|
||||||
|
("opening_low", (t >= opening_end).then_some(opening_low)),
|
||||||
|
("morning_high", (t >= time(690)).then_some(morning_high)),
|
||||||
|
("morning_low", (t >= time(690)).then_some(morning_low)),
|
||||||
|
("afternoon_return", morning_close.map(|v| b.close / v - 1.)),
|
||||||
|
("late_return", late_close.map(|v| b.close / v - 1.)),
|
||||||
|
] {
|
||||||
|
fields.get_mut(name).unwrap().push(value);
|
||||||
|
}
|
||||||
|
timestamps.push(zone.from_local_datetime(&b.timestamp).single().unwrap());
|
||||||
|
available_at.push(zone.from_local_datetime(&b.available_at).single().unwrap());
|
||||||
|
}
|
||||||
|
let frame = Frame {
|
||||||
|
symbol: symbol.into(),
|
||||||
|
frequency: "1m".into(),
|
||||||
|
decision_at: zone.from_local_datetime(&decision).single().unwrap(),
|
||||||
|
timestamps,
|
||||||
|
available_at,
|
||||||
|
fields,
|
||||||
|
};
|
||||||
|
let event = spec
|
||||||
|
.session_event
|
||||||
|
.as_deref()
|
||||||
|
.ok_or("session_event_id_required")?;
|
||||||
|
let values = crate::factor_events::evaluate(&expression(event, &spec.parameters)?, &frame)?;
|
||||||
|
let latest = values.values.last().copied().flatten();
|
||||||
|
result.score = latest;
|
||||||
|
result.matched = latest == Some(1.);
|
||||||
|
result.values = json!({"session_event":event,"session_contract":CONTRACT,"expression":values,"signal_bar_end":last,"decision_at":decision,"bars":visible.len(),"bar_times":frame.timestamps.iter().map(|t|t.format("%Y-%m-%dT%H:%M:%S").to_string()).collect::<Vec<_>>(),"close":visible.last().unwrap().close,"session_return":visible.last().unwrap().close/visible.first().unwrap().open-1.,"price_policy":"same_session_raw_ohlcv"});
|
||||||
|
if latest.is_none() {
|
||||||
|
result.exclusion = Some(json!({"reason":"session_warmup_or_undefined"}));
|
||||||
|
} else {
|
||||||
|
result.checks.push(json!({"label":"分钟事件","actual":latest,"operator":"==","threshold":1,"passed":result.matched}));
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
fn spec(event: &str) -> PatternSpec {
|
||||||
|
serde_json::from_value::<PatternSpec>(
|
||||||
|
json!({"template":"session_event","session_event":event,"parameters":{}}),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.validate()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
fn bars() -> Vec<MinuteBar> {
|
||||||
|
let date = chrono::NaiveDate::from_ymd_opt(2026, 9, 8).unwrap();
|
||||||
|
(570..=690)
|
||||||
|
.chain(781..=900)
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, m)| {
|
||||||
|
let timestamp = date.and_time(time(m));
|
||||||
|
let price = 100. + (i % 17) as f64 / 10.;
|
||||||
|
let volume = if i % 39 == 0 { 1000. } else { 100. };
|
||||||
|
MinuteBar {
|
||||||
|
symbol: "300395.SZ".into(),
|
||||||
|
timestamp,
|
||||||
|
available_at: timestamp,
|
||||||
|
open: price,
|
||||||
|
high: price + 0.1,
|
||||||
|
low: price - 0.1,
|
||||||
|
close: price,
|
||||||
|
volume,
|
||||||
|
amount: volume * price,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn all_thirteen_events_return_native_boolean_series() {
|
||||||
|
let bars = bars();
|
||||||
|
let decision = "2026-09-08T15:00:01".parse().unwrap();
|
||||||
|
for event in EVENTS {
|
||||||
|
let value = evaluate(&spec(event), "300395.SZ", &bars, decision).unwrap();
|
||||||
|
assert!(value.score.is_some(), "{event}");
|
||||||
|
assert_eq!(value.values["expression"]["value_type"], "boolean");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn decision_uses_the_previous_completed_label_and_future_prices_do_not_rewrite() {
|
||||||
|
let mut bars = bars();
|
||||||
|
let decision = "2026-09-08T10:02:00".parse().unwrap();
|
||||||
|
for event in EVENTS {
|
||||||
|
let before = evaluate(&spec(event), "300395.SZ", &bars, decision).unwrap();
|
||||||
|
for bar in &mut bars {
|
||||||
|
if bar.timestamp >= decision {
|
||||||
|
bar.open = 1000.;
|
||||||
|
bar.close = 1000.;
|
||||||
|
bar.high = 1001.;
|
||||||
|
bar.low = 999.;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let after = evaluate(&spec(event), "300395.SZ", &bars, decision).unwrap();
|
||||||
|
assert_eq!(before.values, after.values);
|
||||||
|
assert_eq!(after.values["signal_bar_end"], "2026-09-08T10:01:00");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn gaps_and_stale_last_bars_do_not_become_false_or_repeated_signals() {
|
||||||
|
let mut values = bars();
|
||||||
|
let decision = "2026-09-08T10:02:00".parse().unwrap();
|
||||||
|
values.retain(|r| r.timestamp.time() != time(600));
|
||||||
|
assert!(evaluate(&spec(EVENTS[0]), "300395.SZ", &values, decision)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("session_bar_gap"));
|
||||||
|
let stale = bars()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|r| r.timestamp.time() < time(601))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(evaluate(&spec(EVENTS[0]), "300395.SZ", &stale, decision)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("latest_bar_missing"));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn opening_range_is_unavailable_before_the_range_has_completed() {
|
||||||
|
let value = evaluate(
|
||||||
|
&spec("OPENING_RANGE_BREAKOUT_UP"),
|
||||||
|
"300395.SZ",
|
||||||
|
&bars(),
|
||||||
|
"2026-09-08T09:59:01".parse().unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(value.score, None);
|
||||||
|
assert!(!value.matched);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
//! Immutable, account-independent trading signals. Quantity and execution
|
||||||
|
//! prices are intentionally absent; the existing broker owns those decisions.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
||||||
|
|
||||||
|
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::strategy::{OrderIntent, StrategyContext};
|
||||||
|
use crate::portfolio::PortfolioState;
|
||||||
|
|
||||||
|
pub const SIGNAL_BOOK_SCHEMA: &str = "fidc.signal-book/v2";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct SignalBookReference {
|
||||||
|
pub book_id: String,
|
||||||
|
pub version_sha256: String,
|
||||||
|
pub artifact_sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignalBookReference {
|
||||||
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
if !valid_sha(&self.version_sha256) || !valid_sha(&self.artifact_sha256)
|
||||||
|
|| self.book_id != format!("signal_book_{}",self.version_sha256)
|
||||||
|
{ return Err("signal_book_reference_invalid".into()); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct SignalCache {
|
||||||
|
entries: BTreeMap<String,Weak<ValidatedSignalBook>>,
|
||||||
|
retained: std::collections::VecDeque<(String,Arc<ValidatedSignalBook>,usize)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signal_cache() -> &'static Mutex<SignalCache> {
|
||||||
|
static CACHE: OnceLock<Mutex<SignalCache>> = OnceLock::new();
|
||||||
|
CACHE.get_or_init(||Mutex::new(SignalCache::default()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cached_signal_book(reference: &SignalBookReference) -> Result<Option<Arc<ValidatedSignalBook>>,String> {
|
||||||
|
reference.validate()?;
|
||||||
|
let cache=signal_cache().lock().map_err(|_|"signal_cache_lock_failed")?;
|
||||||
|
let book=cache.entries.get(&reference.artifact_sha256).and_then(Weak::upgrade);
|
||||||
|
if book.as_ref().is_some_and(|book|book.version_sha256()!=reference.version_sha256) {
|
||||||
|
return Err("signal_book_cached_version_mismatch".into());
|
||||||
|
}
|
||||||
|
Ok(book)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register_signal_book(reference: &SignalBookReference, body: &[u8]) -> Result<Arc<ValidatedSignalBook>,String> {
|
||||||
|
use sha2::{Digest,Sha256};
|
||||||
|
reference.validate()?;
|
||||||
|
if body.len()>64*1024*1024 || format!("{:x}",Sha256::digest(body))!=reference.artifact_sha256 {
|
||||||
|
return Err("signal_book_artifact_hash_or_size_invalid".into());
|
||||||
|
}
|
||||||
|
let raw:SignalBook=serde_json::from_slice(body).map_err(|error|format!("signal_book_decode_failed: {error}"))?;
|
||||||
|
if raw.version_sha256!=reference.version_sha256 { return Err("signal_book_version_mismatch".into()); }
|
||||||
|
let book=Arc::new(raw.validate()?);
|
||||||
|
let mut cache=signal_cache().lock().map_err(|_|"signal_cache_lock_failed")?;
|
||||||
|
cache.entries.retain(|_,value|value.strong_count()>0);
|
||||||
|
if let Some(existing)=cache.entries.get(&reference.artifact_sha256).and_then(Weak::upgrade) { return Ok(existing); }
|
||||||
|
cache.entries.insert(reference.artifact_sha256.clone(),Arc::downgrade(&book));
|
||||||
|
let estimated=body.len().saturating_mul(4);
|
||||||
|
if estimated<=128*1024*1024 {
|
||||||
|
cache.retained.push_back((reference.artifact_sha256.clone(),book.clone(),estimated));
|
||||||
|
while cache.retained.len()>4 || cache.retained.iter().map(|entry|entry.2).sum::<usize>()>128*1024*1024 {
|
||||||
|
cache.retained.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(book)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SignalProvenance {
|
||||||
|
Observed,
|
||||||
|
Reconstructed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SignalFrequency {
|
||||||
|
Daily,
|
||||||
|
Minute,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
|
pub enum SignalAction {
|
||||||
|
TargetWeight { symbol: String, weight: f64 },
|
||||||
|
BuyCondition { symbol: String, allowed: bool },
|
||||||
|
Exit { symbol: String },
|
||||||
|
Reduce { symbol: String, remaining_ratio: f64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignalAction {
|
||||||
|
fn symbol(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::TargetWeight { symbol, .. }
|
||||||
|
| Self::BuyCondition { symbol, .. }
|
||||||
|
| Self::Exit { symbol }
|
||||||
|
| Self::Reduce { symbol, .. } => symbol,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct SignalSnapshot {
|
||||||
|
pub signal_at: DateTime<Utc>,
|
||||||
|
pub decision_at: DateTime<Utc>,
|
||||||
|
pub input_as_of: DateTime<Utc>,
|
||||||
|
pub input_available_at: DateTime<Utc>,
|
||||||
|
pub generated_at: DateTime<Utc>,
|
||||||
|
pub published_at: DateTime<Utc>,
|
||||||
|
pub input_sha256: String,
|
||||||
|
pub complete_targets: bool,
|
||||||
|
pub actions: Vec<SignalAction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct SignalBook {
|
||||||
|
pub schema: String,
|
||||||
|
pub version_sha256: String,
|
||||||
|
pub generator_sha256: String,
|
||||||
|
pub model_sha256: Option<String>,
|
||||||
|
pub knowledge_cutoff: Option<DateTime<Utc>>,
|
||||||
|
pub provenance: SignalProvenance,
|
||||||
|
pub frequency: SignalFrequency,
|
||||||
|
pub expected_decisions: Vec<DateTime<Utc>>,
|
||||||
|
pub snapshots: Vec<SignalSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ValidatedSignalBook {
|
||||||
|
book: SignalBook,
|
||||||
|
index: BTreeMap<NaiveDateTime, usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_sha(value: &str) -> bool {
|
||||||
|
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shanghai(value: DateTime<Utc>) -> NaiveDateTime {
|
||||||
|
value.with_timezone(&FixedOffset::east_opt(8 * 3600).expect("Shanghai offset")).naive_local()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignalBook {
|
||||||
|
pub fn content_sha256(&self) -> Result<String, String> {
|
||||||
|
let mut value=serde_json::to_value(self).map_err(|error|error.to_string())?;
|
||||||
|
value.as_object_mut().ok_or("signal_book_object_required")?.remove("versionSha256");
|
||||||
|
value["knowledgeCutoff"]=self.knowledge_cutoff.map(|at|serde_json::json!(at.timestamp_micros())).unwrap_or(serde_json::Value::Null);
|
||||||
|
value["expectedDecisions"]=serde_json::json!(self.expected_decisions.iter().map(DateTime::timestamp_micros).collect::<Vec<_>>());
|
||||||
|
for (raw,snapshot) in value["snapshots"].as_array_mut().ok_or("signal_snapshots_required")?.iter_mut().zip(&self.snapshots) {
|
||||||
|
let object=raw.as_object_mut().ok_or("signal_snapshot_required")?;
|
||||||
|
object.remove("generatedAt");
|
||||||
|
object.remove("publishedAt");
|
||||||
|
for (key,at) in [("signalAt",snapshot.signal_at),("decisionAt",snapshot.decision_at),
|
||||||
|
("inputAsOf",snapshot.input_as_of),("inputAvailableAt",snapshot.input_available_at)] {
|
||||||
|
object.insert(key.into(),serde_json::json!(at.timestamp_micros()));
|
||||||
|
}
|
||||||
|
for (raw,action) in object.get_mut("actions").and_then(serde_json::Value::as_array_mut).ok_or("signal_actions_required")?.iter_mut().zip(&snapshot.actions) {
|
||||||
|
match action {
|
||||||
|
SignalAction::TargetWeight{weight,..}=>raw["weight"]=serde_json::json!(format!("{:016x}",weight.to_bits())),
|
||||||
|
SignalAction::Reduce{remaining_ratio,..}=>raw["remaining_ratio"]=serde_json::json!(format!("{:016x}",remaining_ratio.to_bits())),
|
||||||
|
_=>{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn sorted(value:serde_json::Value)->serde_json::Value {
|
||||||
|
match value {
|
||||||
|
serde_json::Value::Object(map)=>serde_json::Value::Object(map.into_iter().map(|(key,value)|(key,sorted(value)))
|
||||||
|
.collect::<BTreeMap<_,_>>().into_iter().collect()),
|
||||||
|
serde_json::Value::Array(rows)=>serde_json::Value::Array(rows.into_iter().map(sorted).collect()),
|
||||||
|
other=>other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let raw=serde_json::to_vec(&sorted(value)).map_err(|error|error.to_string())?;
|
||||||
|
Ok(format!("{:x}",Sha256::digest(raw)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(self) -> Result<ValidatedSignalBook, String> {
|
||||||
|
if self.schema != SIGNAL_BOOK_SCHEMA || !valid_sha(&self.version_sha256)
|
||||||
|
|| !valid_sha(&self.generator_sha256)
|
||||||
|
{
|
||||||
|
return Err("signal_book_identity_invalid".into());
|
||||||
|
}
|
||||||
|
if self.model_sha256.as_ref().is_some_and(|value| !valid_sha(value))
|
||||||
|
|| self.model_sha256.is_some() != self.knowledge_cutoff.is_some()
|
||||||
|
{ return Err("signal_model_training_identity_incomplete".into()); }
|
||||||
|
if self.expected_decisions.is_empty() || self.expected_decisions.len() > 100_000
|
||||||
|
|| self.expected_decisions.len() != self.snapshots.len()
|
||||||
|
{
|
||||||
|
return Err("signal_book_decision_coverage_incomplete".into());
|
||||||
|
}
|
||||||
|
let mut index = BTreeMap::new();
|
||||||
|
let mut previous = None;
|
||||||
|
let mut total_actions = 0usize;
|
||||||
|
for (number, (expected, snapshot)) in self.expected_decisions.iter().zip(&self.snapshots).enumerate() {
|
||||||
|
if [*expected,snapshot.signal_at,snapshot.input_as_of,snapshot.input_available_at,snapshot.generated_at,snapshot.published_at]
|
||||||
|
.iter().any(|at|at.timestamp_subsec_nanos()%1000!=0) || self.knowledge_cutoff.is_some_and(|at|at.timestamp_subsec_nanos()%1000!=0) {
|
||||||
|
return Err("signal_timestamp_requires_microsecond_precision".into());
|
||||||
|
}
|
||||||
|
if snapshot.decision_at != *expected || previous.is_some_and(|value| value >= *expected) {
|
||||||
|
return Err("signal_book_decisions_duplicate_or_unordered".into());
|
||||||
|
}
|
||||||
|
previous = Some(*expected);
|
||||||
|
if self.knowledge_cutoff.is_some_and(|cutoff| cutoff > snapshot.signal_at) || snapshot.signal_at > *expected
|
||||||
|
|| snapshot.input_available_at > snapshot.signal_at || snapshot.input_as_of > snapshot.input_available_at
|
||||||
|
|| snapshot.published_at < snapshot.generated_at || !valid_sha(&snapshot.input_sha256)
|
||||||
|
|| snapshot.generated_at < snapshot.input_available_at
|
||||||
|
|| self.knowledge_cutoff.is_some_and(|cutoff| snapshot.generated_at < cutoff)
|
||||||
|
{
|
||||||
|
return Err("signal_book_future_or_invalid_input".into());
|
||||||
|
}
|
||||||
|
if self.provenance == SignalProvenance::Observed && snapshot.published_at > *expected {
|
||||||
|
return Err("observed_signal_not_available_at_decision".into());
|
||||||
|
}
|
||||||
|
total_actions = total_actions.checked_add(snapshot.actions.len()).ok_or("signal_book_action_limit")?;
|
||||||
|
if total_actions > 2_000_000 { return Err("signal_book_action_limit".into()); }
|
||||||
|
let mut action_keys = BTreeSet::new();
|
||||||
|
let mut target_symbols = BTreeSet::new();
|
||||||
|
let mut reductions = BTreeSet::new();
|
||||||
|
let mut total_weight = 0.0;
|
||||||
|
for action in &snapshot.actions {
|
||||||
|
let symbol = action.symbol();
|
||||||
|
if symbol.is_empty() || symbol.trim() != symbol { return Err("signal_symbol_invalid".into()); }
|
||||||
|
let kind = match action {
|
||||||
|
SignalAction::TargetWeight { weight, .. } => {
|
||||||
|
if !weight.is_finite() || !(0.0..=1.0).contains(weight) { return Err("signal_target_weight_invalid".into()); }
|
||||||
|
target_symbols.insert(symbol);
|
||||||
|
total_weight += weight;
|
||||||
|
"target"
|
||||||
|
}
|
||||||
|
SignalAction::BuyCondition { .. } => "buy_condition",
|
||||||
|
SignalAction::Exit { .. } => { reductions.insert(symbol); "exit" }
|
||||||
|
SignalAction::Reduce { remaining_ratio, .. } => {
|
||||||
|
if !remaining_ratio.is_finite() || !(0.0..1.0).contains(remaining_ratio) { return Err("signal_reduction_invalid".into()); }
|
||||||
|
reductions.insert(symbol);
|
||||||
|
"reduce"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !action_keys.insert((symbol, kind)) { return Err("signal_action_duplicate".into()); }
|
||||||
|
}
|
||||||
|
if total_weight > 1.0 + 1e-12 { return Err("signal_target_exposure_exceeds_one".into()); }
|
||||||
|
if snapshot.complete_targets && !reductions.is_empty() {
|
||||||
|
return Err("complete_target_snapshot_cannot_mix_relative_exits".into());
|
||||||
|
}
|
||||||
|
if !target_symbols.is_disjoint(&reductions) { return Err("signal_target_exit_conflict".into()); }
|
||||||
|
for symbol in &reductions {
|
||||||
|
if action_keys.contains(&(*symbol, "exit")) && action_keys.contains(&(*symbol, "reduce")) {
|
||||||
|
return Err("signal_exit_reduction_conflict".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index.insert(shanghai(*expected), number);
|
||||||
|
}
|
||||||
|
if self.content_sha256()? != self.version_sha256 {
|
||||||
|
return Err("signal_book_content_hash_mismatch".into());
|
||||||
|
}
|
||||||
|
Ok(ValidatedSignalBook { book: self, index })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ValidatedSignalBook {
|
||||||
|
pub fn require_observed(&self) -> Result<(), String> {
|
||||||
|
if self.book.provenance != SignalProvenance::Observed {
|
||||||
|
return Err("reconstructed_signal_forbidden_in_online_execution".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn version_sha256(&self) -> &str { &self.book.version_sha256 }
|
||||||
|
pub fn generator_sha256(&self) -> &str { &self.book.generator_sha256 }
|
||||||
|
|
||||||
|
pub fn decision_dates(&self) -> BTreeSet<NaiveDate> {
|
||||||
|
self.index.keys().map(|value| value.date()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn symbols(&self) -> BTreeSet<String> {
|
||||||
|
self.book.snapshots.iter().flat_map(|snapshot| &snapshot.actions)
|
||||||
|
.map(|action| action.symbol().to_owned()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot_for(&self, ctx: &StrategyContext<'_>) -> Result<&SignalSnapshot, String> {
|
||||||
|
let snapshot = self.snapshot_at(ctx.execution_date, ctx.current_time(), ctx.is_lagged_execution())?;
|
||||||
|
if self.book.provenance == SignalProvenance::Observed && ctx.current_datetime().is_none() {
|
||||||
|
return Err("observed_signal_consumption_clock_missing".into());
|
||||||
|
}
|
||||||
|
let consumption_clock=ctx.current_datetime()
|
||||||
|
.unwrap_or(ctx.decision_date.and_hms_opt(15,0,0).expect("completed decision session"));
|
||||||
|
let lagged_daily=ctx.is_lagged_execution() && self.book.frequency==SignalFrequency::Daily;
|
||||||
|
if lagged_daily && shanghai(snapshot.input_as_of).date()>ctx.decision_date {
|
||||||
|
return Err("next_open_signal_contains_execution_session_inputs".into());
|
||||||
|
}
|
||||||
|
if shanghai(snapshot.input_available_at)>consumption_clock || shanghai(snapshot.signal_at)>consumption_clock {
|
||||||
|
return Err("signal_not_available_at_consumption_clock".into());
|
||||||
|
}
|
||||||
|
if self.book.provenance == SignalProvenance::Observed
|
||||||
|
&& (shanghai(snapshot.generated_at)>consumption_clock || shanghai(snapshot.published_at)>consumption_clock) {
|
||||||
|
return Err("observed_signal_published_after_consumption_clock".into());
|
||||||
|
}
|
||||||
|
Ok(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_due_on(&self, execution_date: NaiveDate) -> bool {
|
||||||
|
self.index.range(execution_date.and_hms_opt(0,0,0).expect("session start")..)
|
||||||
|
.next().is_some_and(|(at,_)|at.date()==execution_date)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_at(&self, execution_date: NaiveDate, current_time: Option<NaiveTime>, lagged: bool) -> Result<&SignalSnapshot, String> {
|
||||||
|
let at = if self.book.frequency == SignalFrequency::Daily && lagged {
|
||||||
|
execution_date.and_hms_opt(9, 30, 0).expect("next open")
|
||||||
|
} else {
|
||||||
|
execution_date.and_time(current_time.unwrap_or(NaiveTime::from_hms_opt(15, 0, 0).expect("daily close")))
|
||||||
|
};
|
||||||
|
self.index.get(&at).map(|index| &self.book.snapshots[*index])
|
||||||
|
.ok_or_else(|| format!("signal_snapshot_missing_at_decision: {at}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn intents(&self, ctx: &StrategyContext<'_>) -> Result<Vec<OrderIntent>, String> {
|
||||||
|
let snapshot = self.snapshot_for(ctx)?;
|
||||||
|
self.snapshot_intents(snapshot, ctx.portfolio)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_intents(&self, snapshot: &SignalSnapshot, portfolio: &PortfolioState) -> Result<Vec<OrderIntent>, String> {
|
||||||
|
let reason = format!("信号执行 version={} decision={}", self.book.version_sha256, snapshot.decision_at);
|
||||||
|
let mut intents = Vec::new();
|
||||||
|
let mut weights = BTreeMap::new();
|
||||||
|
for action in &snapshot.actions {
|
||||||
|
match action {
|
||||||
|
SignalAction::TargetWeight { symbol, weight } if snapshot.complete_targets => {
|
||||||
|
weights.insert(symbol.clone(), *weight);
|
||||||
|
}
|
||||||
|
SignalAction::TargetWeight { symbol, weight } => intents.push(OrderIntent::TargetPercent {
|
||||||
|
symbol: symbol.clone(), target_percent: *weight, reason: reason.clone(),
|
||||||
|
}),
|
||||||
|
SignalAction::Exit { symbol } => intents.push(OrderIntent::TargetPercent {
|
||||||
|
symbol: symbol.clone(), target_percent: 0.0, reason: reason.clone(),
|
||||||
|
}),
|
||||||
|
SignalAction::Reduce { symbol, remaining_ratio } => {
|
||||||
|
if let Some(position) = portfolio.position(symbol).filter(|position| position.quantity > 0) {
|
||||||
|
let quantity = (f64::from(position.quantity) * remaining_ratio).floor() as u32;
|
||||||
|
let target_quantity = i32::try_from(quantity).map_err(|_| "signal_reduction_quantity_overflow")?;
|
||||||
|
intents.push(OrderIntent::TargetShares { symbol: symbol.clone(), target_quantity, reason: reason.clone() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SignalAction::BuyCondition { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if snapshot.complete_targets {
|
||||||
|
if weights.is_empty() {
|
||||||
|
for position in portfolio.positions().values().filter(|position| position.quantity > 0) {
|
||||||
|
intents.push(OrderIntent::TargetPercent { symbol: position.symbol.clone(), target_percent: 0.0, reason: reason.clone() });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
intents.push(OrderIntent::TargetPortfolioSmart { target_weights: weights,
|
||||||
|
order_prices: None, valuation_prices: None, reason });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(intents)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn buy_denials(&self, ctx: &StrategyContext<'_>) -> Result<BTreeMap<String, String>, String> {
|
||||||
|
Ok(self.snapshot_for(ctx)?.actions.iter().filter_map(|action| match action {
|
||||||
|
SignalAction::BuyCondition { symbol, allowed: false } => Some((symbol.clone(), "信号买入条件未满足".into())),
|
||||||
|
_ => None,
|
||||||
|
}).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::Duration;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn book() -> SignalBook {
|
||||||
|
let decision: DateTime<Utc> = "2025-01-07T09:30:00+08:00".parse().unwrap();
|
||||||
|
let source: DateTime<Utc> = "2025-01-06T15:00:00+08:00".parse().unwrap();
|
||||||
|
seal(SignalBook {
|
||||||
|
schema: SIGNAL_BOOK_SCHEMA.into(), version_sha256: "a".repeat(64), generator_sha256: "b".repeat(64),
|
||||||
|
model_sha256: Some("d".repeat(64)),
|
||||||
|
knowledge_cutoff: Some("2024-12-31T15:00:00+08:00".parse().unwrap()),
|
||||||
|
provenance: SignalProvenance::Reconstructed, frequency: SignalFrequency::Daily,
|
||||||
|
expected_decisions: vec![decision], snapshots: vec![SignalSnapshot {
|
||||||
|
signal_at: source,
|
||||||
|
decision_at: decision, input_as_of: source, input_available_at: source,
|
||||||
|
generated_at: decision + Duration::days(10), published_at: decision + Duration::days(10),
|
||||||
|
input_sha256: "c".repeat(64), complete_targets: true,
|
||||||
|
actions: vec![SignalAction::TargetWeight { symbol: "000001.SZ".into(), weight: 0.5 }],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seal(mut book:SignalBook)->SignalBook {
|
||||||
|
book.version_sha256=book.content_sha256().unwrap();
|
||||||
|
book
|
||||||
|
}
|
||||||
|
|
||||||
|
fn at_context<T>(at: Option<NaiveDateTime>, action: impl FnOnce(&StrategyContext<'_>) -> T) -> T {
|
||||||
|
let data = crate::DataSet::from_components(vec![], vec![], vec![], vec![], vec![crate::BenchmarkSnapshot {
|
||||||
|
date:NaiveDate::from_ymd_opt(2025,1,6).unwrap(), benchmark:"clock-fixture".into(),
|
||||||
|
open:100.0, close:100.0, prev_close:100.0, volume:1,
|
||||||
|
}]).unwrap();
|
||||||
|
let portfolio = PortfolioState::new(10_000.0);
|
||||||
|
let symbols = BTreeSet::new();
|
||||||
|
action(&StrategyContext {
|
||||||
|
execution_date: NaiveDate::from_ymd_opt(2025,1,7).unwrap(),
|
||||||
|
decision_date: NaiveDate::from_ymd_opt(2025,1,6).unwrap(), decision_index:0,
|
||||||
|
data:&data, portfolio:&portfolio, futures_account:None, open_orders:&[],
|
||||||
|
dynamic_universe:None, subscriptions:&symbols, process_events:&[], active_process_event:None,
|
||||||
|
active_datetime:at, order_events:&[], fills:&[],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn observed_next_open_never_backdates_a_morning_publication_into_yesterdays_orders() {
|
||||||
|
let mut raw = book();
|
||||||
|
raw.provenance=SignalProvenance::Observed;
|
||||||
|
raw.snapshots[0].generated_at="2025-01-07T08:45:00+08:00".parse().unwrap();
|
||||||
|
raw.snapshots[0].published_at="2025-01-07T08:46:00+08:00".parse().unwrap();
|
||||||
|
let value=seal(raw).validate().unwrap();
|
||||||
|
for clock in ["2025-01-06T15:00:00", "2025-01-07T08:45:00"] {
|
||||||
|
at_context(Some(clock.parse().unwrap()), |ctx| {
|
||||||
|
assert_eq!(value.intents(ctx).unwrap_err(),"observed_signal_published_after_consumption_clock");
|
||||||
|
assert!(ctx.portfolio.positions().is_empty());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
at_context(Some("2025-01-07T09:30:00".parse().unwrap()), |ctx| {
|
||||||
|
assert_eq!(value.intents(ctx).unwrap().len(),1);
|
||||||
|
assert!(ctx.portfolio.positions().is_empty());
|
||||||
|
});
|
||||||
|
at_context(None, |ctx| assert_eq!(value.intents(ctx).unwrap_err(),"observed_signal_consumption_clock_missing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reconstruction_ignores_research_wall_clock_but_never_early_input_availability() {
|
||||||
|
let value=book().validate().unwrap();
|
||||||
|
at_context(Some("2025-01-06T15:00:00".parse().unwrap()), |ctx| assert!(value.intents(ctx).is_ok()));
|
||||||
|
at_context(Some("2025-01-06T14:59:59".parse().unwrap()), |ctx| {
|
||||||
|
assert_eq!(value.intents(ctx).unwrap_err(),"signal_not_available_at_consumption_clock");
|
||||||
|
});
|
||||||
|
let mut raw=book();
|
||||||
|
raw.snapshots[0].input_as_of="2025-01-07T08:30:00+08:00".parse().unwrap();
|
||||||
|
raw.snapshots[0].input_available_at=raw.snapshots[0].input_as_of;
|
||||||
|
raw.snapshots[0].signal_at=raw.snapshots[0].input_as_of;
|
||||||
|
let value=seal(raw).validate().unwrap();
|
||||||
|
at_context(Some("2025-01-07T09:30:00".parse().unwrap()), |ctx| {
|
||||||
|
assert_eq!(value.intents(ctx).unwrap_err(),"next_open_signal_contains_execution_session_inputs");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn historical_reconstruction_is_not_online_publication() {
|
||||||
|
let validated = book().validate().unwrap();
|
||||||
|
assert!(validated.require_observed().unwrap_err().contains("reconstructed"));
|
||||||
|
let mut observed = book();
|
||||||
|
observed.provenance = SignalProvenance::Observed;
|
||||||
|
assert!(observed.clone().validate().unwrap_err().contains("not_available"));
|
||||||
|
observed.snapshots[0].generated_at = observed.snapshots[0].decision_at;
|
||||||
|
observed.snapshots[0].published_at = observed.snapshots[0].decision_at;
|
||||||
|
seal(observed).validate().unwrap().require_observed().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_future_inputs_and_model_knowledge() {
|
||||||
|
for field in 0..3 {
|
||||||
|
let mut value = book();
|
||||||
|
let future = value.snapshots[0].decision_at + Duration::seconds(1);
|
||||||
|
match field {
|
||||||
|
0 => value.snapshots[0].input_as_of = future,
|
||||||
|
1 => value.snapshots[0].input_available_at = future,
|
||||||
|
_ => value.knowledge_cutoff = Some(future),
|
||||||
|
}
|
||||||
|
assert!(value.validate().unwrap_err().contains("future"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_quantities_prices_and_unknown_signal_fields() {
|
||||||
|
for name in ["quantity", "execution_price", "account_id", "cash"] {
|
||||||
|
let mut action = json!({"kind":"target_weight","symbol":"000001.SZ","weight":0.5});
|
||||||
|
action[name] = json!(100);
|
||||||
|
assert!(serde_json::from_value::<SignalAction>(action).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coverage_and_duplicate_actions_fail_closed() {
|
||||||
|
let mut value = book();
|
||||||
|
value.expected_decisions.push(value.expected_decisions[0] + Duration::days(1));
|
||||||
|
assert!(value.validate().unwrap_err().contains("coverage"));
|
||||||
|
let mut value = book();
|
||||||
|
value.snapshots.push(value.snapshots[0].clone());
|
||||||
|
value.expected_decisions.push(value.expected_decisions[0]);
|
||||||
|
assert!(value.validate().unwrap_err().contains("duplicate"));
|
||||||
|
let mut value = book();
|
||||||
|
let repeated = value.snapshots[0].actions[0].clone();
|
||||||
|
value.snapshots[0].actions.push(repeated);
|
||||||
|
assert!(value.validate().unwrap_err().contains("duplicate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_overallocation_nonfinite_and_ambiguous_actions() {
|
||||||
|
for weight in [f64::NAN, f64::INFINITY, -0.1, 1.1] {
|
||||||
|
let mut value = book();
|
||||||
|
value.snapshots[0].actions[0] = SignalAction::TargetWeight { symbol: "000001.SZ".into(), weight };
|
||||||
|
assert!(value.validate().is_err());
|
||||||
|
}
|
||||||
|
let mut value = book();
|
||||||
|
value.snapshots[0].actions.push(SignalAction::TargetWeight { symbol:"000002.SZ".into(),weight:0.6 });
|
||||||
|
assert!(value.validate().unwrap_err().contains("exposure"));
|
||||||
|
let mut value = book();
|
||||||
|
value.snapshots[0].actions.push(SignalAction::Exit {symbol:"000001.SZ".into()});
|
||||||
|
assert!(value.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_open_uses_decision_session_and_never_nearest_signal() {
|
||||||
|
let value = book().validate().unwrap();
|
||||||
|
let day = NaiveDate::from_ymd_opt(2025,1,7).unwrap();
|
||||||
|
assert!(value.snapshot_at(day, NaiveTime::from_hms_opt(9,30,0), true).is_ok());
|
||||||
|
assert!(value.snapshot_at(day, NaiveTime::from_hms_opt(14,59,0), false).is_err());
|
||||||
|
assert!(value.snapshot_at(day + Duration::days(1), None, true).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reduction_is_resolved_from_each_accounts_actual_position() {
|
||||||
|
let mut raw = book();
|
||||||
|
raw.snapshots[0].complete_targets = false;
|
||||||
|
raw.snapshots[0].actions = vec![SignalAction::Reduce {symbol:"000001.SZ".into(),remaining_ratio:0.5}];
|
||||||
|
let value = seal(raw).validate().unwrap();
|
||||||
|
let day = NaiveDate::from_ymd_opt(2025,1,3).unwrap();
|
||||||
|
for (held, expected) in [(1000,500),(3000,1500)] {
|
||||||
|
let mut portfolio = PortfolioState::new(100_000.0);
|
||||||
|
portfolio.position_mut("000001.SZ").buy(day,held,10.0);
|
||||||
|
let result = value.snapshot_intents(&value.book.snapshots[0],&portfolio).unwrap();
|
||||||
|
assert!(matches!(result[0],OrderIntent::TargetShares {target_quantity,..} if target_quantity==expected));
|
||||||
|
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity,held);
|
||||||
|
}
|
||||||
|
assert!(value.snapshot_intents(&value.book.snapshots[0],&PortfolioState::new(10_000.0)).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_complete_snapshot_clears_only_that_accounts_holdings() {
|
||||||
|
let mut raw = book();
|
||||||
|
raw.snapshots[0].actions.clear();
|
||||||
|
let value = seal(raw).validate().unwrap();
|
||||||
|
let day = NaiveDate::from_ymd_opt(2025,1,3).unwrap();
|
||||||
|
let mut portfolio = PortfolioState::new(100_000.0);
|
||||||
|
portfolio.position_mut("000002.SZ").buy(day,200,10.0);
|
||||||
|
let result = value.snapshot_intents(&value.book.snapshots[0],&portfolio).unwrap();
|
||||||
|
assert!(matches!(&result[0],OrderIntent::TargetPercent {symbol,target_percent,..} if symbol=="000002.SZ" && *target_percent==0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn platform_spec_consumes_book_without_running_another_selection() {
|
||||||
|
let spec = json!({"signalBook":book(),"runtimeExpressions":{"trading":{"actions":[{"kind":"consume_signal"}]}}});
|
||||||
|
let config = crate::platform_strategy_spec::platform_expr_config_from_value("signal-fixture","000001.SZ",&spec).unwrap();
|
||||||
|
assert!(!config.rotation_enabled && config.signal_book.is_some());
|
||||||
|
assert!(matches!(config.explicit_actions.as_slice(),[crate::PlatformTradeAction::ConsumeSignal]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn changed_valid_contents_must_not_reuse_a_version_hash() {
|
||||||
|
let mut raw=book();
|
||||||
|
raw.snapshots[0].actions=vec![SignalAction::TargetWeight{symbol:"000001.SZ".into(),weight:0.4}];
|
||||||
|
assert_eq!(raw.clone().validate().unwrap_err(),"signal_book_content_hash_mismatch");
|
||||||
|
seal(raw).validate().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completed_daily_inputs_may_be_published_after_market_close() {
|
||||||
|
let mut raw=book();
|
||||||
|
raw.expected_decisions=vec!["2026-07-07T09:30:00+08:00".parse().unwrap()];
|
||||||
|
raw.snapshots[0].decision_at=raw.expected_decisions[0];
|
||||||
|
raw.snapshots[0].input_as_of="2026-07-06T15:30:00+08:00".parse().unwrap();
|
||||||
|
raw.snapshots[0].input_available_at="2026-07-06T16:00:00+08:00".parse().unwrap();
|
||||||
|
raw.snapshots[0].signal_at=raw.snapshots[0].input_available_at;
|
||||||
|
raw.snapshots[0].generated_at=raw.snapshots[0].input_available_at;
|
||||||
|
raw.snapshots[0].published_at=raw.snapshots[0].generated_at;
|
||||||
|
raw.provenance=SignalProvenance::Observed;
|
||||||
|
seal(raw).validate().unwrap().require_observed().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -264,6 +264,10 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
|||||||
title: "期货 runtime action 与提交校验".to_string(),
|
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(),
|
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 {
|
ManualSection {
|
||||||
title: "trading.rotation / order.* / order.modify / cancel.* / update_universe / subscribe".to_string(),
|
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(),
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ use chrono::{Duration, NaiveDate, NaiveTime};
|
|||||||
use fidc_core::{
|
use fidc_core::{
|
||||||
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
|
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
|
||||||
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
||||||
IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
|
Instrument, IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
|
||||||
StrategyDecision,
|
StrategyDecision,
|
||||||
};
|
};
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
@@ -16,6 +16,18 @@ fn t(hour: u32, minute: u32, second: u32) -> NaiveTime {
|
|||||||
NaiveTime::from_hms_opt(hour, minute, second).expect("valid time")
|
NaiveTime::from_hms_opt(hour, minute, second).expect("valid time")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fixture_instruments() -> Vec<Instrument> {
|
||||||
|
vec![Instrument {
|
||||||
|
symbol: "000001.SZ".to_string(),
|
||||||
|
name: "quote-plan-fixture".to_string(),
|
||||||
|
board: "SZ".to_string(),
|
||||||
|
round_lot: 100,
|
||||||
|
listed_at: Some(d(2020, 1, 1)),
|
||||||
|
delisted_at: None,
|
||||||
|
status: "active".to_string(),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct DecisionQuoteReader {
|
struct DecisionQuoteReader {
|
||||||
day_count: usize,
|
day_count: usize,
|
||||||
@@ -90,7 +102,7 @@ impl Strategy for NoLoaderDecisionQuoteStrategy {
|
|||||||
|
|
||||||
fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
|
fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
|
||||||
DataSet::from_components(
|
DataSet::from_components(
|
||||||
Vec::new(),
|
fixture_instruments(),
|
||||||
vec![DailyMarketSnapshot {
|
vec![DailyMarketSnapshot {
|
||||||
date,
|
date,
|
||||||
symbol: "000001.SZ".to_string(),
|
symbol: "000001.SZ".to_string(),
|
||||||
@@ -253,7 +265,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
|||||||
let first = d(2026, 1, 5);
|
let first = d(2026, 1, 5);
|
||||||
let second = d(2026, 1, 6);
|
let second = d(2026, 1, 6);
|
||||||
let data = DataSet::from_components(
|
let data = DataSet::from_components(
|
||||||
Vec::new(),
|
fixture_instruments(),
|
||||||
vec![
|
vec![
|
||||||
DailyMarketSnapshot {
|
DailyMarketSnapshot {
|
||||||
date: first,
|
date: first,
|
||||||
@@ -423,7 +435,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
|||||||
let first = d(2026, 1, 5);
|
let first = d(2026, 1, 5);
|
||||||
let second = d(2026, 1, 6);
|
let second = d(2026, 1, 6);
|
||||||
let data = DataSet::from_components_with_actions_and_quotes(
|
let data = DataSet::from_components_with_actions_and_quotes(
|
||||||
Vec::new(),
|
fixture_instruments(),
|
||||||
vec![
|
vec![
|
||||||
DailyMarketSnapshot {
|
DailyMarketSnapshot {
|
||||||
date: first,
|
date: first,
|
||||||
@@ -658,7 +670,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
|||||||
let first = d(2026, 1, 5);
|
let first = d(2026, 1, 5);
|
||||||
let second = d(2026, 1, 6);
|
let second = d(2026, 1, 6);
|
||||||
let data = DataSet::from_components(
|
let data = DataSet::from_components(
|
||||||
Vec::new(),
|
fixture_instruments(),
|
||||||
vec![
|
vec![
|
||||||
DailyMarketSnapshot {
|
DailyMarketSnapshot {
|
||||||
date: first,
|
date: first,
|
||||||
|
|||||||
@@ -2143,15 +2143,15 @@ fn strategy_context_exposes_advanced_data_helpers() {
|
|||||||
fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
||||||
let date = d(2025, 1, 2);
|
let date = d(2025, 1, 2);
|
||||||
let data = DataSet::from_components_with_actions_and_quotes(
|
let data = DataSet::from_components_with_actions_and_quotes(
|
||||||
vec![Instrument {
|
["000001.SZ", "000002.SZ"].into_iter().map(|symbol| Instrument {
|
||||||
symbol: "000001.SZ".to_string(),
|
symbol: symbol.to_string(),
|
||||||
name: "Anchor".to_string(),
|
name: "Anchor".to_string(),
|
||||||
board: "SZ".to_string(),
|
board: "SZ".to_string(),
|
||||||
round_lot: 100,
|
round_lot: 100,
|
||||||
listed_at: Some(d(2020, 1, 1)),
|
listed_at: Some(d(2020, 1, 1)),
|
||||||
delisted_at: None,
|
delisted_at: None,
|
||||||
status: "active".to_string(),
|
status: "active".to_string(),
|
||||||
}],
|
}).collect(),
|
||||||
vec![DailyMarketSnapshot {
|
vec![DailyMarketSnapshot {
|
||||||
date,
|
date,
|
||||||
symbol: "000001.SZ".to_string(),
|
symbol: "000001.SZ".to_string(),
|
||||||
@@ -2174,7 +2174,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
|||||||
upper_limit: 11.0,
|
upper_limit: 11.0,
|
||||||
lower_limit: 9.0,
|
lower_limit: 9.0,
|
||||||
price_tick: 0.01,
|
price_tick: 0.01,
|
||||||
}],
|
}, market_row(date, "000002.SZ", 20.0, 20.4)],
|
||||||
vec![DailyFactorSnapshot {
|
vec![DailyFactorSnapshot {
|
||||||
date,
|
date,
|
||||||
symbol: "000001.SZ".to_string(),
|
symbol: "000001.SZ".to_string(),
|
||||||
@@ -4162,7 +4162,7 @@ impl Strategy for BuyMissingRowThenHoldStrategy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn engine_carries_position_price_when_current_market_row_is_missing() {
|
fn engine_rejects_an_unexplained_missing_holding_close() {
|
||||||
let date1 = d(2025, 5, 26);
|
let date1 = d(2025, 5, 26);
|
||||||
let date2 = d(2025, 5, 27);
|
let date2 = d(2025, 5, 27);
|
||||||
let data = DataSet::from_components(
|
let data = DataSet::from_components(
|
||||||
@@ -4230,20 +4230,16 @@ fn engine_carries_position_price_when_current_market_row_is_missing() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let result = engine
|
let error = engine
|
||||||
.run()
|
.run()
|
||||||
.expect("backtest should not fail on one missing holding row");
|
.expect_err("unknown missing market data must not become a carried close");
|
||||||
assert_eq!(result.equity_curve.len(), 2);
|
let detail = format!("{error:?}");
|
||||||
assert!(
|
assert!(detail.contains("MissingSnapshot") && detail.contains("close price"));
|
||||||
result
|
assert!(detail.contains("601028.SH") && detail.contains("2025-05-27"));
|
||||||
.daily_holdings
|
|
||||||
.iter()
|
|
||||||
.any(|holding| holding.date == date2 && holding.symbol == "601028.SH")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn platform_strategy_skips_position_stop_take_when_current_market_row_is_missing() {
|
fn platform_strategy_cannot_hide_missing_valuation_by_skipping_stop_take() {
|
||||||
let date1 = d(2025, 5, 26);
|
let date1 = d(2025, 5, 26);
|
||||||
let date2 = d(2025, 5, 27);
|
let date2 = d(2025, 5, 27);
|
||||||
let data = DataSet::from_components(
|
let data = DataSet::from_components(
|
||||||
@@ -4333,14 +4329,10 @@ fn platform_strategy_skips_position_stop_take_when_current_market_row_is_missing
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let result = engine
|
let error = engine
|
||||||
.run()
|
.run()
|
||||||
.expect("platform strategy should hold through a missing current market row");
|
.expect_err("skipping a stop condition cannot fabricate the missing valuation");
|
||||||
assert_eq!(result.equity_curve.len(), 2);
|
let detail = format!("{error:?}");
|
||||||
assert!(
|
assert!(detail.contains("MissingSnapshot") && detail.contains("close price"));
|
||||||
result
|
assert!(detail.contains("601028.SH") && detail.contains("2025-05-27"));
|
||||||
.daily_holdings
|
|
||||||
.iter()
|
|
||||||
.any(|holding| holding.date == date2 && holding.symbol == "601028.SH")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "fidc-signal-client"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
fidc-core = { path = "../fidc-core" }
|
||||||
|
reqwest.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
//! Shared signal transport for FIDC backtest and trading services.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use fidc_core::signal_contract::{SignalBookReference,ValidatedSignalBook,cached_signal_book,register_signal_book};
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde_json::{Value,json};
|
||||||
|
|
||||||
|
#[derive(Clone,Copy)]
|
||||||
|
pub enum Purpose { Backtest, Online }
|
||||||
|
|
||||||
|
pub async fn load(client:&Client, source_url:&str, token:&str, reference:&SignalBookReference, purpose:Purpose)
|
||||||
|
-> Result<Arc<ValidatedSignalBook>,String>
|
||||||
|
{
|
||||||
|
reference.validate()?;
|
||||||
|
if token.len()<32 {return Err("signal_service_auth_not_configured".into());}
|
||||||
|
let purpose_name=match purpose {Purpose::Backtest=>"backtest",Purpose::Online=>"online"};
|
||||||
|
let payload=json!({"reference":reference,"purpose":purpose_name});
|
||||||
|
let root=format!("{}/api/strategy-signals/internal",source_url.trim_end_matches('/'));
|
||||||
|
// Registration/purpose validation always precedes a process-cache hit.
|
||||||
|
let response=client.post(format!("{root}/validate"))
|
||||||
|
.header("X-FIDC-Lifecycle-Token",token).json(&payload).send().await
|
||||||
|
.map_err(|_|"signal_validation_service_unavailable")?;
|
||||||
|
if !response.status().is_success() {return Err(format!("signal_validation_rejected_http_{}",response.status()));}
|
||||||
|
let validation:Value=response.json().await.map_err(|_|"signal_validation_response_invalid")?;
|
||||||
|
if validation.get("ok")!=Some(&Value::Bool(true)) || validation.get("reference")!=Some(&json!(reference)) {
|
||||||
|
return Err("signal_validation_identity_mismatch".into());
|
||||||
|
}
|
||||||
|
let book=if let Some(book)=cached_signal_book(reference)? {book} else {
|
||||||
|
let mut response=client.post(format!("{root}/book"))
|
||||||
|
.header("X-FIDC-Lifecycle-Token",token).json(&payload).send().await
|
||||||
|
.map_err(|_|"signal_book_service_unavailable")?;
|
||||||
|
if !response.status().is_success() {return Err(format!("signal_book_rejected_http_{}",response.status()));}
|
||||||
|
if response.content_length().is_some_and(|bytes|bytes>64*1024*1024) {return Err("signal_book_transport_size_exceeded".into());}
|
||||||
|
let mut bytes=Vec::new();
|
||||||
|
while let Some(chunk)=response.chunk().await.map_err(|_|"signal_book_transport_incomplete")? {
|
||||||
|
if bytes.len().saturating_add(chunk.len())>64*1024*1024 {return Err("signal_book_transport_size_exceeded".into());}
|
||||||
|
bytes.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
register_signal_book(reference,&bytes)?
|
||||||
|
};
|
||||||
|
if matches!(purpose,Purpose::Online) {book.require_observed()?;}
|
||||||
|
Ok(book)
|
||||||
|
}
|
||||||
@@ -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`。
|
||||||
|
- 官方部署日志与独立探针源码保存在相同证据根,不写入交易数据库或修改原始行情。
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 生命周期、价格缺失与历史状态
|
||||||
|
|
||||||
|
证券有效区间为 `[listed_at, delisted_at)`。无明确摘牌日期的最新 terminal 标签不能反向污染历史;已知未来摘牌日不阻断此前的正常交易。退市整理期不是已摘牌。
|
||||||
|
|
||||||
|
执行价加载前分别核验证券身份、正式上市/摘牌边界。合法上市前、摘牌后不查询和补价,记录结构化原因;同一日已有正执行价与生命周期边界冲突时报错。未知身份/代码映射、上市后的分钟缺口、候选事实缺失继续失败,不因 missing candidate 而跳过校验。持仓仅在当日正式暂停交易事实成立时允许按既定估值合同沿用历史价格;普通行情缺口不再无条件沿用旧价。
|
||||||
|
|
||||||
|
整个明确证券范围尚未上市时保留官方日历内现金净值点,不缩短回测范围,不伪造成交或 OHLCV。基准只在首个基线点归一,后续无交易日不反复重置。
|
||||||
|
|
||||||
|
513 项核心测试通过,6 项原有测试忽略。新增验证包含沪深北股票和 ETF 上市前、实际摘牌日、未知证券身份、候选缺失、正式停牌和普通价格缺口、全池上市前现金期间。对单个正式分区的数据缺口仍需数据源修复,不从这些测试外推全市场完整性。
|
||||||
|
|
||||||
|
## 真实边界回放补充
|
||||||
|
|
||||||
|
177 回测 `btr_1789041425783_797911_1`:920038.BJ,2026-08-04 至 08-07。真实上市日08-05,原结果只保留08-05至08-07三个净值点。原因是准备面同时加载基准000300.SH,基准不是交易候选但参与了“全部证券生命周期外”的判定。现在只排除已声明且没有交易候选记录的基准,不按代码或名称猜测指数,也不把真实候选排除;补充真实准备结构的回归后,4日现金区间完整保留。
|
||||||
|
|
||||||
|
该草稿沿用源池 `rejectBjseSelection=false`、`rejectBjseBuy=true`,所以选中北交所但不下单符合其买入政策;原规划阶段没有记录拒绝原因则是审计缺项。新增 `scope=buy, stage=buy_planning` 审计,不伪造订单ID,不把买入否决改写成选股排除。测试验证禁止时无订单且有bjse原因,放开买入政策时正常生成意图。最新核心514项通过、6项原有忽略。
|
||||||
Reference in New Issue
Block a user