Compare commits

..

21 Commits

Author SHA1 Message Date
boris b6859a3360 统一 FIDC FiPanda 品牌标识与资源 2026-09-11 20:17:51 +08:00
boris a00dea58b2 test: assert causal TWAP entitlement and unfilled remainder 2026-09-11 17:11:53 +08:00
boris 2445dc925a fix: size TWAP slices from frozen clocks instead of future quotes 2026-09-11 17:04:25 +08:00
boris 1e8d38f2ee Merge remote-tracking branch 'origin/main' 2026-09-11 16:14:55 +08:00
boris d2071e4311 docs: compare execution frameworks and specify remaining causal capacity work 2026-09-11 16:11:57 +08:00
boris 0fba16342f fix: share explicit execution price selection and reject stale-price fallback 2026-09-11 15:44:38 +08:00
boris bd389de57f test: exercise full odd-lot liquidation against the remaining quote quota 2026-09-11 15:28:19 +08:00
boris bfbf898027 test: qualify execution clock regression time types 2026-09-11 15:19:18 +08:00
boris 2b6d031a55 fix: separate as-of quote time from execution clock and retain volume consumption 2026-09-11 15:12:42 +08:00
boris 6160a74d2a 合并主分支最新因子元数据合同 2026-09-11 15:01:29 +08:00
boris 4acecda79d feat: introduce causal capacity primitives and exact participation quotas 2026-09-11 15:00:18 +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
20 changed files with 1361 additions and 560 deletions
+2
View File
@@ -1,3 +1,5 @@
<p><img src="assets/brand/fiPandaWithTitle.png" alt="FiPanda" width="144" height="144"></p>
# fidc-backtest-engine # fidc-backtest-engine
面向中国 A 股和期货策略的 Rust 回测核心。仓库目标是提供平台自有的策略 DSL、执行模型、撮合模型和结果分析能力,最终由 `fidc-backtest-service` 对外提供策略运行服务。 面向中国 A 股和期货策略的 Rust 回测核心。仓库目标是提供平台自有的策略 DSL、执行模型、撮合模型和结果分析能力,最终由 `fidc-backtest-service` 对外提供策略运行服务。
+11
View File
@@ -0,0 +1,11 @@
# FiPanda 品牌资源
FIDC 系列共用用户提供的两张原始透明图片,不重绘、不裁切、不染色。
- `fiPandaWithTitle.png`:带标题版,登录页、项目首页、独立报告使用。
- `fipanda.png`:无标题版,顶部导航、小尺寸标识和系统图标使用。
- `favicon-32.png`:由无标题版等比生成,供浏览器标签页使用。
导航图标建议 28–42px;带标题版建议 96–160px。始终等比完整显示,保留透明背景,不为 Logo 增加大块容器或改变业务页面主题。项目名称、权限、交易合同与内部 FIDC 标识不因品牌图片变更而重命名。
原图保存在 `omniquant/assets/brand/source/``manifest.json` 记录原图和显示资源的 SHA-256。需要更新时,在完整工作区运行 `node omniquant/scripts/sync-fipanda-branding.mjs`,统一生成八个仓库及可下载策略技能包的资源,禁止分别维护不同版本。
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

+38
View File
@@ -0,0 +1,38 @@
{
"schema": "fidc.fipanda-brand/v1",
"brand": "FiPanda",
"sourceRepository": "omniquant",
"sources": [
{
"file": "fipanda.png",
"sha256": "ff7dc0677b7d9c8dc6d008d3391f27fd583e3de35560dab021c1e2f8f2c464e5"
},
{
"file": "fiPandaWithTitle.png",
"sha256": "01bfb6acb36830d9b1ce6f7dc8c718977d43af44572ee6a26991d6c6aa2847d6"
}
],
"assets": [
{
"file": "fipanda.png",
"source": "fipanda.png",
"pixels": 192,
"usage": "navigation, compact icons, apple-touch-icon",
"sha256": "450aad3506ec3c67624b7705f4d8f851a9102fe950e7671a06fb1203dc1d9cf7"
},
{
"file": "fiPandaWithTitle.png",
"source": "fiPandaWithTitle.png",
"pixels": 384,
"usage": "login, project README, standalone reports",
"sha256": "d33846b3687ea69d43b4a1f17ceeb02662bd25f26fabc4c86c25b3475f20dede"
},
{
"file": "favicon-32.png",
"source": "fipanda.png",
"pixels": 32,
"usage": "browser tab icon",
"sha256": "ec3d7783457db284d46ae6e27833685203838637a7d68946ff85402725f8e420"
}
]
}
File diff suppressed because it is too large Load Diff
+170
View File
@@ -0,0 +1,170 @@
//! Causal volume budgets. Session totals may audit fills, never size earlier orders.
use chrono::{NaiveDate, NaiveDateTime};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VolumeCapacityMode {
#[default]
ExecutionObservation,
CompletedBar,
SessionCapacityAudit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum CapacityError {
#[error("execution capacity ratio must be finite and in (0, 1]")]
InvalidRatio,
#[error("execution capacity decimal cannot be represented exactly")]
InvalidDecimal,
#[error("execution capacity observation has invalid time bounds")]
InvalidWindow,
#[error("execution capacity is not visible: available={available_at}, execution={execution_at}")]
NotVisible { available_at: NaiveDateTime, execution_at: NaiveDateTime },
#[error("execution capacity observation belongs to another session")]
WrongSession,
#[error("execution-time capacity is missing; daily session volume cannot size an earlier fill")]
MissingObservation,
}
/// Decimal semantics of the frozen JSON rate, evaluated without a float product.
#[derive(Debug, Clone, Copy)]
pub struct ParticipationRate {
numerator: u128,
denominator: u128,
}
impl ParticipationRate {
pub fn new(rate: f64) -> Result<Self, CapacityError> {
if !rate.is_finite() || rate <= 0.0 || rate > 1.0 {
return Err(CapacityError::InvalidRatio);
}
if rate < 1e-20 {
// Even u64::MAX shares at this rate cannot admit a single share.
return Ok(Self { numerator: 0, denominator: 1 });
}
if rate == 1.0 {
return Ok(Self { numerator: 1, denominator: 1 });
}
let text = rate.to_string();
let digits = text.strip_prefix("0.").ok_or(CapacityError::InvalidDecimal)?;
let digits = digits.trim_end_matches('0');
let numerator = digits.parse::<u128>().map_err(|_| CapacityError::InvalidDecimal)?;
let denominator = 10_u128.checked_pow(digits.len() as u32).ok_or(CapacityError::InvalidDecimal)?;
if numerator > u128::MAX / u128::from(u64::MAX) {
return Err(CapacityError::InvalidDecimal);
}
Ok(Self { numerator, denominator })
}
pub fn total_shares(self, market_shares: u64) -> u64 {
let total = u128::from(market_shares) * self.numerator / self.denominator;
u64::try_from(total).expect("participation rate cannot exceed the market shares")
}
pub fn remaining(self, market_shares: u64, consumed_shares: u64, requested: u32) -> u32 {
self.total_shares(market_shares).saturating_sub(consumed_shares).min(u64::from(requested)) as u32
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VolumeObservationKind {
TradeIncrement,
CompletedBar,
CumulativeSession,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct VolumeObservation {
pub kind: VolumeObservationKind,
pub start: NaiveDateTime,
pub end: NaiveDateTime,
pub available_at: NaiveDateTime,
pub shares: u64,
}
impl VolumeObservation {
pub fn visible_shares(self, execution_at: NaiveDateTime) -> Result<u64, CapacityError> {
if self.start > self.end || self.available_at < self.end {
return Err(CapacityError::InvalidWindow);
}
if self.available_at > execution_at {
return Err(CapacityError::NotVisible { available_at: self.available_at, execution_at });
}
if self.start.date() != self.end.date() || self.end.date() != execution_at.date() {
return Err(CapacityError::WrongSession);
}
Ok(self.shares)
}
pub fn remaining(self, execution_at: NaiveDateTime, rate: ParticipationRate, consumed: u64, requested: u32) -> Result<u32, CapacityError> {
Ok(rate.remaining(self.visible_shares(execution_at)?, consumed, requested))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionCapacityAudit {
pub date: NaiveDate,
pub symbol: String,
pub filled_shares: u64,
pub session_shares: u64,
pub allowed_shares: u64,
pub passed: bool,
}
impl SessionCapacityAudit {
pub fn new(date: NaiveDate, symbol: String, filled_shares: u64, session_shares: u64, rate: ParticipationRate) -> Self {
let allowed_shares = rate.total_shares(session_shares);
Self { date, symbol, filled_shares, session_shares, allowed_shares, passed: filled_shares <= allowed_shares }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decimal_participation_never_rounds_a_fractional_share_up_or_overflows() {
assert_eq!(ParticipationRate::new(0.58).unwrap().total_shares(50), 29);
assert_eq!(ParticipationRate::new(0.25).unwrap().total_shares(3), 0);
assert_eq!(ParticipationRate::new(0.5).unwrap().total_shares(3), 1);
assert_eq!(ParticipationRate::new(1.).unwrap().total_shares(u64::MAX), u64::MAX);
assert_eq!(ParticipationRate::new(0.25).unwrap().remaining(u64::MAX, 0, u32::MAX), u32::MAX);
assert_eq!(ParticipationRate::new(f64::MIN_POSITIVE).unwrap().total_shares(u64::MAX), 0);
for rate in [0., -1., f64::NAN, f64::INFINITY, 1.001] {
assert!(ParticipationRate::new(rate).is_err());
}
}
#[test]
fn completed_volume_cannot_be_used_for_an_earlier_open() {
let day = NaiveDate::from_ymd_opt(2025,1,2).unwrap();
let opening = day.and_hms_opt(9,30,0).unwrap();
let closing = day.and_hms_opt(15,0,0).unwrap();
let observation = VolumeObservation { kind:VolumeObservationKind::CompletedBar, start:opening, end:closing, available_at:closing, shares:10000 };
assert!(matches!(observation.visible_shares(opening), Err(CapacityError::NotVisible { .. })));
assert_eq!(observation.remaining(closing, ParticipationRate::new(0.25).unwrap(), 1000, 5000).unwrap(), 1500);
assert!(matches!(observation.visible_shares(closing+chrono::Duration::days(1)), Err(CapacityError::WrongSession)));
}
#[test]
fn delayed_publication_and_invalid_bounds_are_not_treated_as_zero_volume() {
let at = NaiveDate::from_ymd_opt(2025,1,2).unwrap().and_hms_opt(10,18,0).unwrap();
let observation = VolumeObservation { kind:VolumeObservationKind::TradeIncrement, start:at, end:at, available_at:at+chrono::Duration::seconds(1), shares:0 };
assert!(matches!(observation.visible_shares(at), Err(CapacityError::NotVisible { .. })));
assert_eq!(VolumeObservation { available_at:at-chrono::Duration::seconds(1), ..observation }.visible_shares(at), Err(CapacityError::InvalidWindow));
assert_eq!(VolumeObservation { available_at:at, ..observation }.visible_shares(at).unwrap(), 0);
}
#[test]
fn session_audit_changes_verdict_not_executed_quantity() {
let day = NaiveDate::from_ymd_opt(2025,1,2).unwrap();
let rate = ParticipationRate::new(0.25).unwrap();
let a = SessionCapacityAudit::new(day,"TEST".into(),1000,3000,rate);
let b = SessionCapacityAudit::new(day,"TEST".into(),1000,5000,rate);
assert!(!a.passed); assert!(b.passed);
assert_eq!(a.filled_shares,b.filled_shares);
}
}
@@ -0,0 +1,61 @@
use chrono::NaiveDateTime;
use crate::engine::BacktestError;
/// Cumulative TWAP entitlement depends on the frozen clock, not future quotes.
pub(crate) struct TwapSchedule {
start: NaiveDateTime,
end: NaiveDateTime,
quantity: u32,
}
impl TwapSchedule {
pub(crate) fn new(
start: Option<NaiveDateTime>,
end: Option<NaiveDateTime>,
quantity: u32,
) -> Result<Self, BacktestError> {
let (Some(start), Some(end)) = (start, end) else {
return Err(BacktestError::Execution("TWAP requires an explicit start and end clock".into()));
};
if end < start || start.date() != end.date() {
return Err(BacktestError::Execution("TWAP requires an ordered same-session clock window".into()));
}
Ok(Self { start, end, quantity })
}
pub(crate) fn due_quantity(&self, at: NaiveDateTime, filled: u32) -> u32 {
if at < self.start {
return 0;
}
let entitlement = if at >= self.end {
self.quantity
} else {
let elapsed = (at - self.start).num_microseconds().expect("same-day interval") as u128;
let duration = (self.end - self.start).num_microseconds().expect("same-day interval") as u128;
(u128::from(self.quantity) * elapsed / duration) as u32
};
entitlement.saturating_sub(filled)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, NaiveDate};
#[test]
fn clock_entitlements_are_exact_and_allow_backlog_without_future_quote_counts() {
let start = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap().and_hms_opt(10, 0, 0).unwrap();
let end = start + Duration::minutes(10);
let schedule = TwapSchedule::new(Some(start), Some(end), 1_000).unwrap();
assert_eq!(schedule.due_quantity(start, 0), 0);
assert_eq!(schedule.due_quantity(start + Duration::minutes(2), 0), 200);
assert_eq!(schedule.due_quantity(start + Duration::minutes(5), 100), 400);
assert_eq!(schedule.due_quantity(end, 100), 900);
assert_eq!(schedule.due_quantity(end, 1_000), 0);
assert!(TwapSchedule::new(Some(start), None, 1_000).is_err());
assert!(TwapSchedule::new(Some(end), Some(start), 1_000).is_err());
assert_eq!(TwapSchedule::new(Some(start), Some(start), 1_000).unwrap().due_quantity(start, 0), 1_000);
}
}
@@ -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 -150
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,33 +168,7 @@ const OPERATORS: &[&str] = &[
"IF", "IF",
]; ];
pub fn catalog() -> Value { pub use crate::factor_event_catalog::catalog;
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":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,"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"}})
}
impl Frame { impl Frame {
pub fn validate(&self) -> Result<(), String> { pub fn validate(&self) -> Result<(), String> {
@@ -958,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);
}
+3
View File
@@ -6,6 +6,9 @@ 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;
pub mod execution_capacity;
mod execution_schedule;
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 -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,
+203 -172
View File
@@ -7,7 +7,7 @@ use chrono::{Datelike, Duration, FixedOffset, NaiveDate, NaiveDateTime, NaiveTim
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use rhai::{AST, Dynamic, Engine, ImmutableString, Map, Scope}; use rhai::{AST, Dynamic, Engine, ImmutableString, Map, Scope};
use crate::broker::{MatchingType, RebalanceCashMode, SlippageModel}; use crate::broker::{MatchingType, RebalanceCashMode, SlippageModel, intraday_reference_price};
use crate::cost::ChinaAShareCostModel; use crate::cost::ChinaAShareCostModel;
#[cfg(test)] #[cfg(test)]
use crate::data::EligibleUniverseSnapshot; use crate::data::EligibleUniverseSnapshot;
@@ -17,6 +17,7 @@ use crate::data::{
decision_market_cap_bn, decision_market_cap_bn,
}; };
use crate::engine::BacktestError; use crate::engine::BacktestError;
use crate::execution_capacity::{CapacityError, ParticipationRate};
use crate::events::{OrderSide, ProcessEvent, ProcessEventKind}; use crate::events::{OrderSide, ProcessEvent, ProcessEventKind};
use crate::fixed_point::FixedMoney; use crate::fixed_point::FixedMoney;
use crate::futures::{ use crate::futures::{
@@ -675,6 +676,9 @@ pub struct PlatformExprStrategyConfig {
pub current_day_precomputed_factors: bool, pub current_day_precomputed_factors: bool,
pub completed_session_factor_fields: BTreeSet<String>, pub completed_session_factor_fields: BTreeSet<String>,
pub candidate_symbols_by_date: BTreeMap<NaiveDate, BTreeSet<String>>, pub candidate_symbols_by_date: BTreeMap<NaiveDate, BTreeSet<String>>,
/// Explicit frozen candidate order, independent of the strategy's legacy
/// rank expression. Membership-only books keep their original ranking.
pub candidate_order_by_date: BTreeMap<NaiveDate, BTreeMap<String, usize>>,
pub intraday_execution_time: Option<NaiveTime>, pub intraday_execution_time: Option<NaiveTime>,
pub session_event_times: Vec<NaiveTime>, pub session_event_times: Vec<NaiveTime>,
pub explicit_action_times: Vec<NaiveTime>, pub explicit_action_times: Vec<NaiveTime>,
@@ -759,6 +763,7 @@ impl PlatformExprStrategyConfig {
current_day_precomputed_factors: false, current_day_precomputed_factors: false,
completed_session_factor_fields: BTreeSet::new(), completed_session_factor_fields: BTreeSet::new(),
candidate_symbols_by_date: BTreeMap::new(), candidate_symbols_by_date: BTreeMap::new(),
candidate_order_by_date: BTreeMap::new(),
intraday_execution_time: None, intraday_execution_time: None,
session_event_times: Vec::new(), session_event_times: Vec::new(),
explicit_action_times: Vec::new(), explicit_action_times: Vec::new(),
@@ -1365,6 +1370,7 @@ pub struct PlatformExprStrategy {
pattern_specs: RefCell<BTreeMap<String,String>>, pattern_specs: RefCell<BTreeMap<String,String>>,
pattern_frame_at:RefCell<Option<NaiveDateTime>>, pattern_frame_at:RefCell<Option<NaiveDateTime>>,
config: PlatformExprStrategyConfig, config: PlatformExprStrategyConfig,
volume_rate: Result<ParticipationRate, CapacityError>,
engine: Engine, engine: Engine,
rebalance_day_counter: usize, rebalance_day_counter: usize,
last_rebalance_date: Option<NaiveDate>, last_rebalance_date: Option<NaiveDate>,
@@ -1772,6 +1778,7 @@ impl PlatformExprStrategy {
.clone() .clone()
.map(PlatformPortfolioDrawdownController::new); .map(PlatformPortfolioDrawdownController::new);
Self { Self {
volume_rate: ParticipationRate::new(config.risk_config.trading_constraints.volume_percent),
config, config,
engine, engine,
protection_fill_count: 0, protection_fill_count: 0,
@@ -3007,13 +3014,14 @@ impl PlatformExprStrategy {
fn projected_apply_slippage( fn projected_apply_slippage(
&self, &self,
ctx: &StrategyContext<'_>,
market: &DailyMarketSnapshot, market: &DailyMarketSnapshot,
side: OrderSide, side: OrderSide,
raw_price: f64, raw_price: f64,
quantity: Option<u32>, quantity: Option<u32>,
) -> f64 { ) -> 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);
} }
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));
let mut adjusted = match self.config.slippage_model { let mut adjusted = match self.config.slippage_model {
@@ -3033,8 +3041,11 @@ impl PlatformExprStrategy {
OrderSide::Sell => raw_price - tick * ticks, OrderSide::Sell => raw_price - tick * ticks,
} }
} }
SlippageModel::Dynamic(config) => { SlippageModel::HistoricalVolumeVolatility(config) => {
let ratio = config.ratio(market, raw_price, order_value); let calibration = crate::broker::HistoricalSlippageCalibration::for_execution(
ctx.data, market.date, &market.symbol,
)?;
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),
@@ -3047,7 +3058,7 @@ impl PlatformExprStrategy {
{ {
adjusted *= 1.0 + self.config.sell_then_buy_delay_slippage_rate; adjusted *= 1.0 + self.config.sell_then_buy_delay_slippage_rate;
} }
Self::projected_clamp_execution_price(market, side, adjusted) Ok(Self::projected_clamp_execution_price(market, side, adjusted))
} }
fn projected_clamp_execution_price( fn projected_clamp_execution_price(
@@ -3079,32 +3090,7 @@ impl PlatformExprStrategy {
quote: &crate::data::IntradayExecutionQuote, quote: &crate::data::IntradayExecutionQuote,
side: OrderSide, side: OrderSide,
) -> Option<f64> { ) -> Option<f64> {
let last = intraday_reference_price(quote,self.config.matching_type,side)
|| (quote.last_price.is_finite() && quote.last_price > 0.0).then_some(quote.last_price);
match self.config.matching_type {
MatchingType::MinuteBestOwn => match side {
OrderSide::Buy => (quote.bid1.is_finite() && quote.bid1 > 0.0)
.then_some(quote.bid1)
.or_else(last),
OrderSide::Sell => (quote.ask1.is_finite() && quote.ask1 > 0.0)
.then_some(quote.ask1)
.or_else(last),
},
MatchingType::MinuteBestCounterparty => match side {
OrderSide::Buy => quote.buy_price(),
OrderSide::Sell => quote.sell_price(),
},
MatchingType::MinuteLast | MatchingType::Vwap | MatchingType::Twap => {
last().or_else(|| match side {
OrderSide::Buy => quote.buy_price(),
OrderSide::Sell => quote.sell_price(),
})
}
_ => match side {
OrderSide::Buy => quote.buy_price(),
OrderSide::Sell => quote.sell_price(),
},
}
} }
fn projected_execution_limit_rejection_reason( fn projected_execution_limit_rejection_reason(
@@ -3148,9 +3134,9 @@ impl PlatformExprStrategy {
allow_odd_lot_sell: bool, allow_odd_lot_sell: bool,
current_fill_quantity: u32, current_fill_quantity: u32,
execution_state: &ProjectedExecutionState, execution_state: &ProjectedExecutionState,
) -> Option<u32> { ) -> Result<Option<u32>, BacktestError> {
if requested_qty == 0 { if requested_qty == 0 {
return Some(0); return Ok(Some(0));
} }
let constraints = self.config.risk_config.trading_constraints; let constraints = self.config.risk_config.trading_constraints;
@@ -3175,7 +3161,7 @@ impl PlatformExprStrategy {
}; };
if top_level_liquidity == 0 { if top_level_liquidity == 0 {
if quote.is_some() { if quote.is_some() {
return None; return Ok(None);
} }
} else { } else {
let liquidity_limited = if side == OrderSide::Sell && allow_odd_lot_sell { let liquidity_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
@@ -3188,7 +3174,7 @@ impl PlatformExprStrategy {
) )
}; };
if liquidity_limited == 0 { if liquidity_limited == 0 {
return None; return Ok(None);
} }
max_fill = max_fill.min(liquidity_limited); max_fill = max_fill.min(liquidity_limited);
} }
@@ -3201,7 +3187,7 @@ impl PlatformExprStrategy {
None => market.volume, None => market.volume,
}; };
if volume_basis == 0 { if volume_basis == 0 {
return None; return Ok(None);
} }
let consumed_turnover = execution_state let consumed_turnover = execution_state
.intraday_turnover .intraday_turnover
@@ -3209,23 +3195,23 @@ impl PlatformExprStrategy {
.copied() .copied()
.unwrap_or(0) .unwrap_or(0)
.saturating_add(current_fill_quantity); .saturating_add(current_fill_quantity);
let raw_limit = ((volume_basis as f64) * constraints.volume_percent).floor() as i64 let raw_limit = self.volume_rate.map_err(|error| BacktestError::Execution(error.to_string()))?
- consumed_turnover as i64; .remaining(volume_basis, u64::from(consumed_turnover), requested_qty);
if raw_limit <= 0 { if raw_limit == 0 {
return None; return Ok(None);
} }
let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell { let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
raw_limit as u32 raw_limit
} else { } else {
self.round_lot_quantity(raw_limit as u32, minimum_order_quantity, order_step_size) self.round_lot_quantity(raw_limit, minimum_order_quantity, order_step_size)
}; };
if volume_limited == 0 { if volume_limited == 0 {
return None; return Ok(None);
} }
max_fill = max_fill.min(volume_limited); max_fill = max_fill.min(volume_limited);
} }
Some(max_fill) Ok(Some(max_fill))
} }
fn quote_lacks_level1_depth(quote: &crate::data::IntradayExecutionQuote) -> bool { fn quote_lacks_level1_depth(quote: &crate::data::IntradayExecutionQuote) -> bool {
@@ -3246,7 +3232,7 @@ impl PlatformExprStrategy {
cash_limit: Option<f64>, cash_limit: Option<f64>,
gross_limit: Option<f64>, gross_limit: Option<f64>,
execution_state: &ProjectedExecutionState, execution_state: &ProjectedExecutionState,
) -> Option<ProjectedExecutionFill> { ) -> Result<Option<ProjectedExecutionFill>, BacktestError> {
self.projected_select_execution_fill_at_time( self.projected_select_execution_fill_at_time(
ctx, ctx,
date, date,
@@ -3280,11 +3266,11 @@ impl PlatformExprStrategy {
gross_limit: Option<f64>, gross_limit: Option<f64>,
execution_state: &ProjectedExecutionState, execution_state: &ProjectedExecutionState,
execution_time: Option<NaiveTime>, execution_time: Option<NaiveTime>,
) -> Option<ProjectedExecutionFill> { ) -> Result<Option<ProjectedExecutionFill>, BacktestError> {
if requested_qty == 0 { if requested_qty == 0 {
return None; return Ok(None);
} }
let market = ctx.data.market(date, symbol)?; let Some(market) = ctx.data.market(date, symbol) else { return Ok(None); };
let start_cursor = self.projected_execution_start_cursor_at_time( let start_cursor = self.projected_execution_start_cursor_at_time(
ctx, ctx,
@@ -3325,7 +3311,7 @@ impl PlatformExprStrategy {
allow_odd_lot_sell, allow_odd_lot_sell,
filled_qty, filled_qty,
execution_state, execution_state,
) )?
.unwrap_or(0); .unwrap_or(0);
if available_qty == 0 { if available_qty == 0 {
break; break;
@@ -3340,7 +3326,7 @@ impl PlatformExprStrategy {
} }
let mut quote_price = let mut quote_price =
self.projected_apply_slippage(market, side, raw_quote_price, Some(take_qty)); self.projected_apply_slippage(ctx, market, side, raw_quote_price, Some(take_qty))?;
if self if self
.projected_execution_limit_rejection_reason(market, side, quote_price) .projected_execution_limit_rejection_reason(market, side, quote_price)
.is_some() .is_some()
@@ -3351,11 +3337,12 @@ impl PlatformExprStrategy {
if let Some(cash) = cash_limit { if let Some(cash) = cash_limit {
while take_qty > 0 { while take_qty > 0 {
quote_price = self.projected_apply_slippage( quote_price = self.projected_apply_slippage(
ctx,
market, market,
side, side,
raw_quote_price, raw_quote_price,
Some(take_qty), Some(take_qty),
); )?;
if self if self
.projected_execution_limit_rejection_reason(market, side, quote_price) .projected_execution_limit_rejection_reason(market, side, quote_price)
.is_some() .is_some()
@@ -3389,7 +3376,7 @@ impl PlatformExprStrategy {
} }
quote_price = quote_price =
self.projected_apply_slippage(market, side, raw_quote_price, Some(take_qty)); self.projected_apply_slippage(ctx, market, side, raw_quote_price, Some(take_qty))?;
if self if self
.projected_execution_limit_rejection_reason(market, side, quote_price) .projected_execution_limit_rejection_reason(market, side, quote_price)
.is_some() .is_some()
@@ -3398,20 +3385,20 @@ impl PlatformExprStrategy {
} }
gross_amount += quote_price * take_qty as f64; gross_amount += quote_price * take_qty as f64;
filled_qty += take_qty; filled_qty += take_qty;
last_timestamp = Some(quote.timestamp); last_timestamp = Some(start_cursor.max(quote.timestamp));
if filled_qty >= requested_qty { if filled_qty >= requested_qty {
break; break;
} }
} }
if filled_qty == 0 { if filled_qty == 0 {
return None; return Ok(None);
} }
Some(ProjectedExecutionFill { Ok(Some(ProjectedExecutionFill {
price: gross_amount / filled_qty as f64, price: gross_amount / filled_qty as f64,
quantity: filled_qty, quantity: filled_qty,
next_cursor: last_timestamp.unwrap_or(start_cursor) + Duration::seconds(1), next_cursor: last_timestamp.unwrap_or(start_cursor) + Duration::seconds(1),
}) }))
} }
fn has_execution_quote_at_or_before_at_time( fn has_execution_quote_at_or_before_at_time(
@@ -3442,7 +3429,7 @@ impl PlatformExprStrategy {
date: NaiveDate, date: NaiveDate,
symbol: &str, symbol: &str,
execution_state: &mut ProjectedExecutionState, execution_state: &mut ProjectedExecutionState,
) -> Option<u32> { ) -> Result<Option<u32>, BacktestError> {
self.project_target_zero_at_time(ctx, projected, date, symbol, execution_state, None) self.project_target_zero_at_time(ctx, projected, date, symbol, execution_state, None)
} }
@@ -3454,27 +3441,27 @@ impl PlatformExprStrategy {
symbol: &str, symbol: &str,
execution_state: &mut ProjectedExecutionState, execution_state: &mut ProjectedExecutionState,
execution_time: Option<NaiveTime>, execution_time: Option<NaiveTime>,
) -> Option<u32> { ) -> Result<Option<u32>, BacktestError> {
let position = projected.position(symbol)?; let Some(position) = projected.position(symbol) else { return Ok(None); };
let current_qty = position.quantity; let current_qty = position.quantity;
let sellable_qty = position.sellable_qty(date); let sellable_qty = position.sellable_qty(date);
if self.automatic_trade_permissions.get(symbol).is_some_and(|permission| permission.sell_denial.is_some()) { if self.automatic_trade_permissions.get(symbol).is_some_and(|permission| permission.sell_denial.is_some()) {
return None; return Ok(None);
} }
let quantity = current_qty.min(sellable_qty); let quantity = current_qty.min(sellable_qty);
if quantity == 0 { if quantity == 0 {
return None; return Ok(None);
} }
if !Self::defer_projection_execution_risk(ctx, date) if !Self::defer_projection_execution_risk(ctx, date)
&& !self.can_sell_position_at_time(ctx, date, symbol, execution_time) && !self.can_sell_position_at_time(ctx, date, symbol, execution_time)
{ {
return None; return Ok(None);
} }
let market = ctx.data.market(date, symbol)?; let Some(market) = ctx.data.market(date, symbol) else { return Ok(None); };
let round_lot = self.projected_round_lot(ctx, symbol); let round_lot = self.projected_round_lot(ctx, symbol);
let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol); let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol);
let order_step_size = self.projected_order_step_size(ctx, symbol); let order_step_size = self.projected_order_step_size(ctx, symbol);
let fill = self let mut fill = self
.projected_select_execution_fill_at_time( .projected_select_execution_fill_at_time(
ctx, ctx,
date, date,
@@ -3489,14 +3476,10 @@ impl PlatformExprStrategy {
None, None,
execution_state, execution_state,
execution_time, execution_time,
) )?;
.or_else(|| { if fill.is_none()
if self.uses_intraday_execution_quotes() && (!self.uses_intraday_execution_quotes() || Self::defer_projection_execution_risk(ctx, date))
&& !Self::defer_projection_execution_risk(ctx, date) && !self.has_execution_quote_at_or_before_at_time(
{
return None;
}
if !self.has_execution_quote_at_or_before_at_time(
ctx, ctx,
date, date,
symbol, symbol,
@@ -3504,7 +3487,7 @@ impl PlatformExprStrategy {
execution_time, execution_time,
) && ctx.data.execution_quotes_on(date, symbol).is_empty() ) && ctx.data.execution_quotes_on(date, symbol).is_empty()
{ {
let fallback_quantity = self.projected_market_fillable_quantity( if let Some(fallback_quantity) = self.projected_market_fillable_quantity(
market, market,
None, None,
symbol, symbol,
@@ -3516,27 +3499,24 @@ impl PlatformExprStrategy {
sellable_qty >= current_qty, sellable_qty >= current_qty,
0, 0,
execution_state, execution_state,
)?; )?.filter(|quantity| *quantity > 0)
if fallback_quantity == 0 { {
return None; fill = Some(ProjectedExecutionFill {
}
Some(ProjectedExecutionFill {
price: self.projected_execution_price(market, OrderSide::Sell), price: self.projected_execution_price(market, OrderSide::Sell),
quantity: fallback_quantity, quantity: fallback_quantity,
next_cursor: date.and_time( next_cursor: date.and_time(
execution_time.unwrap_or_else(|| self.intraday_execution_start_time()), execution_time.unwrap_or_else(|| self.intraday_execution_start_time()),
) + Duration::seconds(1), ) + Duration::seconds(1),
}) });
} else {
None
} }
})?; }
let Some(fill) = fill else { return Ok(None); };
let gross_amount = fill.price * fill.quantity as f64; let gross_amount = fill.price * fill.quantity as f64;
let net_cash = self.sell_net_cash(date, gross_amount); let net_cash = self.sell_net_cash(date, gross_amount);
projected projected
.position_mut(symbol) .position_mut(symbol)
.sell(fill.quantity, fill.price) .sell(fill.quantity, fill.price)
.ok()?; .map_err(BacktestError::Execution)?;
projected projected
.apply_cash_delta(net_cash) .apply_cash_delta(net_cash)
.expect("projected sell cash must fit fixed-point ledger"); .expect("projected sell cash must fit fixed-point ledger");
@@ -3548,7 +3528,7 @@ impl PlatformExprStrategy {
.execution_cursors .execution_cursors
.insert(symbol.to_string(), fill.next_cursor); .insert(symbol.to_string(), fill.next_cursor);
projected.prune_flat_positions(); projected.prune_flat_positions();
Some(fill.quantity) Ok(Some(fill.quantity))
} }
fn project_target_value( fn project_target_value(
@@ -3559,34 +3539,35 @@ impl PlatformExprStrategy {
symbol: &str, symbol: &str,
target_value: f64, target_value: f64,
execution_state: &mut ProjectedExecutionState, execution_state: &mut ProjectedExecutionState,
) -> Option<u32> { ) -> Result<Option<u32>, BacktestError> {
let current_qty = projected.position(symbol)?.quantity; let Some(position) = projected.position(symbol) else { return Ok(None); };
let current_qty = position.quantity;
if current_qty == 0 { if current_qty == 0 {
return None; return Ok(None);
} }
if target_value <= f64::EPSILON { if target_value <= f64::EPSILON {
return self.project_target_zero(ctx, projected, date, symbol, execution_state); return self.project_target_zero(ctx, projected, date, symbol, execution_state);
} }
let market = ctx.data.market(date, symbol)?; let Some(market) = ctx.data.market(date, symbol) else { return Ok(None); };
let current_value = let current_value =
self.projected_target_value_current_position_value(ctx, projected, date, symbol); self.projected_target_value_current_position_value(ctx, projected, date, symbol);
if !current_value.is_finite() || current_value <= 0.0 { if !current_value.is_finite() || current_value <= 0.0 {
return None; return Ok(None);
} }
let cash_delta = target_value.max(0.0) - current_value; let cash_delta = target_value.max(0.0) - current_value;
if cash_delta.abs() <= f64::EPSILON { if cash_delta.abs() <= f64::EPSILON {
return None; return Ok(None);
} }
if cash_delta > 0.0 { if cash_delta > 0.0 {
let result = let result =
self.project_order_value(ctx, projected, date, symbol, cash_delta, execution_state); self.project_order_value(ctx, projected, date, symbol, cash_delta, execution_state)?;
return (result.filled_quantity > 0).then_some(result.filled_quantity); return Ok((result.filled_quantity > 0).then_some(result.filled_quantity));
} }
if !Self::defer_projection_execution_risk(ctx, date) if !Self::defer_projection_execution_risk(ctx, date)
&& !self.can_sell_position(ctx, date, symbol) && !self.can_sell_position(ctx, date, symbol)
{ {
return None; return Ok(None);
} }
let sizing_price = self let sizing_price = self
.scheduled_quote(ctx, date, symbol) .scheduled_quote(ctx, date, symbol)
@@ -3599,17 +3580,17 @@ impl PlatformExprStrategy {
}) })
.unwrap_or_else(|| self.projected_execution_price(market, OrderSide::Sell)); .unwrap_or_else(|| self.projected_execution_price(market, OrderSide::Sell));
if !sizing_price.is_finite() || sizing_price <= 0.0 { if !sizing_price.is_finite() || sizing_price <= 0.0 {
return None; return Ok(None);
} }
let round_lot = self.projected_round_lot(ctx, symbol); let round_lot = self.projected_round_lot(ctx, symbol);
let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol); let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol);
let order_step_size = self.projected_order_step_size(ctx, symbol); let order_step_size = self.projected_order_step_size(ctx, symbol);
let sellable_qty = projected.position(symbol)?.sellable_qty(date); let sellable_qty = position.sellable_qty(date);
if self.automatic_trade_permissions.get(symbol).is_some_and(|permission| permission.sell_denial.is_some()) { if self.automatic_trade_permissions.get(symbol).is_some_and(|permission| permission.sell_denial.is_some()) {
return None; return Ok(None);
} }
if sellable_qty == 0 { if sellable_qty == 0 {
return None; return Ok(None);
} }
let requested_qty = self let requested_qty = self
.round_lot_quantity( .round_lot_quantity(
@@ -3620,9 +3601,9 @@ impl PlatformExprStrategy {
.min(current_qty) .min(current_qty)
.min(sellable_qty); .min(sellable_qty);
if requested_qty == 0 { if requested_qty == 0 {
return None; return Ok(None);
} }
let fill = self.projected_select_execution_fill( let Some(fill) = self.projected_select_execution_fill(
ctx, ctx,
date, date,
symbol, symbol,
@@ -3635,13 +3616,13 @@ impl PlatformExprStrategy {
None, None,
None, None,
execution_state, execution_state,
)?; )? else { return Ok(None); };
let gross_amount = fill.price * fill.quantity as f64; let gross_amount = fill.price * fill.quantity as f64;
let net_cash = self.sell_net_cash(date, gross_amount); let net_cash = self.sell_net_cash(date, gross_amount);
projected projected
.position_mut(symbol) .position_mut(symbol)
.sell(fill.quantity, fill.price) .sell(fill.quantity, fill.price)
.ok()?; .map_err(BacktestError::Execution)?;
projected projected
.apply_cash_delta(net_cash) .apply_cash_delta(net_cash)
.expect("projected sell cash must fit fixed-point ledger"); .expect("projected sell cash must fit fixed-point ledger");
@@ -3653,7 +3634,7 @@ impl PlatformExprStrategy {
.execution_cursors .execution_cursors
.insert(symbol.to_string(), fill.next_cursor); .insert(symbol.to_string(), fill.next_cursor);
projected.prune_flat_positions(); projected.prune_flat_positions();
Some(fill.quantity) Ok(Some(fill.quantity))
} }
fn projected_position_is_flat(projected: &PortfolioState, symbol: &str) -> bool { fn projected_position_is_flat(projected: &PortfolioState, symbol: &str) -> bool {
@@ -3941,7 +3922,7 @@ impl PlatformExprStrategy {
symbol, symbol,
buy_cash, buy_cash,
projected_execution_state, projected_execution_state,
); )?;
if order_result.was_submitted() { if order_result.was_submitted() {
order_intents.push(OrderIntent::Value { order_intents.push(OrderIntent::Value {
symbol: symbol.clone(), symbol: symbol.clone(),
@@ -4042,32 +4023,30 @@ impl PlatformExprStrategy {
symbol: &str, symbol: &str,
order_value: f64, order_value: f64,
execution_state: &mut ProjectedExecutionState, execution_state: &mut ProjectedExecutionState,
) -> ProjectedOrderValueResult { ) -> Result<ProjectedOrderValueResult, BacktestError> {
if order_value <= 0.0 { if order_value <= 0.0 {
return ProjectedOrderValueResult::not_submitted(); return Ok(ProjectedOrderValueResult::not_submitted());
} }
let round_lot = self.projected_round_lot(ctx, symbol); let round_lot = self.projected_round_lot(ctx, symbol);
let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol); let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol);
let order_step_size = self.projected_order_step_size(ctx, symbol); let order_step_size = self.projected_order_step_size(ctx, symbol);
let market = match ctx.data.market(date, symbol) { let market = match ctx.data.market(date, symbol) {
Some(market) => market, Some(market) => market,
None => return ProjectedOrderValueResult::not_submitted(), None => return Ok(ProjectedOrderValueResult::not_submitted()),
}; };
let stock = match self.stock_state(ctx, date, symbol) { let stock = match self.stock_state(ctx, date, symbol) {
Ok(stock) => stock, Ok(stock) => stock,
Err(BacktestError::Data(crate::data::DataSetError::MissingSnapshot { .. })) => { Err(BacktestError::Data(crate::data::DataSetError::MissingSnapshot { .. })) => {
return ProjectedOrderValueResult::not_submitted(); return Ok(ProjectedOrderValueResult::not_submitted());
} }
Err(_) => return ProjectedOrderValueResult::not_submitted(), Err(error) => return Err(error),
}; };
if !Self::defer_projection_execution_risk(ctx, date) if !Self::defer_projection_execution_risk(ctx, date)
&& self && self
.buy_rejection_reason(ctx, date, symbol, &stock) .buy_rejection_reason(ctx, date, symbol, &stock)?
.ok()
.flatten()
.is_some() .is_some()
{ {
return ProjectedOrderValueResult::not_submitted(); return Ok(ProjectedOrderValueResult::not_submitted());
} }
let raw_sizing_price = if self.uses_intraday_execution_quotes() { let raw_sizing_price = if self.uses_intraday_execution_quotes() {
self.scheduled_last_price(ctx, date, symbol) self.scheduled_last_price(ctx, date, symbol)
@@ -4076,9 +4055,9 @@ impl PlatformExprStrategy {
self.projected_execution_price(market, OrderSide::Buy) self.projected_execution_price(market, OrderSide::Buy)
}; };
let sizing_price = let sizing_price =
self.projected_apply_slippage(market, OrderSide::Buy, raw_sizing_price, None); self.projected_apply_slippage(ctx, market, OrderSide::Buy, raw_sizing_price, None)?;
if !sizing_price.is_finite() || sizing_price <= 0.0 { if !sizing_price.is_finite() || sizing_price <= 0.0 {
return ProjectedOrderValueResult::not_submitted(); return Ok(ProjectedOrderValueResult::not_submitted());
} }
let snapshot_requested_qty = self.value_buy_quantity( let snapshot_requested_qty = self.value_buy_quantity(
projected.cash().min(order_value), projected.cash().min(order_value),
@@ -4108,11 +4087,11 @@ impl PlatformExprStrategy {
self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size); self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size);
} }
if quantity == 0 { if quantity == 0 {
return ProjectedOrderValueResult::not_submitted(); return Ok(ProjectedOrderValueResult::not_submitted());
} }
let submitted_quantity = quantity; let submitted_quantity = quantity;
let defer_projection_execution_risk = Self::defer_projection_execution_risk(ctx, date); let defer_projection_execution_risk = Self::defer_projection_execution_risk(ctx, date);
let fill = self let mut fill = self
.projected_select_execution_fill( .projected_select_execution_fill(
ctx, ctx,
date, date,
@@ -4126,23 +4105,22 @@ impl PlatformExprStrategy {
Some(cash_limit), Some(cash_limit),
gross_limit, gross_limit,
execution_state, execution_state,
) )?;
.or_else(|| { if fill.is_none()
if !defer_projection_execution_risk && !(!defer_projection_execution_risk
&& ctx.data.has_execution_quotes_on_date(date) && ctx.data.has_execution_quotes_on_date(date)
&& ctx.data.execution_quotes_on(date, symbol).is_empty() && ctx.data.execution_quotes_on(date, symbol).is_empty())
{ && !self.has_execution_quote_at_or_before_at_time(
None
} else if !self.has_execution_quote_at_or_before_at_time(
ctx, ctx,
date, date,
symbol, symbol,
execution_state, execution_state,
None, None,
) && ctx.data.execution_quotes_on(date, symbol).is_empty() ) && ctx.data.execution_quotes_on(date, symbol).is_empty()
&& let Some(market) = ctx.data.market(date, symbol)
{ {
let fallback_quantity = self.projected_market_fillable_quantity( if let Some(fallback_quantity) = self.projected_market_fillable_quantity(
ctx.data.market(date, symbol)?, market,
None, None,
symbol, symbol,
OrderSide::Buy, OrderSide::Buy,
@@ -4153,27 +4131,23 @@ impl PlatformExprStrategy {
false, false,
0, 0,
execution_state, execution_state,
)?; )?.filter(|quantity| *quantity > 0)
if fallback_quantity == 0 { {
return None; fill = Some(ProjectedExecutionFill {
}
Some(ProjectedExecutionFill {
price: sizing_price, price: sizing_price,
quantity: fallback_quantity, quantity: fallback_quantity,
next_cursor: date.and_time(self.intraday_execution_start_time()) next_cursor: date.and_time(self.intraday_execution_start_time())
+ Duration::seconds(1), + Duration::seconds(1),
})
} else {
None
}
}); });
}
}
let Some(fill) = fill else { let Some(fill) = fill else {
return ProjectedOrderValueResult::submitted_without_fill(submitted_quantity); return Ok(ProjectedOrderValueResult::submitted_without_fill(submitted_quantity));
}; };
let gross_amount = fill.price * fill.quantity as f64; let gross_amount = fill.price * fill.quantity as f64;
let cash_out = self.buy_cash_out(gross_amount); let cash_out = self.buy_cash_out(gross_amount);
if !Self::fixed_cash_fits(cash_out, cash_limit) { if !Self::fixed_cash_fits(cash_out, cash_limit) {
return ProjectedOrderValueResult::submitted_without_fill(submitted_quantity); return Ok(ProjectedOrderValueResult::submitted_without_fill(submitted_quantity));
} }
projected projected
.apply_cash_delta(-cash_out) .apply_cash_delta(-cash_out)
@@ -4188,7 +4162,7 @@ impl PlatformExprStrategy {
execution_state execution_state
.execution_cursors .execution_cursors
.insert(symbol.to_string(), fill.next_cursor); .insert(symbol.to_string(), fill.next_cursor);
ProjectedOrderValueResult::submitted_with_fill(submitted_quantity, fill.quantity) Ok(ProjectedOrderValueResult::submitted_with_fill(submitted_quantity, fill.quantity))
} }
fn defer_projection_execution_risk(ctx: &StrategyContext<'_>, date: NaiveDate) -> bool { fn defer_projection_execution_risk(ctx: &StrategyContext<'_>, date: NaiveDate) -> bool {
@@ -10660,7 +10634,8 @@ impl PlatformExprStrategy {
} }
fn rank_reuses_market_cap_order(&self) -> bool { fn rank_reuses_market_cap_order(&self) -> bool {
!self.rank_expr_present self.config.candidate_order_by_date.is_empty()
&& !self.rank_expr_present
&& !self.config.rank_desc && !self.config.rank_desc
&& matches!(self.config.rank_by.trim(), "market_cap" | "market_cap_bn") && matches!(self.config.rank_by.trim(), "market_cap" | "market_cap_bn")
} }
@@ -11046,8 +11021,13 @@ impl PlatformExprStrategy {
if field_value < band_low || field_value > band_high { if field_value < band_low || field_value > band_high {
continue; continue;
} }
let rank_value = let rank_value = if let Some(order) = self.config.candidate_order_by_date.get(&date) {
self.rank_value_from_caps(ctx, day, market_cap_bn, free_float_cap_bn, &stock)?; *order.get(symbol).ok_or_else(|| BacktestError::Execution(format!(
"frozen candidate order is missing {date}/{symbol}"
)))? as f64
} else {
self.rank_value_from_caps(ctx, day, market_cap_bn, free_float_cap_bn, &stock)?
};
if !rank_value.is_finite() { if !rank_value.is_finite() {
// Model-score artifacts intentionally contain only the PIT-eligible // Model-score artifacts intentionally contain only the PIT-eligible
// ranked universe. Do not report a missing score for a symbol that // ranked universe. Do not report a missing score for a symbol that
@@ -11094,7 +11074,7 @@ impl PlatformExprStrategy {
candidates.sort_by(|lhs, rhs| { candidates.sort_by(|lhs, rhs| {
let lhs_value = lhs.1; let lhs_value = lhs.1;
let rhs_value = rhs.1; let rhs_value = rhs.1;
let ordering = if self.config.rank_desc { let ordering = if self.config.rank_desc && self.config.candidate_order_by_date.is_empty() {
rhs_value rhs_value
.partial_cmp(&lhs_value) .partial_cmp(&lhs_value)
.unwrap_or(std::cmp::Ordering::Equal) .unwrap_or(std::cmp::Ordering::Equal)
@@ -13022,7 +13002,7 @@ impl PlatformExprStrategy {
&symbol, &symbol,
&mut projected_execution_state, &mut projected_execution_state,
Some(delayed_limit_exit_time), Some(delayed_limit_exit_time),
) )?
.is_some() .is_some()
&& Self::projected_position_is_flat(&projected, &symbol) && Self::projected_position_is_flat(&projected, &symbol)
}; };
@@ -13178,7 +13158,7 @@ impl PlatformExprStrategy {
projection_date, projection_date,
&position.symbol, &position.symbol,
&mut projected_execution_state, &mut projected_execution_state,
) )?
.is_some(); .is_some();
if close_submitted { if close_submitted {
self.refresh_available_cash_after_projected_sell( self.refresh_available_cash_after_projected_sell(
@@ -13278,7 +13258,7 @@ impl PlatformExprStrategy {
&symbol, &symbol,
&mut projected_execution_state, &mut projected_execution_state,
Some(risk_level_forced_exit_time), Some(risk_level_forced_exit_time),
) )?
.is_some(); .is_some();
if close_submitted { if close_submitted {
self.refresh_available_cash_after_projected_sell(&mut available_cash, &projected); self.refresh_available_cash_after_projected_sell(&mut available_cash, &projected);
@@ -13333,7 +13313,7 @@ impl PlatformExprStrategy {
projection_date, projection_date,
symbol, symbol,
&mut projected_execution_state, &mut projected_execution_state,
); )?;
} else { } else {
let current_value = self.projected_position_value_at_execution_price( let current_value = self.projected_position_value_at_execution_price(
ctx, ctx,
@@ -13350,7 +13330,7 @@ impl PlatformExprStrategy {
symbol, symbol,
target_value, target_value,
&mut projected_execution_state, &mut projected_execution_state,
); )?;
} }
self.refresh_available_cash_after_projected_sell(&mut available_cash, &projected); self.refresh_available_cash_after_projected_sell(&mut available_cash, &projected);
if Self::projected_position_is_flat(&projected, symbol) { if Self::projected_position_is_flat(&projected, symbol) {
@@ -13504,7 +13484,7 @@ impl PlatformExprStrategy {
&position.symbol, &position.symbol,
target_value, target_value,
&mut trial_execution_state, &mut trial_execution_state,
); )?;
let after_qty = trial_projected let after_qty = trial_projected
.position(&position.symbol) .position(&position.symbol)
.map(|projected_position| projected_position.quantity) .map(|projected_position| projected_position.quantity)
@@ -13599,7 +13579,7 @@ impl PlatformExprStrategy {
&symbol, &symbol,
target_value, target_value,
&mut projected_execution_state, &mut projected_execution_state,
); )?;
let after_qty = projected let after_qty = projected
.position(&symbol) .position(&symbol)
.map(|position| position.quantity) .map(|position| position.quantity)
@@ -13650,7 +13630,7 @@ impl PlatformExprStrategy {
projection_date, projection_date,
&position.symbol, &position.symbol,
&mut projected_execution_state, &mut projected_execution_state,
) )?
.is_some(); .is_some();
if close_submitted { if close_submitted {
self.refresh_available_cash_after_projected_sell( self.refresh_available_cash_after_projected_sell(
@@ -13761,7 +13741,7 @@ impl PlatformExprStrategy {
projection_date, projection_date,
&position.symbol, &position.symbol,
&mut projected_execution_state, &mut projected_execution_state,
) )?
.is_some(); .is_some();
if close_submitted { if close_submitted {
self.refresh_available_cash_after_projected_sell( self.refresh_available_cash_after_projected_sell(
@@ -13849,7 +13829,7 @@ impl PlatformExprStrategy {
projection_date, projection_date,
&position.symbol, &position.symbol,
&mut projected_execution_state, &mut projected_execution_state,
) )?
.is_some(); .is_some();
if close_submitted { if close_submitted {
self.refresh_available_cash_after_projected_sell( self.refresh_available_cash_after_projected_sell(
@@ -13931,7 +13911,7 @@ impl PlatformExprStrategy {
projection_date, projection_date,
&position.symbol, &position.symbol,
&mut projected_execution_state, &mut projected_execution_state,
) )?
.is_some(); .is_some();
if close_submitted { if close_submitted {
self.refresh_available_cash_after_projected_sell( self.refresh_available_cash_after_projected_sell(
@@ -14036,7 +14016,7 @@ impl PlatformExprStrategy {
&symbol, &symbol,
target_value, target_value,
&mut projected_execution_state, &mut projected_execution_state,
); )?;
} else { } else {
self.project_order_value( self.project_order_value(
ctx, ctx,
@@ -14045,7 +14025,7 @@ impl PlatformExprStrategy {
&symbol, &symbol,
target_value, target_value,
&mut projected_execution_state, &mut projected_execution_state,
); )?;
intraday_attempted_buys.insert(symbol.clone()); intraday_attempted_buys.insert(symbol.clone());
self.remember_position_entry_date(symbol, signal_date); self.remember_position_entry_date(symbol, signal_date);
} }
@@ -14112,7 +14092,7 @@ impl PlatformExprStrategy {
projection_date, projection_date,
symbol, symbol,
&mut projected_execution_state, &mut projected_execution_state,
) )?
.is_some() .is_some()
&& Self::projected_position_is_flat(&projected, symbol) && Self::projected_position_is_flat(&projected, symbol)
{ {
@@ -14172,7 +14152,7 @@ impl PlatformExprStrategy {
symbol, symbol,
target_value, target_value,
&mut trial_execution_state, &mut trial_execution_state,
); )?;
let after_qty = trial_projected let after_qty = trial_projected
.position(symbol) .position(symbol)
.map(|position| position.quantity) .map(|position| position.quantity)
@@ -14235,7 +14215,7 @@ impl PlatformExprStrategy {
symbol, symbol,
target_value, target_value,
&mut projected_execution_state, &mut projected_execution_state,
); )?;
order_intents.push(OrderIntent::TargetValue { order_intents.push(OrderIntent::TargetValue {
symbol: symbol.clone(), symbol: symbol.clone(),
target_value, target_value,
@@ -15004,6 +14984,31 @@ mod tests {
assert_eq!(shared.version_sha256(),shared_version); assert_eq!(shared.version_sha256(),shared_version);
} }
#[test]
fn projected_historical_slippage_does_not_swallow_missing_calibration() {
let date = d(2025, 1, 7);
let symbol = "000001.SZ";
let data = single_symbol_platform_data(&[date], symbol);
let portfolio = PortfolioState::new(100_000.0);
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date, decision_date: date, decision_index: 0, data: &data, portfolio: &portfolio,
futures_account: None, open_orders: &[], dynamic_universe: None, subscriptions: &subscriptions,
process_events: &[], active_process_event: None, active_datetime: Some(date.and_hms_opt(15, 0, 0).unwrap()),
order_events: &[], fills: &[],
};
let mut config = PlatformExprStrategyConfig::generic();
config.signal_symbol = symbol.into();
config.slippage_model = SlippageModel::HistoricalVolumeVolatility(crate::DynamicSlippageConfig::default());
let strategy = PlatformExprStrategy::new(config);
let mut projected = portfolio.clone();
let result = strategy.project_order_value(&ctx, &mut projected, date, symbol, 50_000.0, &mut super::ProjectedExecutionState::default());
let error = result.expect_err("calibration failures must reach the strategy caller");
assert!(error.to_string().contains("historical_slippage_calibration_missing"), "{error}");
assert_eq!(projected.cash(), portfolio.cash());
assert!(projected.positions().is_empty());
}
#[test] #[test]
fn portfolio_loss_observes_finalized_nav_after_fees_and_cash_flows() { fn portfolio_loss_observes_finalized_nav_after_fees_and_cash_flows() {
use std::sync::Mutex; use std::sync::Mutex;
@@ -16039,7 +16044,7 @@ mod tests {
symbol, symbol,
3_410.0, 3_410.0,
&mut execution_state, &mut execution_state,
), ).unwrap(),
Some(200) Some(200)
); );
assert_eq!(projected.position(symbol).unwrap().quantity, 300); assert_eq!(projected.position(symbol).unwrap().quantity, 300);
@@ -17719,7 +17724,7 @@ mod tests {
symbol, symbol,
125_000.0, 125_000.0,
&mut execution_state, &mut execution_state,
); ).unwrap();
assert_eq!(result.filled_quantity, 24_400); assert_eq!(result.filled_quantity, 24_400);
let position = projected.position(symbol).expect("position"); let position = projected.position(symbol).expect("position");
@@ -17853,7 +17858,7 @@ mod tests {
symbol, symbol,
125_000.0, 125_000.0,
&mut execution_state, &mut execution_state,
); ).unwrap();
assert_eq!(result.filled_quantity, 24_400); assert_eq!(result.filled_quantity, 24_400);
} }
@@ -17919,7 +17924,7 @@ mod tests {
false, false,
0, 0,
&execution_state, &execution_state,
), ).expect("valid volume capacity"),
Some(2_500) Some(2_500)
); );
@@ -17939,7 +17944,7 @@ mod tests {
false, false,
0, 0,
&execution_state, &execution_state,
), ).expect("valid remaining volume capacity"),
Some(100) Some(100)
); );
} }
@@ -18085,7 +18090,7 @@ mod tests {
symbol, symbol,
target_value, target_value,
&mut execution_state, &mut execution_state,
); ).unwrap();
assert_eq!(filled, Some(500)); assert_eq!(filled, Some(500));
assert_eq!(projected.position(symbol).unwrap().quantity, 19_100); assert_eq!(projected.position(symbol).unwrap().quantity, 19_100);
@@ -24599,7 +24604,7 @@ mod tests {
symbol, symbol,
target_value, target_value,
&mut execution_state, &mut execution_state,
) ).unwrap()
.expect("target adjustment should buy"); .expect("target adjustment should buy");
assert_eq!(filled, 200); assert_eq!(filled, 200);
@@ -33043,7 +33048,7 @@ mod tests {
let mut execution_state = super::ProjectedExecutionState::default(); let mut execution_state = super::ProjectedExecutionState::default();
let filled = let filled =
strategy.project_target_zero(&ctx, &mut projected, date, symbol, &mut execution_state); strategy.project_target_zero(&ctx, &mut projected, date, symbol, &mut execution_state).unwrap();
assert_eq!(filled, Some(100)); assert_eq!(filled, Some(100));
assert!( assert!(
@@ -33187,7 +33192,7 @@ mod tests {
symbol, symbol,
10_000.0, 10_000.0,
&mut execution_state, &mut execution_state,
); ).unwrap();
assert_eq!(result.filled_quantity, 0); assert_eq!(result.filled_quantity, 0);
assert_eq!( assert_eq!(
@@ -33337,7 +33342,7 @@ mod tests {
symbol, symbol,
10_000.0, 10_000.0,
&mut execution_state, &mut execution_state,
); ).unwrap();
assert!(result.filled_quantity > 0); assert!(result.filled_quantity > 0);
assert_eq!( assert_eq!(
@@ -33457,7 +33462,7 @@ mod tests {
decision_date, decision_date,
symbol, symbol,
&mut execution_state, &mut execution_state,
); ).unwrap();
assert_eq!(filled, Some(1_000)); assert_eq!(filled, Some(1_000));
assert!(projected.position(symbol).is_none()); assert!(projected.position(symbol).is_none());
@@ -33561,7 +33566,7 @@ mod tests {
let mut execution_state = super::ProjectedExecutionState::default(); let mut execution_state = super::ProjectedExecutionState::default();
let filled = let filled =
strategy.project_target_zero(&ctx, &mut projected, date, symbol, &mut execution_state); strategy.project_target_zero(&ctx, &mut projected, date, symbol, &mut execution_state).unwrap();
assert_eq!(filled, None); assert_eq!(filled, None);
assert_eq!(projected.cash(), portfolio.cash()); assert_eq!(projected.cash(), portfolio.cash());
@@ -33667,7 +33672,7 @@ mod tests {
let mut execution_state = super::ProjectedExecutionState::default(); let mut execution_state = super::ProjectedExecutionState::default();
let filled = let filled =
strategy.project_target_zero(&ctx, &mut projected, date, symbol, &mut execution_state); strategy.project_target_zero(&ctx, &mut projected, date, symbol, &mut execution_state).unwrap();
assert_eq!(filled, None); assert_eq!(filled, None);
assert_eq!(projected.cash(), portfolio.cash()); assert_eq!(projected.cash(), portfolio.cash());
@@ -34054,7 +34059,7 @@ mod tests {
filtered_cfg filtered_cfg
.candidate_symbols_by_date .candidate_symbols_by_date
.insert(curr, BTreeSet::from(["300002.SZ".to_string()])); .insert(curr, BTreeSet::from(["300002.SZ".to_string()]));
let mut filtered_strategy = PlatformExprStrategy::new(filtered_cfg); let mut filtered_strategy = PlatformExprStrategy::new(filtered_cfg.clone());
let filtered = filtered_strategy.on_day(&ctx).expect("filtered decision"); let filtered = filtered_strategy.on_day(&ctx).expect("filtered decision");
assert!( assert!(
matches!( matches!(
@@ -34065,6 +34070,32 @@ mod tests {
filtered.order_intents, filtered.order_intents,
filtered.diagnostics filtered.diagnostics
); );
// The published screen order can deliberately disagree with both code
// and market-cap order. The old rank direction must not reverse it.
for rank_desc in [false, true] {
let mut ordered_cfg = filtered_cfg.clone();
ordered_cfg.rank_desc = rank_desc;
ordered_cfg.candidate_symbols_by_date.insert(curr, BTreeSet::from([
"300001.SZ".to_string(), "300002.SZ".to_string(),
]));
ordered_cfg.candidate_order_by_date.insert(curr, BTreeMap::from([
("300002.SZ".to_string(), 0), ("300001.SZ".to_string(), 1),
]));
let mut ordered_strategy = PlatformExprStrategy::new(ordered_cfg.clone());
let ordered = ordered_strategy.on_day(&ctx).expect("ordered decision");
assert!(matches!(ordered.order_intents.first(),
Some(crate::strategy::OrderIntent::TargetValue { symbol, .. }) if symbol == "300002.SZ"
), "{:?}", ordered);
// Rejection before Top N advances to the next published candidate.
ordered_cfg.stock_filter_expr = "symbol != \"300002.SZ\"".to_string();
let mut excluded = PlatformExprStrategy::new(ordered_cfg);
let decision = excluded.on_day(&ctx).expect("filtered ordered decision");
assert!(matches!(decision.order_intents.first(),
Some(crate::strategy::OrderIntent::TargetValue { symbol, .. }) if symbol == "300001.SZ"
), "{:?}", decision);
}
} }
#[test] #[test]
+69 -24
View File
@@ -912,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)]
@@ -1528,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(),
} }
} }
@@ -1573,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())
@@ -1590,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}")),
} }
} }
@@ -1630,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());
@@ -2127,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
@@ -2149,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()
@@ -3329,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!({
@@ -4337,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
@@ -4351,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!({
+23 -19
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,
))); )));
@@ -3077,23 +3080,24 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
) )
.expect("broker execution"); .expect("broker execution");
assert_eq!(report.fill_events.len(), 3); assert_eq!(report.fill_events.len(), 2);
assert_eq!( assert_eq!(
report report
.fill_events .fill_events
.iter() .iter()
.map(|fill| fill.quantity) .map(|fill| fill.quantity)
.sum::<u32>(), .sum::<u32>(),
300 200
); );
assert!(report.fill_events.iter().all(|fill| fill.quantity == 100)); assert!(report.fill_events.iter().all(|fill| fill.quantity == 100));
assert!(report.fill_events.iter().all(|fill| fill.execution_timestamp.is_some_and(|at| at >= date.and_hms_opt(10, 15, 0).unwrap())));
assert_eq!( assert_eq!(
report report
.process_events .process_events
.iter() .iter()
.filter(|event| event.kind == ProcessEventKind::Trade) .filter(|event| event.kind == ProcessEventKind::Trade)
.count(), .count(),
3 2
); );
} }
+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,73 @@
# FIDC执行合同与开源框架审查
状态:源码审查和部分通用修复已完成。尚未完成多策略长区间A/B;不能据此宣称某个框架性能最优或FIDC生产闭环已通过。
## 固定版本
源码保存在`/Volumes/SystemSSD/WorkSpace/fidc-reference-frameworks`,未执行第三方项目的策略或交易程序。
| 框架 | 审查版本 | 主要参考位置 |
|---|---|---|
| NautilusTrader | 6f48cd8e713b72b878626e9a9757c6e4a026b708 | [报价类型](/Volumes/SystemSSD/WorkSpace/fidc-reference-frameworks/nautilus_trader/crates/model/src/data/quote.rs:51)、[归并](/Volumes/SystemSSD/WorkSpace/fidc-reference-frameworks/nautilus_trader/crates/backtest/src/node.rs:442) |
| LEAN | 8ee075a39918f2df6fe9e0a5944e366fb60d10dc | [股票撮合](/Volumes/SystemSSD/WorkSpace/fidc-reference-frameworks/Lean/Common/Orders/Fills/EquityFillModel.cs:482) |
| Zipline Reloaded | 943010b9da848e317fc520de87edade2b884d329 | [滑点与容量](/Volumes/SystemSSD/WorkSpace/fidc-reference-frameworks/zipline-reloaded/src/zipline/finance/slippage.py:464) |
| Backtrader | b853d7c90b6721476eb5a5ea3135224e33db1f14 | [Broker](/Volumes/SystemSSD/WorkSpace/fidc-reference-frameworks/backtrader/backtrader/brokers/bbroker.py:228) |
## 可采用的设计
### 事件与时钟
Nautilus把Quote、Trade、Bar区分为不同类型,保存事件与接收/初始化时钟;数据归并按`ts_init`排序,分块保留同一时间戳的完整组。这适合FIDC后续有界分钟流:Source声明观测类型、覆盖区间和可见时间,执行端持有独立账户状态,而非共享一个同时装着日终与盘中字段的可变字典。
FIDC本轮实际发现:以09:32:58行情给09:33订单定价时,成交时间也写成09:32:58。已修为实际成交不得早于订单执行时钟;价格仍引用原行情,容量扣减也继续绑定原行情,不能通过换一个订单时钟重新获得额度。
### 开盘和陈旧行情
LEAN的MarketOnOpenFill单独处理正式开盘成交标志、bar开始/结束时间和订单时间;不是任意取一个日线字段即可成交。但其默认模型也有整体成交和数据回退假设,不能直接当作真实市场流动性保证。
FIDC已统一预估与撮合的盘中价格选择。己方价、对手价和最近成交价各用自己的字段;缺失/无效时不改用另一种价,也不越过最新坏行情寻找更旧的可用价格。
### 成交量与滑点
Zipline按股票和bar累计已使用量,历史冲击窗口排除当前未完成交易日。FIDC应复用不可变历史校准,但每个账户自己的成交消耗必须独立;不能按订单次数重复分配同一个bar的额度。它在历史不足时的回退不是FIDC的默认准入策略。
FIDC新增`ParticipationRate`:按冻结JSON小数语义精确向下取整,避免浮点乘法和窄整数转换误差。例如58%乘50股为29,不应因二进制误差成为28;巨大市场量也不能在转u32时截断成另一个额度。比例解析在配置阶段完成,不放入逐成交腿热循环。
Backtrader把volume filler、滑点开关、cheat-on-open/close等模型选择显式区分。值得采用的是明确暴露假设;不照搬不带成交量限制的默认值,也不为了收益对齐开启能改变可见时间的行为。
## 已落地通用修复
- 精确参与率计算用于Broker和策略预估,同一观测的已消耗量跨订单共享、跨账户隔离。
- 实际执行时钟与参考行情时钟分开,修复成交早于下单的问题。
- 缺失的执行价不由其他价格或更旧行情代替;删除重复价格选择及不可达深度分支。
- Runner拒绝空身份、错日期、晚于请求时钟的行情、非有限价格、缺失或非整数数量。
- 多查询引用同一观测只有完全一致才合并;同一目标查询的源重复行和跨查询冲突均报错,不采用第一条。
- 目录缓存使用Linux变更通知,不能只凭mtime/目录大小判断没有新文件;通知不可用时重新扫描。事件溢出、目录替换、重命名、删除均失效重建。
这些改变不按策略ID、固定选股条件或10:17/10:18等时间分支。
## 仍未关闭的问题
1. **日线全天量仍进入早晨数量计算**Broker和projection两条路径都需接入容量模式。`VolumeCapacityMode``SessionCapacityAudit`目前只是已测试基础类型,未完成配置、撮合、日终结果和页面的完整接线。不得称P0已修。
2. **Source可能掩盖缺失**:当前目标分钟序列会先剔除无效close,再寻找旧的有效值;部分缺失volume/amount置0;备用Arrow包装器还会用请求时钟替代缺失行情时间。必须清理这些行为,并按数据/市场缺席合同分类。
3. **TWAP早期配额可能依赖未来可成交记录数**:当前实现使用筛选后的`eligible_quotes.len()`分配数量。应按冻结时间表切片,后续缺量只能影响后续执行,不改早期分配。
4. **集合竞价**:实时数据与日线/分钟模型必须区分委托进入竞价、等待撮合和实际成交,不能用竞价中间的零量判定股票停牌,不能提前使用最终清算价。
5. **数据种类/单位**:源观测类型与价格模型能力要明确;分钟OHLC不能冒充真实L1深度。盘口单位、名义bar区间和可见时间仍须逐源校验。
## 下一阶段实施合同
- 普通风险开关和参与率仍是同一个策略三端共享配置。历史回测的容量假设单独冻结,不能用研究审核模式绕过Paper/Live临单风控。
- 有真实可见观测时使用`execution_observation``completed_bar`须验证bar结束和可见时间,不靠日线日期猜15:00。
- 只有日线总量而没有开盘容量时,严格模式须在提交预检失败;用户可明确选择`session_capacity_audit`做研究。日终只判定容量验收,不回写早晨的数量、现金、持仓或净值。
- 不能自动关闭参与率限制,也不能悄悄替换为昨日量。
- Source、Runner、Engine、研究handoff、Strategy AI和UI成套发布;持有冻结合同的旧任务不改写。当前研究维护方尚未授权共享服务切换。
## 验证与基准
Engine当前680项通过、8忽略;Runner375项通过,真实Source的8个JSON/Arrow行情点通过严格解析。小样本中的14:59零量在serving Parquet中确实存在,15:00有成交;这是源事实核对,不是完整集合竞价逻辑验收。历史股票收盘竞价时段应按适用规则处理,不能将后来的规则回填到旧日期。[深交所2023年交易机制说明](https://investor.szse.cn/institute/rules/t20230629_601434.html)
上交所2026版规则于2026-07-06生效,且通知明确存在暂缓实施条文。后续交易时段/容量合同必须同时核对规则版本和实施范围。[上交所发布通知](https://www.sse.com.cn/lawandrules/sselawsrules2025/stocks/exchange/c/c_20260424_10816482.shtml)
下一组A/B应覆盖动态Top-N日线、静态组合、逐分钟策略、TWAP/VWAP及共享模型信号,分别检查同条件重复、时间变更、参数变更、短区间和五年区间。先关闭时点错误,再测准备/引擎/持久化/端到端耗时、峰值RSS、缓存代际与逐笔差异。不得用微型元数据读取速度外推完整回测性能。
本轮证据根:`/srv/fidc/canonical/run/research/historical-slippage-20260911/`。服务均未由本任务重启,没有提交回测任务或证券订单。
@@ -0,0 +1,10 @@
# 股票池候选顺序合同
新请求可显式设置 `runtimeExpressions.selection.preserveCandidateOrder=true`,同一 `candidateSymbolsByDate` 同时冻结成员和顺序。原有未设置该标志的策略保留成员过滤后自行排名的语义,不改写历史回测。
- 顺序在解析时保留,重复证券仍报错;空日期保持空,不继承旧候选。
- 不再走市值快排或套用旧 rank 方向。选股风控和股票条件仍在 Top N 前执行,被排除后从后续已冻结候选补位。
- 该标志必须绑定非空的日期映射,不允许空映射放开全市场。
- 股票池完成日线筛选的新前端请求采用 next_bar_open,日线信号日与真实执行日分离。
本轮共享内核全量回归 668 项通过(8 项显式忽略),新增顺序/旧排名方向/选股排除补位验证。该记录不是实盘成交验收,也不代表手选与自动候选混合来源完整实现。