Compare commits

...

49 Commits

Author SHA1 Message Date
boris 6160a74d2a 合并主分支最新因子元数据合同 2026-09-11 15:01:29 +08:00
boris d847cb5c28 修正回撤指标的初始净值基线并补充回归测试 2026-09-11 15:00:02 +08:00
boris fa0b316a8b refactor: separate expression metadata and tests from numerical identity 2026-09-11 13:26:39 +08:00
boris 21786187c9 feat: publish typed native indicator parameter domains 2026-09-11 12:52:28 +08:00
boris e0bed38184 Merge remote-tracking branch 'refs/remotes/highmem177/main' 2026-09-11 12:14:35 +08:00
boris c0b78846d6 fix: preserve frozen stock pool candidate order through execution 2026-09-11 12:14:33 +08:00
boris 9d72567b99 test: use the projection module state in calibration failure regression 2026-09-11 12:09:33 +08:00
boris e47228beff fix: reject invalid historical slippage bounds before execution 2026-09-11 11:57:59 +08:00
boris 1fc8a3a9e6 test: enforce causal historical slippage and reject missing calibration 2026-09-11 11:42:21 +08:00
boris 98199c02a2 refactor: isolate historical slippage calibration and propagate pricing errors 2026-09-11 11:35:58 +08:00
boris 6eaa06c1d6 docs: record per-leg price risk release and unchanged minute replay 2026-09-11 10:44:15 +08:00
boris 7e0877b586 fix: validate price risk on every execution leg before and after slippage 2026-09-11 10:21:46 +08:00
boris 36833b7a6a docs: distinguish merged trading tests from published runtime 2026-09-11 10:12:38 +08:00
boris 4c0157b66c docs: record pre-existing realtime quota outage and merged-main test scope 2026-09-11 10:08:40 +08:00
boris f7d16fb664 Merge remote-tracking branch 'origin/main' 2026-09-11 10:03:34 +08:00
boris 97cdfa5972 docs: record execution-price release and open capacity audit blockers 2026-09-11 10:01:37 +08:00
boris f2e228e0a3 合并最新执行风控与策略保护内核 2026-09-11 09:52:51 +08:00
boris 33924b1fba 统一策略成交保护与锁定周期并修正日期条件覆盖 2026-09-11 09:52:04 +08:00
boris d3c36e9478 fix: retain precise missing execution quote diagnostics during risk checks 2026-09-11 09:14:58 +08:00
boris c32807db1d test: distinguish next-open one-yuan execution from later close classification 2026-09-11 09:11:28 +08:00
boris 20d723e2a1 test: inspect broker fill events in shared signal account audit 2026-09-11 09:08:57 +08:00
boris c2b939e818 fix: price one-yuan buy risk at execution and audit account-local signal exits 2026-09-11 09:02:52 +08:00
boris 6684f48f95 test: provide the required benchmark in clock-only dataset fixtures 2026-09-11 05:28:40 +08:00
boris adbfadcc07 test: use canonical local timestamps in signal consumption cases 2026-09-11 05:25:18 +08:00
boris cb6f57be6f fix: bind signal availability to the actual consumption clock 2026-09-11 05:23:33 +08:00
boris e75db2d7b0 test: declare daily market coverage for both minute subscriptions 2026-09-11 04:58:00 +08:00
boris e42dc6938b test: align minute fixtures and missing-data assertions with strict contracts 2026-09-11 04:56:25 +08:00
boris 4f4c1ab7e0 fix: canonicalize all execution limit aliases consistently 2026-09-11 04:48:56 +08:00
boris e549a23c66 统一登记信号引用的策略字段别名 2026-09-11 01:45:46 +08:00
boris 4b21fc4f3f 开放共享策略别名规范化入口供服务统一使用 2026-09-11 01:32:05 +08:00
boris e4bac1cf40 合并最新内核并保留严格数值别名校验 2026-09-11 01:04:27 +08:00
boris 1de96494b3 统一策略数值别名并保留精确冲突校验 2026-09-11 01:03:22 +08:00
boris d9bac529d6 feat: expose frozen signal generator identity to execution clients 2026-09-11 00:04:08 +08:00
boris 3e8d652af1 merge: keep verified dependency lock and canonical v2 signal kernel 2026-09-10 23:50:04 +08:00
boris 23043ee18b build: preserve generated signal client dependency resolution 2026-09-10 23:48:54 +08:00
boris c7c2e69b88 合并已登记信号读取与稳定跨语言数值摘要 2026-09-10 23:30:41 +08:00
boris e6746a7a0e 统一第二版信号簿字段并提供原生校验入口 2026-09-10 23:27:43 +08:00
boris 123467d7ae build: lock shared signal client dependencies 2026-09-10 23:15:59 +08:00
boris b3a3bdbdfd test: provide native signal book identity probe 2026-09-10 23:00:50 +08:00
boris 3f9cff1ee5 merge: retain audited signal identity and cross-language semantic hashing 2026-09-10 22:50:54 +08:00
boris db88abb9e0 feat: share verified signal books across backtest and trading clients 2026-09-10 22:40:59 +08:00
boris 75e5e32281 绑定信号簿内容摘要并按决策日校验日线输入 2026-09-10 22:22:03 +08:00
boris 7d05f8f7c7 为执行报价预取测试补齐明确证券生命周期 2026-09-10 22:18:50 +08:00
boris d01f32ca5b Merge branch 'main' of /tmp/fidc-signal-transfer.2sgDaY/signal.bundle 2026-09-10 22:12:56 +08:00
boris 3dd7b2bd50 fix: distinguish signal consumption and strategy decision schedule dates 2026-09-10 21:20:43 +08:00
boris c8f6ed102c feat: introduce typed account-independent signal execution contract 2026-09-10 21:19:04 +08:00
boris 4664f1a2d3 排除仅作参考的基准并记录周期买入风控拒绝 2026-09-10 20:21:44 +08:00
boris 40481e8825 按日期区分生命周期缺价并保留上市前现金区间 2026-09-10 19:09:21 +08:00
boris 2473cc04bb 为原生因子研究冻结计算内核身份 2026-09-10 16:32:21 +08:00
31 changed files with 4152 additions and 508 deletions
Generated
+999 -9
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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,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(())
}
+341 -97
View File
@@ -292,42 +292,76 @@ pub struct DynamicSlippageConfig {
impl DynamicSlippageConfig { impl DynamicSlippageConfig {
pub fn new(impact_coefficient: f64, volatility_coefficient: f64, max_ratio: f64) -> Self { pub fn new(impact_coefficient: f64, volatility_coefficient: f64, max_ratio: f64) -> Self {
Self { Self {
impact_coefficient: impact_coefficient.max(0.0), impact_coefficient,
volatility_coefficient: volatility_coefficient.max(0.0), volatility_coefficient,
max_ratio: max_ratio.max(0.0), max_ratio,
} }
} }
pub(crate) fn ratio( pub(crate) fn ratio(
&self, &self,
snapshot: &crate::data::DailyMarketSnapshot, calibration: &HistoricalSlippageCalibration,
raw_price: f64,
order_value: Option<f64>, order_value: Option<f64>,
) -> f64 { ) -> Result<f64, BacktestError> {
let daily_amount = (snapshot.volume as f64 * raw_price).max(0.0); if [self.impact_coefficient, self.volatility_coefficient, self.max_ratio]
.into_iter().any(|value| !value.is_finite() || value < 0.0)
|| self.max_ratio >= 1.0
|| order_value.is_some_and(|value| !value.is_finite() || value < 0.0)
{
return Err(BacktestError::Execution("invalid_historical_slippage_parameters_or_order_value".into()));
}
let impact_ratio = match order_value { let impact_ratio = match order_value {
Some(value) if value.is_finite() && value > 0.0 && daily_amount > 0.0 => { Some(value) if value.is_finite() && value > 0.0 => {
value / daily_amount value / calibration.turnover_proxy
} }
_ => 0.0, _ => 0.0,
}; };
let volatility_base = if snapshot.prev_close.is_finite() && snapshot.prev_close > 0.0 { let impact = if self.impact_coefficient == 0.0 { 0.0 } else { impact_ratio * self.impact_coefficient };
snapshot.prev_close let ratio = impact
} else { + calibration.range_ratio * self.volatility_coefficient;
raw_price Ok(ratio.clamp(0.0, self.max_ratio))
}; }
let volatility = if snapshot.high.is_finite() }
&& snapshot.low.is_finite()
&& volatility_base.is_finite() #[derive(Debug, Clone, Copy)]
&& volatility_base > 0.0 pub(crate) struct HistoricalSlippageCalibration {
source_date: NaiveDate,
turnover_proxy: f64,
range_ratio: f64,
}
impl HistoricalSlippageCalibration {
pub(crate) fn for_execution(data: &DataSet, date: NaiveDate, symbol: &str) -> Result<Self, BacktestError> {
let missing = || BacktestError::Execution(format!(
"historical_slippage_calibration_missing symbol={symbol} execution_date={date} policy=previous_completed_session"
));
let previous_date = data.previous_trading_date(date, 1).ok_or_else(missing)?;
let row = data.market(previous_date, symbol).ok_or_else(missing)?;
Self::from_completed_snapshot(row, date)
}
fn from_completed_snapshot(
row: &crate::data::DailyMarketSnapshot,
execution_date: NaiveDate,
) -> Result<Self, BacktestError> {
let turnover_proxy = row.volume as f64 * row.close;
let range_ratio = (row.high - row.low) / row.prev_close;
if row.date >= execution_date
|| [row.high, row.low, row.close, row.prev_close, turnover_proxy]
.into_iter().any(|value| !value.is_finite() || value <= 0.0)
|| row.high < row.low
|| !range_ratio.is_finite()
{ {
((snapshot.high - snapshot.low).abs() / volatility_base).max(0.0) return Err(BacktestError::Execution(format!(
} else { "historical_slippage_calibration_invalid symbol={} source_date={} execution_date={} volume={} high={} low={} close={} prev_close={}",
0.0 row.symbol, row.date, execution_date, row.volume, row.high, row.low, row.close, row.prev_close,
}; )));
let ratio = }
impact_ratio * self.impact_coefficient + volatility * self.volatility_coefficient; Ok(Self {
ratio.clamp(0.0, self.max_ratio) source_date: row.date,
turnover_proxy,
range_ratio,
})
} }
} }
@@ -343,7 +377,7 @@ pub enum SlippageModel {
PriceRatio(f64), PriceRatio(f64),
TickSize(f64), TickSize(f64),
LimitPrice, LimitPrice,
Dynamic(DynamicSlippageConfig), HistoricalVolumeVolatility(DynamicSlippageConfig),
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -381,6 +415,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 +450,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 +489,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),
@@ -1103,12 +1143,28 @@ where
fn snapshot_execution_price( fn snapshot_execution_price(
&self, &self,
data: &DataSet,
snapshot: &crate::data::DailyMarketSnapshot, snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide, side: OrderSide,
quantity: Option<u32>, quantity: Option<u32>,
) -> f64 { ) -> Result<f64, BacktestError> {
let raw_price = self.snapshot_raw_execution_price(snapshot, side); let raw_price = self.snapshot_raw_execution_price(snapshot, side);
self.apply_slippage(snapshot, side, raw_price, quantity) let calibration = self.slippage_calibration(data, snapshot)?;
self.apply_slippage(snapshot, side, raw_price, quantity, calibration.as_ref())
}
fn slippage_calibration(
&self,
data: &DataSet,
snapshot: &crate::data::DailyMarketSnapshot,
) -> Result<Option<HistoricalSlippageCalibration>, BacktestError> {
if !matches!(self.slippage_model, SlippageModel::HistoricalVolumeVolatility(_))
|| self.is_open_auction_matching()
|| self.is_post_close_fixed_price(snapshot.date)
{
return Ok(None);
}
HistoricalSlippageCalibration::for_execution(data, snapshot.date, &snapshot.symbol).map(Some)
} }
fn snapshot_raw_execution_price( fn snapshot_raw_execution_price(
@@ -1178,17 +1234,18 @@ where
side: OrderSide, side: OrderSide,
raw_price: f64, raw_price: f64,
quantity: Option<u32>, quantity: Option<u32>,
) -> f64 { calibration: Option<&HistoricalSlippageCalibration>,
) -> Result<f64, BacktestError> {
if !raw_price.is_finite() || raw_price <= 0.0 { if !raw_price.is_finite() || raw_price <= 0.0 {
return raw_price; return Ok(raw_price);
} }
if self.is_open_auction_matching() { if self.is_open_auction_matching() {
return self.clamp_execution_price(snapshot, side, raw_price); return Ok(self.clamp_execution_price(snapshot, side, raw_price));
} }
if self.is_post_close_fixed_price(snapshot.date) { if self.is_post_close_fixed_price(snapshot.date) {
return self.clamp_execution_price(snapshot, side, raw_price); return Ok(self.clamp_execution_price(snapshot, side, raw_price));
} }
let order_value = quantity.and_then(|qty| (qty > 0).then_some(raw_price * qty as f64)); let order_value = quantity.and_then(|qty| (qty > 0).then_some(raw_price * qty as f64));
@@ -1210,8 +1267,12 @@ where
} }
} }
SlippageModel::LimitPrice => raw_price, SlippageModel::LimitPrice => raw_price,
SlippageModel::Dynamic(config) => { SlippageModel::HistoricalVolumeVolatility(config) => {
let ratio = config.ratio(snapshot, raw_price, order_value); let calibration = calibration.filter(|value| value.source_date < snapshot.date)
.ok_or_else(|| BacktestError::Execution(format!(
"historical_slippage_calibration_required symbol={} execution_date={}", snapshot.symbol, snapshot.date,
)))?;
let ratio = config.ratio(calibration, order_value)?;
match side { match side {
OrderSide::Buy => raw_price * (1.0 + ratio), OrderSide::Buy => raw_price * (1.0 + ratio),
OrderSide::Sell => raw_price * (1.0 - ratio), OrderSide::Sell => raw_price * (1.0 - ratio),
@@ -1225,7 +1286,7 @@ where
adjusted *= 1.0 + self.sell_then_buy_delay_slippage_rate; adjusted *= 1.0 + self.sell_then_buy_delay_slippage_rate;
} }
self.clamp_execution_price(snapshot, side, adjusted) Ok(self.clamp_execution_price(snapshot, side, adjusted))
} }
fn clamp_execution_price( fn clamp_execution_price(
@@ -1260,8 +1321,9 @@ where
side: OrderSide, side: OrderSide,
raw_price: f64, raw_price: f64,
quantity: Option<u32>, quantity: Option<u32>,
) -> f64 { calibration: Option<&HistoricalSlippageCalibration>,
self.apply_slippage(snapshot, side, raw_price, quantity) ) -> Result<f64, BacktestError> {
self.apply_slippage(snapshot, side, raw_price, quantity, calibration)
} }
fn matching_type_for_algo_request( fn matching_type_for_algo_request(
@@ -1389,6 +1451,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 +1465,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);
@@ -1564,7 +1633,7 @@ where
.unwrap_or(0); .unwrap_or(0);
if target_qty > current_qty { if target_qty > current_qty {
let requested_qty = target_qty - current_qty; let requested_qty = target_qty - current_qty;
if !self.can_afford_minimum_buy(date, portfolio, data, &symbol) { if !self.can_afford_minimum_buy(date, portfolio, data, &symbol)? {
if report.diagnostics.len() < 32 { if report.diagnostics.len() < 32 {
report.diagnostics.push(format!( report.diagnostics.push(format!(
"rebalance_buy_reduced symbol={} provisional={} final={} current={} reason=actual_cash_after_sells", "rebalance_buy_reduced symbol={} provisional={} final={} current={} reason=actual_cash_after_sells",
@@ -2850,6 +2919,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;
{ {
@@ -3363,7 +3441,7 @@ where
price, price,
minimum_order_quantity, minimum_order_quantity,
order_step_size, order_step_size,
)) )?)
} else { } else {
self.round_buy_quantity( self.round_buy_quantity(
(target_value / price).floor() as u32, (target_value / price).floor() as u32,
@@ -3419,15 +3497,17 @@ where
let buy_execution_price = data let buy_execution_price = data
.market(date, &symbol) .market(date, &symbol)
.map(|snapshot| { .map(|snapshot| {
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(buy_quantity)) self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(buy_quantity))
}) })
.transpose()?
.filter(|execution_price| execution_price.is_finite() && *execution_price > 0.0) .filter(|execution_price| execution_price.is_finite() && *execution_price > 0.0)
.unwrap_or(price); .unwrap_or(price);
let sell_execution_price = data let sell_execution_price = data
.market(date, &symbol) .market(date, &symbol)
.map(|snapshot| { .map(|snapshot| {
self.snapshot_execution_price(snapshot, OrderSide::Sell, Some(sell_quantity)) self.snapshot_execution_price(data, snapshot, OrderSide::Sell, Some(sell_quantity))
}) })
.transpose()?
.filter(|execution_price| execution_price.is_finite() && *execution_price > 0.0) .filter(|execution_price| execution_price.is_finite() && *execution_price > 0.0)
.unwrap_or(price); .unwrap_or(price);
if desired_qty < current_qty if desired_qty < current_qty
@@ -3757,7 +3837,7 @@ where
continue; continue;
} }
let buy_qty = target_qty - current_qty; let buy_qty = target_qty - current_qty;
if !self.can_afford_minimum_buy(date, portfolio, data, symbol) { if !self.can_afford_minimum_buy(date, portfolio, data, symbol)? {
if report.diagnostics.len() < 32 { if report.diagnostics.len() < 32 {
report.diagnostics.push(format!( report.diagnostics.push(format!(
"rebalance_buy_reduced symbol={} provisional={} final={} current={} reason=actual_cash_after_sells", "rebalance_buy_reduced symbol={} provisional={} final={} current={} reason=actual_cash_after_sells",
@@ -4139,6 +4219,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;
} }
@@ -4258,9 +4341,9 @@ where
portfolio: &PortfolioState, portfolio: &PortfolioState,
data: &DataSet, data: &DataSet,
symbol: &str, symbol: &str,
) -> bool { ) -> Result<bool, BacktestError> {
let Some(snapshot) = data.market(date, symbol) else { let Some(snapshot) = data.market(date, symbol) else {
return true; return Ok(true);
}; };
let minimum_order_quantity = self.minimum_order_quantity(data, symbol); let minimum_order_quantity = self.minimum_order_quantity(data, symbol);
let order_step_size = self.order_step_size(data, symbol); let order_step_size = self.order_step_size(data, symbol);
@@ -4270,14 +4353,14 @@ where
order_step_size, order_step_size,
); );
if minimum_buy_quantity == 0 { if minimum_buy_quantity == 0 {
return false; return Ok(false);
} }
let minimum_execution_price = let minimum_execution_price =
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(minimum_buy_quantity)); self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(minimum_buy_quantity))?;
Self::fixed_cash_fits( Ok(Self::fixed_cash_fits(
self.estimated_buy_cash_out(date, minimum_execution_price, minimum_buy_quantity), self.estimated_buy_cash_out(date, minimum_execution_price, minimum_buy_quantity),
portfolio.cash(), portfolio.cash(),
) ))
} }
fn process_sell( fn process_sell(
@@ -4299,6 +4382,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(());
@@ -4681,7 +4768,7 @@ where
None, None,
algo_request, algo_request,
limit_price, limit_price,
); )?;
let (filled_qty, execution_legs, next_cursor, liquidity_consumption) = if let Some(fill) = let (filled_qty, execution_legs, next_cursor, liquidity_consumption) = if let Some(fill) =
fill fill
{ {
@@ -4695,7 +4782,7 @@ where
) )
} else { } else {
let execution_price = let execution_price =
self.snapshot_execution_price(snapshot, OrderSide::Sell, Some(fillable_qty)); self.snapshot_execution_price(data, snapshot, OrderSide::Sell, Some(fillable_qty))?;
if let Some(reason) = if let Some(reason) =
self.execution_limit_rejection_reason(snapshot, OrderSide::Sell, execution_price) self.execution_limit_rejection_reason(snapshot, OrderSide::Sell, execution_price)
{ {
@@ -6074,6 +6161,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 +6256,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 +6310,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)
@@ -6406,7 +6496,7 @@ where
value_gross_limit, value_gross_limit,
algo_request, algo_request,
limit_price, limit_price,
); )?;
let (filled_qty, execution_legs, next_cursor, liquidity_consumption) = if let Some(fill) = let (filled_qty, execution_legs, next_cursor, liquidity_consumption) = if let Some(fill) =
fill fill
{ {
@@ -6420,7 +6510,7 @@ where
) )
} else { } else {
let execution_price = let execution_price =
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(constrained_qty)); self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(constrained_qty))?;
if let Some(reason) = if let Some(reason) =
self.execution_limit_rejection_reason(snapshot, OrderSide::Buy, execution_price) self.execution_limit_rejection_reason(snapshot, OrderSide::Buy, execution_price)
{ {
@@ -6462,10 +6552,11 @@ where
let mut blocked_by_final_price = false; let mut blocked_by_final_price = false;
if filled_qty > 0 { if filled_qty > 0 {
execution_price = self.snapshot_execution_price( execution_price = self.snapshot_execution_price(
data,
snapshot, snapshot,
OrderSide::Buy, OrderSide::Buy,
Some(filled_qty), Some(filled_qty),
); )?;
match self.execution_price_with_limit_slippage_or_rejection( match self.execution_price_with_limit_slippage_or_rejection(
snapshot, snapshot,
OrderSide::Buy, OrderSide::Buy,
@@ -7053,7 +7144,7 @@ where
fallback_price: f64, fallback_price: f64,
minimum_order_quantity: u32, minimum_order_quantity: u32,
order_step_size: u32, order_step_size: u32,
) -> u32 { ) -> Result<u32, BacktestError> {
let snapshot = data.market(date, symbol); let snapshot = data.market(date, symbol);
let mut quantity = self.value_buy_quantity( let mut quantity = self.value_buy_quantity(
date, date,
@@ -7065,8 +7156,9 @@ where
for _ in 0..8 { for _ in 0..8 {
let execution_price = snapshot let execution_price = snapshot
.map(|snapshot| { .map(|snapshot| {
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(quantity)) self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(quantity))
}) })
.transpose()?
.filter(|price| price.is_finite() && *price > 0.0) .filter(|price| price.is_finite() && *price > 0.0)
.unwrap_or(fallback_price); .unwrap_or(fallback_price);
let resolved = self.value_buy_quantity( let resolved = self.value_buy_quantity(
@@ -7077,27 +7169,28 @@ where
order_step_size, order_step_size,
); );
if resolved == quantity { if resolved == quantity {
return quantity; return Ok(quantity);
} }
quantity = resolved; quantity = resolved;
} }
while quantity >= minimum_order_quantity.max(1) { while quantity >= minimum_order_quantity.max(1) {
let execution_price = snapshot let execution_price = snapshot
.map(|snapshot| { .map(|snapshot| {
self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(quantity)) self.snapshot_execution_price(data, snapshot, OrderSide::Buy, Some(quantity))
}) })
.transpose()?
.filter(|price| price.is_finite() && *price > 0.0) .filter(|price| price.is_finite() && *price > 0.0)
.unwrap_or(fallback_price); .unwrap_or(fallback_price);
if Self::fixed_cash_fits( if Self::fixed_cash_fits(
self.estimated_buy_cash_out(date, execution_price, quantity), self.estimated_buy_cash_out(date, execution_price, quantity),
value_budget, value_budget,
) { ) {
return quantity; return Ok(quantity);
} }
quantity = quantity =
self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size); self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size);
} }
0 Ok(0)
} }
fn decrement_order_quantity( fn decrement_order_quantity(
@@ -7286,9 +7379,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) =>
@@ -7358,14 +7457,14 @@ where
gross_limit: Option<f64>, gross_limit: Option<f64>,
algo_request: Option<&AlgoExecutionRequest>, algo_request: Option<&AlgoExecutionRequest>,
limit_price: Option<f64>, limit_price: Option<f64>,
) -> Option<ExecutionFill> { ) -> Result<Option<ExecutionFill>, BacktestError> {
let matching_type = self.matching_type_for_algo_request(algo_request); let matching_type = self.matching_type_for_algo_request(algo_request);
let post_close_window = self.post_close_execution_window(date); let post_close_window = self.post_close_execution_window(date);
let use_intraday_quotes = post_close_window.is_some() let use_intraday_quotes = post_close_window.is_some()
|| algo_request.is_some() || algo_request.is_some()
|| self.matching_type_uses_intraday_quotes(); || self.matching_type_uses_intraday_quotes();
if !use_intraday_quotes { if !use_intraday_quotes {
return None; return Ok(None);
} }
let runtime_start_time = self.runtime_intraday_start_time.get(); let runtime_start_time = self.runtime_intraday_start_time.get();
@@ -7392,6 +7491,7 @@ where
end_cursor end_cursor
}; };
let quotes = data.execution_quotes_on(date, symbol); let quotes = data.execution_quotes_on(date, symbol);
let calibration = self.slippage_calibration(data, snapshot)?;
if let Some(fill) = self.select_execution_fill_with_ledger( if let Some(fill) = self.select_execution_fill_with_ledger(
symbol, symbol,
@@ -7410,8 +7510,9 @@ where
gross_limit, gross_limit,
limit_price, limit_price,
execution_ledger, execution_ledger,
) { calibration.as_ref(),
return Some(fill); )? {
return Ok(Some(fill));
} }
if post_close_window.is_some() if post_close_window.is_some()
@@ -7426,7 +7527,7 @@ where
.or(self.intraday_execution_start_time) .or(self.intraday_execution_start_time)
.map(|start_time| date.and_time(start_time) + Duration::seconds(1)) .map(|start_time| date.and_time(start_time) + Duration::seconds(1))
.unwrap_or_else(|| date.and_hms_opt(0, 0, 1).expect("valid midnight")); .unwrap_or_else(|| date.and_hms_opt(0, 0, 1).expect("valid midnight"));
return Some(ExecutionFill { return Ok(Some(ExecutionFill {
quantity: 0, quantity: 0,
next_cursor, next_cursor,
legs: Vec::new(), legs: Vec::new(),
@@ -7437,10 +7538,10 @@ where
end_cursor, end_cursor,
matching_type == MatchingType::MinuteLast && start_cursor.is_some(), matching_type == MatchingType::MinuteLast && start_cursor.is_some(),
)), )),
}); }));
} }
None Ok(None)
} }
fn empty_intraday_quote_reason( fn empty_intraday_quote_reason(
@@ -7504,7 +7605,9 @@ where
gross_limit, gross_limit,
limit_price, limit_price,
&IntradayExecutionLedger::default(), &IntradayExecutionLedger::default(),
None,
) )
.expect("test quote selection without historical calibration")
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -7526,9 +7629,10 @@ where
gross_limit: Option<f64>, gross_limit: Option<f64>,
limit_price: Option<f64>, limit_price: Option<f64>,
execution_ledger: &IntradayExecutionLedger, execution_ledger: &IntradayExecutionLedger,
) -> Option<ExecutionFill> { calibration: Option<&HistoricalSlippageCalibration>,
) -> Result<Option<ExecutionFill>, BacktestError> {
if requested_qty == 0 { if requested_qty == 0 {
return None; return Ok(None);
} }
let quote_quantity_limited = let quote_quantity_limited =
@@ -7591,6 +7695,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 {
@@ -7671,7 +7780,7 @@ where
} }
let mut quote_price = let mut quote_price =
self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty)); self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty), calibration)?;
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price) if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price)
{ {
execution_block_reason.get_or_insert(reason); execution_block_reason.get_or_insert(reason);
@@ -7691,7 +7800,7 @@ where
if let Some(cash) = cash_limit { if let Some(cash) = cash_limit {
while take_qty > 0 { while take_qty > 0 {
quote_price = quote_price =
self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty)); self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty), calibration)?;
if !quote_price.is_finite() || quote_price <= 0.0 { if !quote_price.is_finite() || quote_price <= 0.0 {
budget_block_reason = Some("invalid execution price"); budget_block_reason = Some("invalid execution price");
take_qty = 0; take_qty = 0;
@@ -7743,7 +7852,7 @@ where
} }
quote_price = quote_price =
self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty)); self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty), calibration)?;
quote_price = self.execution_price_with_limit_slippage(quote_price, limit_price); quote_price = self.execution_price_with_limit_slippage(quote_price, limit_price);
if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price) if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price)
{ {
@@ -7801,7 +7910,7 @@ where
if let Some(reason) = execution_block_reason if let Some(reason) = execution_block_reason
&& !saw_non_blocked_execution_price && !saw_non_blocked_execution_price
{ {
return Some(ExecutionFill { return Ok(Some(ExecutionFill {
quantity: 0, quantity: 0,
next_cursor: execution_block_timestamp next_cursor: execution_block_timestamp
.expect("blocked execution quote timestamp") .expect("blocked execution quote timestamp")
@@ -7809,12 +7918,12 @@ where
legs: Vec::new(), legs: Vec::new(),
liquidity_consumption: Vec::new(), liquidity_consumption: Vec::new(),
unfilled_reason: Some(reason), unfilled_reason: Some(reason),
}); }));
} }
return None; return Ok(None);
} }
Some(ExecutionFill { Ok(Some(ExecutionFill {
quantity: filled_qty, quantity: filled_qty,
next_cursor: last_timestamp.unwrap() + Duration::seconds(1), next_cursor: last_timestamp.unwrap() + Duration::seconds(1),
legs: if matching_type == MatchingType::Vwap { legs: if matching_type == MatchingType::Vwap {
@@ -7838,7 +7947,7 @@ where
} else { } else {
None None
}, },
}) }))
} }
fn quote_has_executable_liquidity( fn quote_has_executable_liquidity(
@@ -8086,6 +8195,88 @@ mod tests {
} }
} }
#[test]
fn historical_slippage_does_not_read_later_daily_fields_for_open_or_minute_fills() {
let previous = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let build_data = |changed: bool| {
let mut prior = dated_limit_test_snapshot(previous);
prior.timestamp = None;
let mut current = dated_limit_test_snapshot(date);
current.timestamp = None;
if changed {
current.high = 10.9;
current.low = 9.1;
current.close = 10.8;
current.volume = 400;
}
let mut quote = limit_test_quote(10.0, 10.0, 10.0);
quote.date = date;
quote.timestamp = date.and_hms_opt(13, 7, 0).unwrap();
DataSet::from_components_with_actions_and_quotes(
vec![limit_test_instrument()], vec![prior, current], Vec::new(),
vec![dated_limit_test_candidate(previous, false, false, true, true), dated_limit_test_candidate(date, false, false, true, true)],
vec![dated_limit_test_benchmark(previous), dated_limit_test_benchmark(date)],
Vec::new(), vec![quote],
).unwrap()
};
let decision = StrategyDecision {
order_intents: vec![OrderIntent::Value {
symbol: "000001.SZ".into(), value: 50_000.0, reason: "historical_model_invariance".into(),
}], ..StrategyDecision::default()
};
for matching in [MatchingType::NextBarOpen, MatchingType::MinuteLast] {
let mut results = Vec::new();
for changed in [false, true] {
let data = build_data(changed);
let mut broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(matching)
.with_volume_limit(false).with_liquidity_limit(false)
.with_slippage_model(SlippageModel::HistoricalVolumeVolatility(super::DynamicSlippageConfig::new(0.5, 0.3, 0.1)));
if matching == MatchingType::MinuteLast {
broker = broker.with_intraday_execution_start_time(NaiveTime::from_hms_opt(13, 7, 0).unwrap());
}
let mut account = PortfolioState::new(1_000_000.0);
let report = broker.execute_with_event_dates(date, previous, previous, &mut account, &data, &decision).unwrap();
assert_eq!(report.fill_events.len(), 1, "{report:?}");
results.push((serde_json::to_value(&report.fill_events).unwrap(), account.cash()));
}
assert_eq!(results[0], results[1], "{matching:?}");
}
}
#[test]
fn historical_slippage_rejects_missing_future_or_invalid_calibration_without_raw_price_fallback() {
let snapshot = limit_test_snapshot();
let date = snapshot.date;
assert!(super::HistoricalSlippageCalibration::from_completed_snapshot(&snapshot, date).is_err());
let later = date + chrono::Duration::days(1);
let mut bad = snapshot.clone();
bad.volume = 0;
assert!(super::HistoricalSlippageCalibration::from_completed_snapshot(&bad, later).is_err());
bad = snapshot.clone();
bad.high = f64::NAN;
assert!(super::HistoricalSlippageCalibration::from_completed_snapshot(&bad, later).is_err());
let calibration = super::HistoricalSlippageCalibration::from_completed_snapshot(&snapshot, later).unwrap();
assert!(super::DynamicSlippageConfig::new(f64::NAN, 0.3, 0.1).ratio(&calibration, Some(100.0)).is_err());
assert!(super::DynamicSlippageConfig::new(-1.0, 0.3, 0.1).ratio(&calibration, Some(100.0)).is_err());
assert!(super::DynamicSlippageConfig::new(0.5, 0.3, 1.0).ratio(&calibration, Some(100.0)).is_err());
let data = DataSet::from_components(vec![limit_test_instrument()], vec![snapshot], Vec::new(),
vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()]).unwrap();
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_volume_limit(false).with_liquidity_limit(false)
.with_slippage_model(SlippageModel::HistoricalVolumeVolatility(super::DynamicSlippageConfig::default()));
let mut account = PortfolioState::new(1_000_000.0);
let decision = StrategyDecision { order_intents: vec![OrderIntent::Value {
symbol: "000001.SZ".into(), value: 50_000.0, reason: "missing_calibration".into(),
}], ..StrategyDecision::default() };
let error = broker.execute(date, &mut account, &data, &decision).unwrap_err();
assert!(error.to_string().contains("historical_slippage_calibration_missing"), "{error}");
assert_eq!(account.cash(), 1_000_000.0);
assert!(account.positions().is_empty());
}
fn limit_test_candidate(allow_buy: bool, allow_sell: bool) -> CandidateEligibility { fn limit_test_candidate(allow_buy: bool, allow_sell: bool) -> CandidateEligibility {
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");
CandidateEligibility { CandidateEligibility {
@@ -8399,6 +8590,11 @@ mod tests {
let mut snapshot = dated_limit_test_snapshot(date); let mut snapshot = dated_limit_test_snapshot(date);
snapshot.close = 10.0; snapshot.close = 10.0;
snapshot.upper_limit = 20.0; snapshot.upper_limit = 20.0;
let data = DataSet::from_components(
vec![limit_test_instrument()], vec![snapshot.clone()], Vec::new(),
vec![dated_limit_test_candidate(date, false, false, true, true)],
vec![dated_limit_test_benchmark(date)],
).unwrap();
for (hour, minute) in [(14, 59), (15, 31)] { for (hour, minute) in [(14, 59), (15, 31)] {
broker broker
@@ -8409,7 +8605,7 @@ mod tests {
EquityExecutionPhase::ContinuousAuction EquityExecutionPhase::ContinuousAuction
); );
assert_eq!( assert_eq!(
broker.snapshot_execution_price(&snapshot, OrderSide::Buy, Some(100)), broker.snapshot_execution_price(&data, &snapshot, OrderSide::Buy, Some(100)).unwrap(),
12.5 12.5
); );
} }
@@ -8422,7 +8618,7 @@ mod tests {
EquityExecutionPhase::PostCloseFixedPrice EquityExecutionPhase::PostCloseFixedPrice
); );
assert_eq!( assert_eq!(
broker.snapshot_execution_price(&snapshot, OrderSide::Buy, Some(100)), broker.snapshot_execution_price(&data, &snapshot, OrderSide::Buy, Some(100)).unwrap(),
10.0 10.0
); );
} }
@@ -8564,6 +8760,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");
+8 -2
View File
@@ -3375,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)
@@ -6229,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(),
@@ -6257,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
View File
@@ -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]
@@ -0,0 +1,52 @@
//! Indicator metadata is versioned independently from the numerical kernel.
use crate::factor_events::{CONTRACT, OPERATORS, TA_REV};
use serde_json::{Value, json};
use ta_lib::abstract_api::{self, OptInputType};
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!({
"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<_>>(),
"parameters":f.opt_inputs.iter().map(|p|json!({"name":p.param_name,"label":p.display_name,"description":p.hint,"domain":parameter_domain(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,
})).collect();
json!({"contract":CONTRACT,"parameter_domain_contract":"fidc.indicator-parameter-domain/v1","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,
"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",
"minute_execution":"strictly_after_completed_bar","cross_section":"requires_separate_complete_universe_contract"}})
}
pub(crate) fn parameter_domain(kind: OptInputType) -> Value {
match kind {
OptInputType::IntegerRange { min, max, default, .. } => json!({
"value_type":"integer", "minimum":min, "maximum":max, "default":default,
}),
OptInputType::RealRange { min, max, default, precision, .. } => json!({
"value_type":"number", "minimum":min, "maximum":max,
"default":default, "display_precision":precision,
}),
OptInputType::IntegerList { values, default } => json!({
"value_type":"integer", "default":default,
"choices":values.iter().map(|(value,label)|json!({"value":value,"label":label})).collect::<Vec<_>>(),
}),
OptInputType::RealList { values, default } => json!({
"value_type":"number", "default":default,
"choices":values.iter().map(|(value,label)|json!({"value":value,"label":label})).collect::<Vec<_>>(),
}),
}
}
+5 -144
View File
@@ -1,7 +1,7 @@
//! Causal, typed indicator/event expressions shared by research and trading. //! Causal, typed indicator/event expressions shared by research and trading.
use chrono::{DateTime, FixedOffset}; use chrono::{DateTime, FixedOffset};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::Value;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use ta_lib::{ use ta_lib::{
Core, Core,
@@ -82,7 +82,7 @@ pub struct Series {
pub values: Vec<Option<f64>>, pub values: Vec<Option<f64>>,
} }
const OPERATORS: &[&str] = &[ pub(crate) const OPERATORS: &[&str] = &[
"GT", "GT",
"GTE", "GTE",
"LT", "LT",
@@ -168,27 +168,7 @@ const OPERATORS: &[&str] = &[
"IF", "IF",
]; ];
pub fn catalog() -> Value { pub use crate::factor_event_catalog::catalog;
let indicators: Vec<Value> = abstract_api::funcs().map(|f| json!({
"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<_>>(),
"parameters":f.opt_inputs.iter().map(|p|json!({"name":p.param_name,"label":p.display_name,"description":p.hint,"domain":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,
})).collect();
json!({"contract":CONTRACT,"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,
"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",
"minute_execution":"strictly_after_completed_bar","cross_section":"requires_separate_complete_universe_contract"}})
}
impl Frame { impl Frame {
pub fn validate(&self) -> Result<(), String> { pub fn validate(&self) -> Result<(), String> {
@@ -952,124 +932,5 @@ fn operator(
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "factor_events_tests.rs"]
use super::*; mod tests;
fn frame(values: Vec<Option<f64>>) -> Frame {
let start = DateTime::parse_from_rfc3339("2026-09-01T15:30:00+08:00").unwrap();
let times = (0..values.len())
.map(|i| start + chrono::Duration::days(i as i64))
.collect::<Vec<_>>();
Frame {
symbol: "TEST".into(),
frequency: "1d".into(),
decision_at: *times.last().unwrap(),
available_at: times.clone(),
timestamps: times,
fields: BTreeMap::from([("close".into(), values)]),
}
}
fn expr(v: Value) -> Expr {
serde_json::from_value(v).unwrap()
}
#[test]
fn ta_sma_real_values_and_parameter_validation() {
let frame = frame(vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0)]);
let e = expr(
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":3}}),
);
assert_eq!(
evaluate(&e, &frame).unwrap().values,
vec![None, None, Some(2.0), Some(3.0)]
);
let bad = expr(
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"period":3}}),
);
assert!(
evaluate(&bad, &frame)
.unwrap_err()
.contains("parameter_unknown")
);
}
#[test]
fn cross_is_event_not_state_and_never_uses_future() {
let f = frame(vec![
Some(9.0),
Some(10.0),
Some(11.0),
Some(12.0),
Some(8.0),
]);
let e = expr(
json!({"kind":"operator","name":"CROSS_ABOVE","args":[{"kind":"field","name":"close"},{"kind":"number","value":10.0}]}),
);
assert_eq!(
evaluate(&e, &f).unwrap().values,
vec![None, Some(0.0), Some(1.0), Some(0.0), Some(0.0)]
);
let mut invalid = f.clone();
invalid.available_at[4] = invalid.decision_at + chrono::Duration::seconds(1);
assert!(evaluate(&e, &invalid).is_err());
}
#[test]
fn missing_is_not_zero_and_breakout_excludes_current() {
let f = frame(vec![Some(1.0), Some(2.0), Some(3.0), None, Some(5.0)]);
let e = expr(
json!({"kind":"operator","name":"BREAK_HIGH","window":2,"args":[{"kind":"field","name":"close"}]}),
);
assert_eq!(
evaluate(&e, &f).unwrap().values,
vec![None, None, Some(1.0), None, None]
);
let zero = expr(
json!({"kind":"operator","name":"DIV","args":[{"kind":"field","name":"close"},{"kind":"number","value":0}]}),
);
assert!(
evaluate(&zero, &f)
.unwrap()
.values
.iter()
.all(Option::is_none)
);
}
#[test]
fn ta_rewarms_after_gap_and_const_zscore_is_unknown() {
let f = frame(vec![Some(1.0), Some(1.0), None, Some(2.0), Some(2.0)]);
let e = expr(
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":2}}),
);
assert_eq!(
evaluate(&e, &f).unwrap().values,
vec![None, Some(1.0), None, None, Some(2.0)]
);
let e = expr(
json!({"kind":"operator","name":"ZSCORE","window":2,"args":[{"kind":"field","name":"close"}]}),
);
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
}
#[test]
fn no_event_has_no_bars_since_and_type_errors_reject() {
let f = frame(vec![Some(1.0), Some(1.0), Some(1.0)]);
let state = json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":5}]});
let e = expr(json!({"kind":"operator","name":"BARS_SINCE","args":[state]}));
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
assert!(
evaluate(
&expr(
json!({"kind":"operator","name":"NOT","args":[{"kind":"field","name":"close"}]})
),
&f
)
.is_err()
);
}
#[test]
fn literal_unknown_fields_reject_and_catalog_is_not_trading_permission() {
assert!(
serde_json::from_value::<Expr>(json!({"kind":"number","value":1,"account_id":2}))
.is_err()
);
let c = catalog();
assert!(c["indicators"].as_array().unwrap().len() > 190);
assert_eq!(c["live_routing"], false);
}
}
+161
View File
@@ -0,0 +1,161 @@
use super::*;
use serde_json::json;
use crate::factor_event_catalog::parameter_domain;
#[test]
fn every_parameter_domain_is_structured_and_matches_native_defaults() {
for function in abstract_api::funcs() {
let handle = abstract_api::get_func_handle(function.name).unwrap();
let core = Core::new();
let mut call = handle.new_call(&core);
for (index, parameter) in function.opt_inputs.iter().enumerate() {
let domain = parameter_domain(parameter.kind);
let default = domain["default"].as_f64().unwrap();
assert!(default.is_finite(), "{} {}", function.name, parameter.param_name);
if let Some(choices) = domain.get("choices") {
assert!(choices.as_array().unwrap().iter().any(|v| v["value"].as_f64() == Some(default)));
} else {
assert!(default >= domain["minimum"].as_f64().unwrap());
assert!(default <= domain["maximum"].as_f64().unwrap());
}
if domain["value_type"] == "integer" {
assert_eq!(default.fract(), 0.0);
call.set_opt(index, default as i32).unwrap();
} else {
call.set_opt(index, default).unwrap();
}
}
assert!(call.lookback().is_ok(), "{}", function.name);
}
}
#[test]
fn parameter_domains_keep_enumeration_labels_without_debug_string_parsing() {
let catalog = catalog();
assert_eq!(catalog["parameter_domain_contract"], "fidc.indicator-parameter-domain/v1");
let indicators = catalog["indicators"].as_array().unwrap();
let rsi = indicators.iter().find(|v| v["name"] == "RSI").unwrap();
assert_eq!(rsi["parameters"][0]["domain"]["minimum"], 2);
let stoch = indicators.iter().find(|v| v["name"] == "STOCH").unwrap();
let ma_type = stoch["parameters"].as_array().unwrap().iter().find(|p| p["name"] == "optInSlowK_MAType").unwrap();
assert!(ma_type["domain"]["choices"].as_array().unwrap().iter().any(|v| v["label"] == "EMA" && v["value"] == 1));
}
fn frame(values: Vec<Option<f64>>) -> Frame {
let start = DateTime::parse_from_rfc3339("2026-09-01T15:30:00+08:00").unwrap();
let times = (0..values.len())
.map(|i| start + chrono::Duration::days(i as i64))
.collect::<Vec<_>>();
Frame {
symbol: "TEST".into(),
frequency: "1d".into(),
decision_at: *times.last().unwrap(),
available_at: times.clone(),
timestamps: times,
fields: BTreeMap::from([("close".into(), values)]),
}
}
fn expr(v: Value) -> Expr {
serde_json::from_value(v).unwrap()
}
#[test]
fn ta_sma_real_values_and_parameter_validation() {
let frame = frame(vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0)]);
let e = expr(
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":3}}),
);
assert_eq!(
evaluate(&e, &frame).unwrap().values,
vec![None, None, Some(2.0), Some(3.0)]
);
let bad = expr(
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"period":3}}),
);
assert!(
evaluate(&bad, &frame)
.unwrap_err()
.contains("parameter_unknown")
);
}
#[test]
fn cross_is_event_not_state_and_never_uses_future() {
let f = frame(vec![
Some(9.0),
Some(10.0),
Some(11.0),
Some(12.0),
Some(8.0),
]);
let e = expr(
json!({"kind":"operator","name":"CROSS_ABOVE","args":[{"kind":"field","name":"close"},{"kind":"number","value":10.0}]}),
);
assert_eq!(
evaluate(&e, &f).unwrap().values,
vec![None, Some(0.0), Some(1.0), Some(0.0), Some(0.0)]
);
let mut invalid = f.clone();
invalid.available_at[4] = invalid.decision_at + chrono::Duration::seconds(1);
assert!(evaluate(&e, &invalid).is_err());
}
#[test]
fn missing_is_not_zero_and_breakout_excludes_current() {
let f = frame(vec![Some(1.0), Some(2.0), Some(3.0), None, Some(5.0)]);
let e = expr(
json!({"kind":"operator","name":"BREAK_HIGH","window":2,"args":[{"kind":"field","name":"close"}]}),
);
assert_eq!(
evaluate(&e, &f).unwrap().values,
vec![None, None, Some(1.0), None, None]
);
let zero = expr(
json!({"kind":"operator","name":"DIV","args":[{"kind":"field","name":"close"},{"kind":"number","value":0}]}),
);
assert!(
evaluate(&zero, &f)
.unwrap()
.values
.iter()
.all(Option::is_none)
);
}
#[test]
fn ta_rewarms_after_gap_and_const_zscore_is_unknown() {
let f = frame(vec![Some(1.0), Some(1.0), None, Some(2.0), Some(2.0)]);
let e = expr(
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":2}}),
);
assert_eq!(
evaluate(&e, &f).unwrap().values,
vec![None, Some(1.0), None, None, Some(2.0)]
);
let e = expr(
json!({"kind":"operator","name":"ZSCORE","window":2,"args":[{"kind":"field","name":"close"}]}),
);
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
}
#[test]
fn no_event_has_no_bars_since_and_type_errors_reject() {
let f = frame(vec![Some(1.0), Some(1.0), Some(1.0)]);
let state = json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":5}]});
let e = expr(json!({"kind":"operator","name":"BARS_SINCE","args":[state]}));
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
assert!(
evaluate(
&expr(
json!({"kind":"operator","name":"NOT","args":[{"kind":"field","name":"close"}]})
),
&f
)
.is_err()
);
}
#[test]
fn literal_unknown_fields_reject_and_catalog_is_not_trading_permission() {
assert!(
serde_json::from_value::<Expr>(json!({"kind":"number","value":1,"account_id":2}))
.is_err()
);
let c = catalog();
assert!(c["indicators"].as_array().unwrap().len() > 190);
assert_eq!(c["live_routing"], false);
}
+369
View File
@@ -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")));
}
}
+30 -2
View File
@@ -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
View File
@@ -6,6 +6,7 @@ pub mod daily_patterns;
pub mod pattern_context; pub mod pattern_context;
pub mod session_events; pub mod session_events;
pub mod factor_events; pub mod factor_events;
mod factor_event_catalog;
pub mod factor_cross_section; pub mod factor_cross_section;
pub mod market_event_context; pub mod market_event_context;
pub mod engine; pub mod engine;
@@ -25,6 +26,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;
+25 -1
View File
@@ -558,7 +558,9 @@ fn alpha_beta(
} }
fn drawdown_stats(nav: &[f64]) -> (f64, usize) { fn drawdown_stats(nav: &[f64]) -> (f64, usize) {
let mut peak = 0.0_f64; // NAV is measured against the pre-period capital. The first real loss
// must not become a new zero-drawdown baseline.
let mut peak = 1.0_f64;
let mut max_drawdown = 0.0_f64; let mut max_drawdown = 0.0_f64;
let mut duration = 0_usize; let mut duration = 0_usize;
let mut max_duration = 0_usize; let mut max_duration = 0_usize;
@@ -767,6 +769,28 @@ fn safe_div(numerator: f64, denominator: f64, fallback: f64) -> f64 {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn drawdown_includes_initial_nav_without_adding_a_trading_day() {
let (drawdown, duration) = drawdown_stats(&[0.9, 0.99]);
assert!((drawdown + 0.1).abs() < 1e-12);
assert_eq!(duration, 2);
assert_eq!(drawdown_stats(&[1.0, 1.1, 1.1]), (0.0, 0));
assert_eq!(drawdown_stats(&[0.0]), (-1.0, 1));
assert_eq!(drawdown_stats(&[]), (0.0, 0));
}
#[test]
fn first_day_loss_is_preserved_in_shared_backtest_metrics() {
let curve = vec![
equity_point("2025-01-03", 99.16648349337, 98.81608059815, 100.0),
equity_point("2025-01-06", 99.68551588547, 98.65392198168, 98.81608059815),
];
let metrics = compute_backtest_metrics(&curve, &[], &[], &[], 100.0, None).unwrap();
assert!((metrics.max_drawdown + 0.0083351650663).abs() < 1e-12);
assert_eq!(metrics.total_trade_days, 2);
assert_eq!(metrics.max_drawdown_duration_days, 2);
}
fn equity_point( fn equity_point(
date: &str, date: &str,
total_equity: f64, total_equity: f64,
File diff suppressed because it is too large Load Diff
+163 -30
View File
@@ -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
@@ -897,6 +912,8 @@ pub struct StrategyExpressionSelectionConfig {
pub current_day_precomputed_factors: Option<bool>, pub current_day_precomputed_factors: Option<bool>,
#[serde(default, alias = "candidate_symbols_by_date")] #[serde(default, alias = "candidate_symbols_by_date")]
pub candidate_symbols_by_date: BTreeMap<String, Vec<String>>, pub candidate_symbols_by_date: BTreeMap<String, Vec<String>>,
#[serde(default, alias = "preserve_candidate_order")]
pub preserve_candidate_order: bool,
} }
#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
@@ -990,6 +1007,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)]
@@ -1511,7 +1530,6 @@ fn normalize_slippage_model_name(value: &str) -> String {
| "price_rate" | "price_rate"
| "price_ratio_slippage" | "price_ratio_slippage"
| "priceratioslippage" => "price_ratio".to_string(), | "priceratioslippage" => "price_ratio".to_string(),
"dynamic_volume_volatility" => "dynamic".to_string(),
other => other.to_string(), other => other.to_string(),
} }
} }
@@ -1556,11 +1574,13 @@ fn parse_slippage_model(
impact_coefficient: Option<f64>, impact_coefficient: Option<f64>,
volatility_coefficient: Option<f64>, volatility_coefficient: Option<f64>,
max_value: Option<f64>, max_value: Option<f64>,
) -> Option<SlippageModel> { ) -> Result<SlippageModel, String> {
let value = valid_non_negative(value); for (name, parameter) in [("slippageValue", value), ("slippageImpactCoefficient", impact_coefficient),
let impact_coefficient = valid_non_negative(impact_coefficient); ("slippageVolatilityCoefficient", volatility_coefficient), ("slippageMaxValue", max_value)] {
let volatility_coefficient = valid_non_negative(volatility_coefficient); if parameter.is_some_and(|number| !number.is_finite() || number < 0.0) {
let max_value = valid_non_negative(max_value); return Err(format!("{name} must be finite and non-negative"));
}
}
let model = model let model = model
.map(normalize_slippage_model_name) .map(normalize_slippage_model_name)
.filter(|item| !item.is_empty()) .filter(|item| !item.is_empty())
@@ -1573,16 +1593,23 @@ fn parse_slippage_model(
}); });
match model.as_str() { match model.as_str() {
"none" => Some(SlippageModel::None), "none" => Ok(SlippageModel::None),
"price_ratio" => Some(SlippageModel::PriceRatio(value.unwrap_or(0.0))), "price_ratio" => Ok(SlippageModel::PriceRatio(value.unwrap_or(0.0))),
"tick_size" => Some(SlippageModel::TickSize(value.unwrap_or(0.0))), "tick_size" => Ok(SlippageModel::TickSize(value.unwrap_or(0.0))),
"limit_price" => Some(SlippageModel::LimitPrice), "limit_price" => Ok(SlippageModel::LimitPrice),
"dynamic" => Some(SlippageModel::Dynamic(DynamicSlippageConfig::new( "historical_volume_volatility" => {
impact_coefficient.unwrap_or(0.5), let max_ratio = max_value.or(value).unwrap_or(0.01);
volatility_coefficient.unwrap_or(0.3), if max_ratio >= 1.0 {
max_value.or(value).unwrap_or(0.01), return Err("historical slippage maximum must be less than 1".into());
))), }
_ => None, Ok(SlippageModel::HistoricalVolumeVolatility(DynamicSlippageConfig::new(
impact_coefficient.unwrap_or(0.5), volatility_coefficient.unwrap_or(0.3), max_ratio,
)))
},
"dynamic" | "dynamic_volume_volatility" => Err(
"retired_slippage_model: dynamic used unfinished daily data; explicitly select historical_volume_volatility or another supported model".into()
),
_ => Err(format!("unsupported slippageModel: {model}")),
} }
} }
@@ -1613,15 +1640,13 @@ fn apply_execution_behavior_overrides(
|| slippage_volatility_coefficient.is_some() || slippage_volatility_coefficient.is_some()
|| slippage_max_value.is_some() || slippage_max_value.is_some()
{ {
if let Some(parsed) = parse_slippage_model( cfg.slippage_model = parse_slippage_model(
slippage_model, slippage_model,
slippage_value, slippage_value,
slippage_impact_coefficient, slippage_impact_coefficient,
slippage_volatility_coefficient, slippage_volatility_coefficient,
slippage_max_value, slippage_max_value,
) { )?;
cfg.slippage_model = parsed;
}
} }
if strict_value_budget == Some(false) { if strict_value_budget == Some(false) {
return Err("strictValueBudget=false is not supported".to_string()); return Err("strictValueBudget=false is not supported".to_string());
@@ -2110,12 +2135,16 @@ pub fn platform_expr_config_from_spec(
if let Some(enabled) = selection.current_day_precomputed_factors { if let Some(enabled) = selection.current_day_precomputed_factors {
cfg.current_day_precomputed_factors = enabled; cfg.current_day_precomputed_factors = enabled;
} }
if selection.preserve_candidate_order && selection.candidate_symbols_by_date.is_empty() {
return Err("preserveCandidateOrder requires a dated candidate book".to_string());
}
for (raw_date, raw_symbols) in &selection.candidate_symbols_by_date { for (raw_date, raw_symbols) in &selection.candidate_symbols_by_date {
let trade_date = NaiveDate::parse_from_str(raw_date, "%Y-%m-%d").map_err(|_| { let trade_date = NaiveDate::parse_from_str(raw_date, "%Y-%m-%d").map_err(|_| {
format!("candidateSymbolsByDate contains invalid date: {raw_date}") format!("candidateSymbolsByDate contains invalid date: {raw_date}")
})?; })?;
let mut symbols = BTreeSet::new(); let mut symbols = BTreeSet::new();
for raw_symbol in raw_symbols { let mut order = BTreeMap::new();
for (index, raw_symbol) in raw_symbols.iter().enumerate() {
let symbol = normalize_symbol(raw_symbol, None); let symbol = normalize_symbol(raw_symbol, None);
let valid = symbol.rsplit_once('.').is_some_and(|(code, exchange)| { let valid = symbol.rsplit_once('.').is_some_and(|(code, exchange)| {
code.len() == 6 code.len() == 6
@@ -2132,8 +2161,12 @@ pub fn platform_expr_config_from_spec(
"candidateSymbolsByDate contains duplicate date/symbol: {raw_date} {symbol}" "candidateSymbolsByDate contains duplicate date/symbol: {raw_date} {symbol}"
)); ));
} }
order.insert(symbol, index);
} }
cfg.candidate_symbols_by_date.insert(trade_date, symbols); cfg.candidate_symbols_by_date.insert(trade_date, symbols);
if selection.preserve_candidate_order {
cfg.candidate_order_by_date.insert(trade_date, order);
}
} }
} }
if let Some(allocation) = runtime_expr.allocation.as_ref() if let Some(allocation) = runtime_expr.allocation.as_ref()
@@ -2316,6 +2349,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();
} }
@@ -2599,6 +2636,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)
} }
@@ -2751,6 +2822,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
@@ -3149,6 +3221,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!({
@@ -3263,6 +3345,25 @@ mod tests {
); );
} }
#[test]
fn frozen_candidate_order_is_explicit_and_preserves_source_positions() {
let mut spec = serde_json::json!({"runtimeExpressions": {"selection": {
"candidateSymbolsByDate": {
"2025-01-02": ["600000.SH", "000001.SZ"], "2025-01-03": []
}
}}});
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let legacy = platform_expr_config_from_value("", "", &spec).unwrap();
assert!(legacy.candidate_order_by_date.is_empty());
spec["runtimeExpressions"]["selection"]["preserveCandidateOrder"] = serde_json::json!(true);
let ordered = platform_expr_config_from_value("", "", &spec).unwrap();
assert_eq!(ordered.candidate_order_by_date[&date]["600000.SH"], 0);
assert_eq!(ordered.candidate_order_by_date[&date]["000001.SZ"], 1);
assert!(ordered.candidate_order_by_date[&NaiveDate::from_ymd_opt(2025, 1, 3).unwrap()].is_empty());
spec["runtimeExpressions"]["selection"]["candidateSymbolsByDate"] = serde_json::json!({});
assert!(platform_expr_config_from_value("", "", &spec).unwrap_err().to_string().contains("dated candidate book"));
}
#[test] #[test]
fn rejects_invalid_or_duplicate_static_universe_symbols() { fn rejects_invalid_or_duplicate_static_universe_symbols() {
let invalid = serde_json::json!({ let invalid = serde_json::json!({
@@ -4066,6 +4167,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!({
@@ -4249,10 +4372,10 @@ mod tests {
} }
#[test] #[test]
fn parses_dynamic_slippage_into_platform_config() { fn parses_explicit_historical_slippage_into_platform_config() {
let spec = serde_json::json!({ let spec = serde_json::json!({
"execution": { "execution": {
"slippageModel": "dynamic", "slippageModel": "historical_volume_volatility",
"slippageImpactCoefficient": 0.6, "slippageImpactCoefficient": 0.6,
"slippageVolatilityCoefficient": 0.2, "slippageVolatilityCoefficient": 0.2,
"slippageMaxValue": 0.015 "slippageMaxValue": 0.015
@@ -4263,10 +4386,20 @@ mod tests {
assert_eq!( assert_eq!(
cfg.slippage_model, cfg.slippage_model,
SlippageModel::Dynamic(DynamicSlippageConfig::new(0.6, 0.2, 0.015)) SlippageModel::HistoricalVolumeVolatility(DynamicSlippageConfig::new(0.6, 0.2, 0.015))
); );
} }
#[test]
fn retired_or_unknown_slippage_models_do_not_fall_back_to_fixed_or_none() {
for model in ["dynamic", "dynamic_volume_volatility", "dynamic-volume-volatility", "unknown"] {
let spec = serde_json::json!({"execution": {"slippageModel": model, "slippageValue": 0.002}});
assert!(platform_expr_config_from_value("", "", &spec).is_err(), "{model}");
}
let spec = serde_json::json!({"execution": {"slippageModel": "historical_volume_volatility", "slippageImpactCoefficient": -1}});
assert!(platform_expr_config_from_value("", "", &spec).is_err());
}
#[test] #[test]
fn engine_stock_ma_filter_generates_price_and_volume_expr() { fn engine_stock_ma_filter_generates_price_and_volume_expr() {
let spec = serde_json::json!({ let spec = serde_json::json!({
+46 -7
View File
@@ -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);
+78 -13
View File
@@ -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);
+590
View File
@@ -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();
}
}
+4
View File
@@ -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[].timeInForceDAY 日内保留并在收盘 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[].timeInForceDAY 日内保留并在收盘 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,
+16 -24
View File
@@ -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")
);
} }
+19 -16
View File
@@ -1740,8 +1740,9 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() {
} }
#[test] #[test]
fn broker_applies_dynamic_slippage_on_snapshot_fills() { fn broker_applies_explicit_historical_slippage_on_snapshot_fills() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let previous_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();
let data = DataSet::from_components( let data = DataSet::from_components(
vec![Instrument { vec![Instrument {
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
@@ -1752,20 +1753,20 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
delisted_at: None, delisted_at: None,
status: "active".to_string(), status: "active".to_string(),
}], }],
vec![DailyMarketSnapshot { [previous_date, date].into_iter().map(|day| DailyMarketSnapshot {
date, date: day,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
timestamp: Some("2024-01-10 10:18:00".to_string()), timestamp: Some(format!("{day} 15:00:00")),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
high: 10.1, high: if day == previous_date { 10.1 } else { 10.9 },
low: 9.9, low: if day == previous_date { 9.9 } else { 9.1 },
close: 10.0, close: if day == previous_date { 10.0 } else { 10.8 },
last_price: 10.0, last_price: 10.0,
bid1: 9.99, bid1: 9.99,
ask1: 10.01, ask1: 10.01,
prev_close: 10.0, prev_close: 10.0,
volume: 100_000, volume: if day == previous_date { 100_000 } else { 2_000_000 },
minute_volume: 100_000, minute_volume: 100_000,
bid1_volume: 80_000, bid1_volume: 80_000,
ask1_volume: 80_000, ask1_volume: 80_000,
@@ -1774,7 +1775,7 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
upper_limit: 11.0, upper_limit: 11.0,
lower_limit: 9.0, lower_limit: 9.0,
price_tick: 0.01, price_tick: 0.01,
}], }).collect(),
vec![DailyFactorSnapshot { vec![DailyFactorSnapshot {
date, date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
@@ -1786,8 +1787,8 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: BTreeMap::new(),
}], }],
vec![CandidateEligibility { [previous_date, date].into_iter().map(|day| CandidateEligibility {
date, date: day,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
@@ -1798,15 +1799,15 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
is_kcb: false, is_kcb: false,
is_one_yuan: false, is_one_yuan: false,
risk_level_code: None, risk_level_code: None,
}], }).collect(),
vec![BenchmarkSnapshot { [previous_date, date].into_iter().map(|day| BenchmarkSnapshot {
date, date: day,
benchmark: "000300.SH".to_string(), benchmark: "000300.SH".to_string(),
open: 100.0, open: 100.0,
close: 100.0, close: 100.0,
prev_close: 99.0, prev_close: 99.0,
volume: 1_000_000, volume: 1_000_000,
}], }).collect(),
) )
.expect("dataset"); .expect("dataset");
let mut portfolio = PortfolioState::new(1_000_000.0); let mut portfolio = PortfolioState::new(1_000_000.0);
@@ -1815,7 +1816,9 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
ChinaEquityRuleHooks::default(), ChinaEquityRuleHooks::default(),
PriceField::Open, PriceField::Open,
) )
.with_slippage_model(SlippageModel::Dynamic(DynamicSlippageConfig::new( .with_volume_limit(false)
.with_liquidity_limit(false)
.with_slippage_model(SlippageModel::HistoricalVolumeVolatility(DynamicSlippageConfig::new(
0.5, 0.3, 0.1, 0.5, 0.3, 0.1,
))); )));
+10
View File
@@ -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
+43
View File
@@ -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实际成交验收。
+54
View File
@@ -0,0 +1,54 @@
# 逐成交腿价格风控验收
## 修复范围
Engine `7e0877b5860d8724da1c4507a1d1ba393b3497f5`Trading `1f7bc074024191cfaa5975546f22c2c2c733602a`,均以 tag `v2026.9.11.2` 发布177。
- 回测在每条实际报价进入撮合前检查原始参考价,滑点和限价处理后再次检查最终价。买入一元股、买入涨停、卖出跌停以及无效价格均按本腿价格处理,不能只依赖最初下单的日线标记或价格。
- Paper和Live的订单前检查与Paper撮合共用`MarketSnapshot::execution_reference_price`:普通买入用卖一、卖出用买一;未提供该侧价格时保留既有最新价合同,显式0或负数不当缺失处理、不回退。
- 选股仍独立使用其日线最新价与显式规则,不被买卖盘差异改写。盘后固定价仍使用原正式收盘价合同。
- Paper已接受/部分成交订单在新报价到达时重新检查。后续被风控拒绝不删除或回滚此前真实模拟成交,不重复扣资金或手续费。
- 实盘这里只验证发单前路径;券商实际成交事实必须原样保存,不能声称本地检查能保证委托进入券商后市场不再变化。本轮未提交证券订单。
## 测试
- 原始报价0.9、正向滑点20%后为1.08,仍不得利用滑点绕过一元股规则。
- 先出现0.9、后出现1.2的报价,只允许在后一个实际时点成交;不回写到前一时点。
- 限价滑点将最终执行价变为0.9时仍拒绝;显式关闭一元股买入规则后放行;卖出不继承买入一元股规则。
- 最后价10而卖一11触及涨停:买入拒绝。最后价11而卖一10.5:执行检查不按旧最后价误拒;显式选股涨停规则仍可按最后价拒绝。
- 最后价10而买一9触及跌停:卖出拒绝。最后价9而买一9.5:执行检查不按旧最后价误拒。
- 原挂单/部分成交后,最后价1.1但卖一0.9:余单拒绝,既有成交数和现金保持不变。
177测试:Engine 667通过/8忽略,Trading工作区548通过/10忽略,Runner370通过/3忽略,API99通过/1忽略。新场景使用隔离合成账户/报价,未以此冒充原始市场样本。
## 真实分钟回放
- 同一冻结请求、信号及bundle2025-01-03至2025-01-06,分钟13:07,初始10,000,000,滑点0.002,佣金万三/最低5,分钟25%量约束不改。
- 原基准 `btr_1789074235759_2081201_1`
- 新运行 `btr_1789093974375_2601124_0`
- 均21成交、11个最终持仓,最终资产9,968,551.588547;订单、成交、账户、权益、持仓和风险审计六项canonical完全一致。
- 总SHA `a1aa004f544b34eae0ade41e849a0fd067e39600d1c4ad1a127f5a3d6a79be11`
- 服务端3.490秒,客户端提交/读取/轮询16.927秒。未采集客户端各子段,不能把差值归因到某个具体服务,也不与原报告“提交后轮询耗时”混比。缓存条件的短样本不能外推冷态或多年性能。
## 发布与状态
通过官方Backtest和Trading installer构建和发布,没有调用Source/因子重启入口。Backtest service源码仍`75202cc3b876daf99d0d2dffb988ca456c34aabf`并重新链接上述engine。运行二进制SHA与清单一致。
本轮发布前10:27已观测到3Paper/0Live,重复读取确认;这不同于上一轮的3Paper/1Live,不是本次发布删除。本轮没有新建、恢复或删除实例。发布后仍3Paper/0Live,完整配置/状态摘要与本轮发布前相同。
Source主PID2267019和因子主PID2178403、NRestarts不变。发布后样本Paper9行、Live11行无WARN/ERRORRuntime0行不能视为实际执行成功;行情`/readyz`仍503THS -4302配额问题未恢复。
## 未完成
next-open全天量容量和动态滑点使用全天high/low/volume的问题没有被本次修复覆盖,仍按P0时点问题处理。新的执行观察规格位于`/Users/boris/WorkSpace/docs/fidc/execution-observation-prd-20260911.md`,只是后续实现规格,不是已部署能力。禁止静默改用昨量、自动关闭风控、修改旧结果或把后续一分钟量回填到开盘。
自然Paper/Live还需要合格模型、正式审批和真实可用行情;不开放2026封存,不替研究模型审批。当前实盘列表为空,不自行补建。
## 证据
`/srv/fidc/canonical/run/research/execution-leg-risk-20260911/`
- `engine-focused.log``engine-full.log``trading-full.log``backtest-full.log`
- `minute-replay/request.json``submission.json``result.json``comparison.json`
- `deploy-before.json``deploy-after.json``running-binary-verification.json``post-deploy-log-audit.json`
- 官方部署日志、研究审计脚本与执行观察设计稿。不改旧证据目录和WFT V18制品。
@@ -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 Runner370 通过、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`
- 官方部署日志与独立探针源码保存在相同证据根,不写入交易数据库或修改原始行情。
+15
View File
@@ -0,0 +1,15 @@
# 生命周期、价格缺失与历史状态
证券有效区间为 `[listed_at, delisted_at)`。无明确摘牌日期的最新 terminal 标签不能反向污染历史;已知未来摘牌日不阻断此前的正常交易。退市整理期不是已摘牌。
执行价加载前分别核验证券身份、正式上市/摘牌边界。合法上市前、摘牌后不查询和补价,记录结构化原因;同一日已有正执行价与生命周期边界冲突时报错。未知身份/代码映射、上市后的分钟缺口、候选事实缺失继续失败,不因 missing candidate 而跳过校验。持仓仅在当日正式暂停交易事实成立时允许按既定估值合同沿用历史价格;普通行情缺口不再无条件沿用旧价。
整个明确证券范围尚未上市时保留官方日历内现金净值点,不缩短回测范围,不伪造成交或 OHLCV。基准只在首个基线点归一,后续无交易日不反复重置。
513 项核心测试通过,6 项原有测试忽略。新增验证包含沪深北股票和 ETF 上市前、实际摘牌日、未知证券身份、候选缺失、正式停牌和普通价格缺口、全池上市前现金期间。对单个正式分区的数据缺口仍需数据源修复,不从这些测试外推全市场完整性。
## 真实边界回放补充
177 回测 `btr_1789041425783_797911_1`920038.BJ2026-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项原有忽略。
@@ -0,0 +1,10 @@
# 股票池候选顺序合同
新请求可显式设置 `runtimeExpressions.selection.preserveCandidateOrder=true`,同一 `candidateSymbolsByDate` 同时冻结成员和顺序。原有未设置该标志的策略保留成员过滤后自行排名的语义,不改写历史回测。
- 顺序在解析时保留,重复证券仍报错;空日期保持空,不继承旧候选。
- 不再走市值快排或套用旧 rank 方向。选股风控和股票条件仍在 Top N 前执行,被排除后从后续已冻结候选补位。
- 该标志必须绑定非空的日期映射,不允许空映射放开全市场。
- 股票池完成日线筛选的新前端请求采用 next_bar_open,日线信号日与真实执行日分离。
本轮共享内核全量回归 668 项通过(8 项显式忽略),新增顺序/旧排名方向/选股排除补位验证。该记录不是实盘成交验收,也不代表手选与自动候选混合来源完整实现。