Compare commits
29 Commits
v2026.9.11
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 29eafc79e2 | |||
| b6859a3360 | |||
| a00dea58b2 | |||
| 2445dc925a | |||
| 1e8d38f2ee | |||
| d2071e4311 | |||
| 0fba16342f | |||
| bd389de57f | |||
| bfbf898027 | |||
| 2b6d031a55 | |||
| 6160a74d2a | |||
| 4acecda79d | |||
| d847cb5c28 | |||
| fa0b316a8b | |||
| 21786187c9 | |||
| e0bed38184 | |||
| c0b78846d6 | |||
| 9d72567b99 | |||
| e47228beff | |||
| 1fc8a3a9e6 | |||
| 98199c02a2 | |||
| 6eaa06c1d6 | |||
| 7e0877b586 | |||
| 36833b7a6a | |||
| 4c0157b66c | |||
| f7d16fb664 | |||
| 97cdfa5972 | |||
| f2e228e0a3 | |||
| 33924b1fba |
@@ -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` 对外提供策略运行服务。
|
||||||
|
|||||||
@@ -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 |
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+488
-190
File diff suppressed because it is too large
Load Diff
@@ -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<_>>(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
use chrono::NaiveDate;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::TradingCalendar;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum TradingActionOrigin {
|
||||||
|
Strategy,
|
||||||
|
Manual,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct AutomaticTradeProtection {
|
||||||
|
#[serde(default, deserialize_with = "optional_days")]
|
||||||
|
pub buy_protection_days: u32,
|
||||||
|
#[serde(default, deserialize_with = "optional_days")]
|
||||||
|
pub sell_cooldown_days: u32,
|
||||||
|
#[serde(default, deserialize_with = "optional_days")]
|
||||||
|
pub max_holding_days: u32,
|
||||||
|
#[serde(default, deserialize_with = "optional_locks")]
|
||||||
|
pub locks: Vec<AutomaticTradeLock>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize_optional_policy<'de, D: serde::Deserializer<'de>>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> Result<AutomaticTradeProtection, D::Error> {
|
||||||
|
Ok(Option::<AutomaticTradeProtection>::deserialize(deserializer)?.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional_days<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<u32, D::Error> {
|
||||||
|
let raw = serde_json::Value::deserialize(deserializer)?;
|
||||||
|
if raw.is_null() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
raw.as_f64()
|
||||||
|
.filter(|value| {
|
||||||
|
value.is_finite() && value.fract() == 0.0 && *value >= 0.0 && *value <= 3650.0
|
||||||
|
})
|
||||||
|
.map(|value| value as u32)
|
||||||
|
.ok_or_else(|| serde::de::Error::custom("protection days must be integers in 0..3650"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional_locks<'de, D: serde::Deserializer<'de>>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> Result<Vec<AutomaticTradeLock>, D::Error> {
|
||||||
|
Ok(Option::<Vec<AutomaticTradeLock>>::deserialize(deserializer)?.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct AutomaticTradeLock {
|
||||||
|
pub symbol: String,
|
||||||
|
pub start_date: NaiveDate,
|
||||||
|
pub end_date: Option<NaiveDate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct HoldingLifecycleEvidence {
|
||||||
|
pub has_position: bool,
|
||||||
|
pub opened_date: Option<NaiveDate>,
|
||||||
|
pub last_buy_date: Option<NaiveDate>,
|
||||||
|
pub last_sell_date: Option<NaiveDate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct AutomaticTradePermission {
|
||||||
|
pub buy_denial: Option<&'static str>,
|
||||||
|
pub sell_denial: Option<&'static str>,
|
||||||
|
pub max_holding_exit: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AutomaticTradeProtection {
|
||||||
|
pub fn enabled(&self) -> bool {
|
||||||
|
self.buy_protection_days > 0
|
||||||
|
|| self.sell_cooldown_days > 0
|
||||||
|
|| self.max_holding_days > 0
|
||||||
|
|| !self.locks.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
if [
|
||||||
|
self.buy_protection_days,
|
||||||
|
self.sell_cooldown_days,
|
||||||
|
self.max_holding_days,
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.any(|days| days > 3650)
|
||||||
|
{
|
||||||
|
return Err("automatic_trade_holding_days_out_of_range: expected 0..3650".into());
|
||||||
|
}
|
||||||
|
if self.locks.len() > 2000 {
|
||||||
|
return Err("automatic_trade_locks_limit: maximum 2000 intervals".into());
|
||||||
|
}
|
||||||
|
for lock in &self.locks {
|
||||||
|
let valid_symbol = lock.symbol.split_once('.').is_some_and(|(code, venue)| {
|
||||||
|
code.len() == 6
|
||||||
|
&& code.bytes().all(|ch| ch.is_ascii_digit())
|
||||||
|
&& matches!(venue, "SH" | "SZ" | "BJ")
|
||||||
|
});
|
||||||
|
if !valid_symbol {
|
||||||
|
return Err(format!(
|
||||||
|
"automatic_trade_lock_invalid_symbol: {}",
|
||||||
|
lock.symbol
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if lock.end_date.is_some_and(|end| end < lock.start_date) {
|
||||||
|
return Err(format!(
|
||||||
|
"automatic_trade_lock_invalid_interval: {}",
|
||||||
|
lock.symbol
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn evaluate(
|
||||||
|
&self,
|
||||||
|
symbol: &str,
|
||||||
|
execution_date: NaiveDate,
|
||||||
|
evidence: &HoldingLifecycleEvidence,
|
||||||
|
calendar: &TradingCalendar,
|
||||||
|
) -> Result<AutomaticTradePermission, String> {
|
||||||
|
self.validate()?;
|
||||||
|
if self.locks.iter().any(|lock| {
|
||||||
|
lock.symbol == symbol
|
||||||
|
&& lock.start_date <= execution_date
|
||||||
|
&& lock.end_date.is_none_or(|end| execution_date <= end)
|
||||||
|
}) {
|
||||||
|
return Ok(AutomaticTradePermission {
|
||||||
|
buy_denial: Some("automatic_trade_locked"),
|
||||||
|
sell_denial: Some("automatic_trade_locked"),
|
||||||
|
max_holding_exit: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let elapsed = |date: NaiveDate| -> Result<usize, String> {
|
||||||
|
let start = calendar.index_of(date).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"automatic_trade_holding_calendar_missing: symbol={symbol} fact_date={date}"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let end = calendar.index_of(execution_date).ok_or_else(|| format!("automatic_trade_holding_calendar_missing: symbol={symbol} execution_date={execution_date}"))?;
|
||||||
|
end.checked_sub(start).ok_or_else(|| format!("automatic_trade_holding_future_fact: symbol={symbol} fact_date={date} execution_date={execution_date}"))
|
||||||
|
};
|
||||||
|
let mut decision = AutomaticTradePermission::default();
|
||||||
|
if self.buy_protection_days > 0
|
||||||
|
&& evidence.has_position
|
||||||
|
&& let Some(date) = evidence.last_buy_date
|
||||||
|
&& elapsed(date)? <= self.buy_protection_days as usize
|
||||||
|
{
|
||||||
|
decision.sell_denial = Some("buy_fill_protection");
|
||||||
|
}
|
||||||
|
if self.sell_cooldown_days > 0
|
||||||
|
&& let Some(date) = evidence.last_sell_date
|
||||||
|
&& elapsed(date)? <= self.sell_cooldown_days as usize
|
||||||
|
{
|
||||||
|
decision.buy_denial = Some("sell_fill_cooldown");
|
||||||
|
}
|
||||||
|
if self.max_holding_days > 0 && evidence.has_position {
|
||||||
|
let opened = evidence.opened_date.ok_or_else(|| format!("automatic_trade_opened_date_missing: symbol={symbol}; require confirmed position lifecycle evidence"))?;
|
||||||
|
decision.max_holding_exit = elapsed(opened)? >= self.max_holding_days as usize
|
||||||
|
&& decision.sell_denial.is_none();
|
||||||
|
if decision.max_holding_exit {
|
||||||
|
decision.buy_denial = Some("maximum_holding_exit");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(decision)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The caller supplies origin from its authenticated execution path, never
|
||||||
|
/// from an untrusted order-body flag. Broker and ordinary risk checks remain.
|
||||||
|
pub fn evaluate_for_origin(
|
||||||
|
&self,
|
||||||
|
origin: TradingActionOrigin,
|
||||||
|
symbol: &str,
|
||||||
|
execution_date: NaiveDate,
|
||||||
|
evidence: &HoldingLifecycleEvidence,
|
||||||
|
calendar: &TradingCalendar,
|
||||||
|
) -> Result<AutomaticTradePermission, String> {
|
||||||
|
self.validate()?;
|
||||||
|
match origin {
|
||||||
|
TradingActionOrigin::Strategy => {
|
||||||
|
self.evaluate(symbol, execution_date, evidence, calendar)
|
||||||
|
}
|
||||||
|
TradingActionOrigin::Manual => Ok(AutomaticTradePermission::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
fn d(value: &str) -> NaiveDate {
|
||||||
|
NaiveDate::parse_from_str(value, "%Y-%m-%d").unwrap()
|
||||||
|
}
|
||||||
|
fn calendar() -> TradingCalendar {
|
||||||
|
TradingCalendar::new(
|
||||||
|
[
|
||||||
|
"2026-09-11",
|
||||||
|
"2026-09-14",
|
||||||
|
"2026-09-15",
|
||||||
|
"2026-09-16",
|
||||||
|
"2026-09-17",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(d)
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn three_complete_sessions_protect_through_wednesday_not_72_hours() {
|
||||||
|
let policy = AutomaticTradeProtection {
|
||||||
|
buy_protection_days: 3,
|
||||||
|
sell_cooldown_days: 3,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let evidence = HoldingLifecycleEvidence {
|
||||||
|
has_position: true,
|
||||||
|
last_buy_date: Some(d("2026-09-11")),
|
||||||
|
last_sell_date: Some(d("2026-09-11")),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
for day in ["2026-09-11", "2026-09-14", "2026-09-15", "2026-09-16"] {
|
||||||
|
let decision = policy
|
||||||
|
.evaluate("000001.SZ", d(day), &evidence, &calendar())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(decision.sell_denial, Some("buy_fill_protection"));
|
||||||
|
assert_eq!(decision.buy_denial, Some("sell_fill_cooldown"));
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
policy
|
||||||
|
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
|
||||||
|
.unwrap(),
|
||||||
|
AutomaticTradePermission::default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absolute_locks_are_inclusive_and_override_timed_exit_without_changing_other_symbols() {
|
||||||
|
let policy = AutomaticTradeProtection {
|
||||||
|
max_holding_days: 1,
|
||||||
|
locks: vec![AutomaticTradeLock {
|
||||||
|
symbol: "000001.SZ".into(),
|
||||||
|
start_date: d("2026-09-11"),
|
||||||
|
end_date: Some(d("2026-09-16")),
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let evidence = HoldingLifecycleEvidence {
|
||||||
|
has_position: true,
|
||||||
|
opened_date: Some(d("2026-09-11")),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let locked = policy
|
||||||
|
.evaluate("000001.SZ", d("2026-09-16"), &evidence, &calendar())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(locked.sell_denial, Some("automatic_trade_locked"));
|
||||||
|
assert!(!locked.max_holding_exit);
|
||||||
|
assert!(
|
||||||
|
policy
|
||||||
|
.evaluate("600000.SH", d("2026-09-16"), &evidence, &calendar())
|
||||||
|
.unwrap()
|
||||||
|
.max_holding_exit
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
policy
|
||||||
|
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
|
||||||
|
.unwrap()
|
||||||
|
.max_holding_exit
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_disabled_and_missing_calendar_or_opened_date_are_not_inferred() {
|
||||||
|
let evidence = HoldingLifecycleEvidence {
|
||||||
|
has_position: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
AutomaticTradeProtection::default()
|
||||||
|
.evaluate(
|
||||||
|
"000001.SZ",
|
||||||
|
d("2026-09-17"),
|
||||||
|
&evidence,
|
||||||
|
&TradingCalendar::new(vec![])
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
AutomaticTradePermission::default()
|
||||||
|
);
|
||||||
|
let policy = AutomaticTradeProtection {
|
||||||
|
max_holding_days: 1,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
policy
|
||||||
|
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("opened_date_missing")
|
||||||
|
);
|
||||||
|
let evidence = HoldingLifecycleEvidence {
|
||||||
|
opened_date: Some(d("2026-09-10")),
|
||||||
|
..evidence
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
policy
|
||||||
|
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("calendar_missing")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manual_origin_only_bypasses_automatic_policy_not_an_order_or_broker_permission() {
|
||||||
|
let policy = AutomaticTradeProtection {
|
||||||
|
locks: vec![AutomaticTradeLock {
|
||||||
|
symbol: "000001.SZ".into(),
|
||||||
|
start_date: d("2026-09-11"),
|
||||||
|
end_date: None,
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
policy
|
||||||
|
.evaluate_for_origin(
|
||||||
|
TradingActionOrigin::Manual,
|
||||||
|
"000001.SZ",
|
||||||
|
d("2026-09-14"),
|
||||||
|
&HoldingLifecycleEvidence::default(),
|
||||||
|
&calendar()
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
AutomaticTradePermission::default()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
policy
|
||||||
|
.evaluate_for_origin(
|
||||||
|
TradingActionOrigin::Strategy,
|
||||||
|
"000001.SZ",
|
||||||
|
d("2026-09-14"),
|
||||||
|
&HoldingLifecycleEvidence::default(),
|
||||||
|
&calendar()
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.buy_denial,
|
||||||
|
Some("automatic_trade_locked")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opening_date_follows_fills_not_partial_sales_or_corporate_conversions() {
|
||||||
|
let mut portfolio = crate::PortfolioState::new(100_000.0);
|
||||||
|
let position = portfolio.position_mut("000001.SZ");
|
||||||
|
position.buy(d("2026-09-11"), 100, 10.0);
|
||||||
|
position.buy(d("2026-09-14"), 200, 10.0);
|
||||||
|
position.sell(100, 10.0).unwrap();
|
||||||
|
assert_eq!(position.opened_date(), Some(d("2026-09-11")));
|
||||||
|
portfolio
|
||||||
|
.apply_successor_conversion("000001.SZ", "000002.SZ", 2.0, 0.0)
|
||||||
|
.unwrap();
|
||||||
|
let successor = portfolio.position_mut("000002.SZ");
|
||||||
|
assert_eq!(successor.opened_date(), Some(d("2026-09-11")));
|
||||||
|
assert_eq!(successor.last_buy_date(), Some(d("2026-09-14")));
|
||||||
|
successor.sell(400, 5.0).unwrap();
|
||||||
|
assert_eq!(successor.opened_date(), None);
|
||||||
|
successor.buy(d("2026-09-17"), 100, 5.0);
|
||||||
|
assert_eq!(successor.opened_date(), Some(d("2026-09-17")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,6 +28,7 @@ pub mod risk_control;
|
|||||||
pub mod rules;
|
pub mod rules;
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
pub mod strategy;
|
pub mod strategy;
|
||||||
|
pub mod holding_policy;
|
||||||
pub mod signal_contract;
|
pub mod signal_contract;
|
||||||
pub mod strategy_ai;
|
pub mod strategy_ai;
|
||||||
pub mod universe;
|
pub mod universe;
|
||||||
|
|||||||
@@ -558,7 +558,9 @@ fn alpha_beta(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn drawdown_stats(nav: &[f64]) -> (f64, usize) {
|
fn drawdown_stats(nav: &[f64]) -> (f64, usize) {
|
||||||
let mut peak = 0.0_f64;
|
// NAV is measured against the pre-period capital. The first real loss
|
||||||
|
// must not become a new zero-drawdown baseline.
|
||||||
|
let mut peak = 1.0_f64;
|
||||||
let mut max_drawdown = 0.0_f64;
|
let mut max_drawdown = 0.0_f64;
|
||||||
let mut duration = 0_usize;
|
let mut duration = 0_usize;
|
||||||
let mut max_duration = 0_usize;
|
let mut max_duration = 0_usize;
|
||||||
@@ -767,6 +769,28 @@ fn safe_div(numerator: f64, denominator: f64, fallback: f64) -> f64 {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drawdown_includes_initial_nav_without_adding_a_trading_day() {
|
||||||
|
let (drawdown, duration) = drawdown_stats(&[0.9, 0.99]);
|
||||||
|
assert!((drawdown + 0.1).abs() < 1e-12);
|
||||||
|
assert_eq!(duration, 2);
|
||||||
|
assert_eq!(drawdown_stats(&[1.0, 1.1, 1.1]), (0.0, 0));
|
||||||
|
assert_eq!(drawdown_stats(&[0.0]), (-1.0, 1));
|
||||||
|
assert_eq!(drawdown_stats(&[]), (0.0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_day_loss_is_preserved_in_shared_backtest_metrics() {
|
||||||
|
let curve = vec![
|
||||||
|
equity_point("2025-01-03", 99.16648349337, 98.81608059815, 100.0),
|
||||||
|
equity_point("2025-01-06", 99.68551588547, 98.65392198168, 98.81608059815),
|
||||||
|
];
|
||||||
|
let metrics = compute_backtest_metrics(&curve, &[], &[], &[], 100.0, None).unwrap();
|
||||||
|
assert!((metrics.max_drawdown + 0.0083351650663).abs() < 1e-12);
|
||||||
|
assert_eq!(metrics.total_trade_days, 2);
|
||||||
|
assert_eq!(metrics.max_drawdown_duration_days, 2);
|
||||||
|
}
|
||||||
|
|
||||||
fn equity_point(
|
fn equity_point(
|
||||||
date: &str,
|
date: &str,
|
||||||
total_equity: f64,
|
total_equity: f64,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -664,7 +664,7 @@ fn normalize_strategy_aliases_in_value_inner(
|
|||||||
for (key, child) in object.iter_mut() {
|
for (key, child) in object.iter_mut() {
|
||||||
normalize_strategy_aliases_in_value_inner(
|
normalize_strategy_aliases_in_value_inner(
|
||||||
child,
|
child,
|
||||||
in_risk_policy || matches!(key.as_str(), "riskPolicy" | "risk_policy"),
|
in_risk_policy || matches!(key.as_str(), "riskPolicy" | "risk_policy" | "automaticTradeProtection" | "automatic_trade_protection"),
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -686,6 +686,7 @@ const STRATEGY_ALIAS_GROUPS: &[(&str, &[&str])] = &[
|
|||||||
("signalSymbol", &["signal_symbol"]),
|
("signalSymbol", &["signal_symbol"]),
|
||||||
("engineConfig", &["engine_config"]),
|
("engineConfig", &["engine_config"]),
|
||||||
("runtimeExpressions", &["runtime_expressions"]),
|
("runtimeExpressions", &["runtime_expressions"]),
|
||||||
|
("automaticTradeProtection", &["automatic_trade_protection"]),
|
||||||
("rebalanceSchedule", &["rebalance_schedule"]),
|
("rebalanceSchedule", &["rebalance_schedule"]),
|
||||||
("skipWindows", &["skip_windows"]),
|
("skipWindows", &["skip_windows"]),
|
||||||
("dynamicRange", &["dynamic_range"]),
|
("dynamicRange", &["dynamic_range"]),
|
||||||
@@ -911,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)]
|
||||||
@@ -1004,6 +1007,8 @@ pub struct StrategyExpressionOrderingConfig {
|
|||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct StrategyExpressionTradingConfig {
|
pub struct StrategyExpressionTradingConfig {
|
||||||
|
#[serde(default, alias = "automatic_trade_protection")]
|
||||||
|
pub automatic_trade_protection: Option<crate::holding_policy::AutomaticTradeProtection>,
|
||||||
#[serde(default, alias = "buy_filter_expr")]
|
#[serde(default, alias = "buy_filter_expr")]
|
||||||
pub buy_filter_expr: Option<String>,
|
pub buy_filter_expr: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -1525,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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1570,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())
|
||||||
@@ -1587,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}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1627,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());
|
||||||
@@ -2124,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
|
||||||
@@ -2146,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()
|
||||||
@@ -2330,6 +2349,10 @@ pub fn platform_expr_config_from_spec(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(trading) = runtime_expr.trading.as_ref() {
|
if let Some(trading) = runtime_expr.trading.as_ref() {
|
||||||
|
if let Some(policy) = &trading.automatic_trade_protection {
|
||||||
|
policy.validate()?;
|
||||||
|
cfg.automatic_trade_protection = policy.clone();
|
||||||
|
}
|
||||||
if let Some(expr) = trading.buy_filter_expr.as_ref() {
|
if let Some(expr) = trading.buy_filter_expr.as_ref() {
|
||||||
cfg.buy_filter_expr = expr.clone();
|
cfg.buy_filter_expr = expr.clone();
|
||||||
}
|
}
|
||||||
@@ -2639,6 +2662,14 @@ pub fn platform_expr_config_from_spec(
|
|||||||
return Err("consume_signal_requires_verified_signal_book".into());
|
return Err("consume_signal_requires_verified_signal_book".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let has_automatic_policy = spec.runtime_expressions.as_ref().and_then(|runtime| runtime.trading.as_ref()).is_some_and(|trading| trading.automatic_trade_protection.is_some());
|
||||||
|
if has_automatic_policy {
|
||||||
|
let limit = i64::from(cfg.automatic_trade_protection.max_holding_days);
|
||||||
|
if cfg.max_holding_days.is_some_and(|previous| previous != limit) {
|
||||||
|
return Err("conflicting maximum holding policies".into());
|
||||||
|
}
|
||||||
|
cfg.max_holding_days = (limit > 0).then_some(limit);
|
||||||
|
}
|
||||||
Ok(cfg)
|
Ok(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3314,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!({
|
||||||
@@ -4322,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
|
||||||
@@ -4336,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!({
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ pub struct PositionLot {
|
|||||||
pub struct Position {
|
pub struct Position {
|
||||||
pub symbol: String,
|
pub symbol: String,
|
||||||
pub quantity: u32,
|
pub quantity: u32,
|
||||||
|
opened_date: Option<NaiveDate>,
|
||||||
|
last_buy_date: Option<NaiveDate>,
|
||||||
// ALV-compatible moving average execution price; partial sells do not rebase it.
|
// ALV-compatible moving average execution price; partial sells do not rebase it.
|
||||||
pub average_price: f64,
|
pub average_price: f64,
|
||||||
// ALV-compatible moving average including buy costs; partial sells do not rebase it.
|
// ALV-compatible moving average including buy costs; partial sells do not rebase it.
|
||||||
@@ -88,6 +90,8 @@ impl Position {
|
|||||||
Self {
|
Self {
|
||||||
symbol: symbol.into(),
|
symbol: symbol.into(),
|
||||||
quantity: 0,
|
quantity: 0,
|
||||||
|
opened_date: None,
|
||||||
|
last_buy_date: None,
|
||||||
average_price: 0.0,
|
average_price: 0.0,
|
||||||
average_cost: 0.0,
|
average_cost: 0.0,
|
||||||
last_price: 0.0,
|
last_price: 0.0,
|
||||||
@@ -114,6 +118,12 @@ impl Position {
|
|||||||
self.quantity == 0
|
self.quantity == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn opened_date(&self) -> Option<NaiveDate> {
|
||||||
|
self.opened_date
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn last_buy_date(&self) -> Option<NaiveDate> { self.last_buy_date }
|
||||||
|
|
||||||
pub fn buy(&mut self, date: NaiveDate, quantity: u32, price: f64) {
|
pub fn buy(&mut self, date: NaiveDate, quantity: u32, price: f64) {
|
||||||
self.buy_with_mark_price(date, quantity, price, price);
|
self.buy_with_mark_price(date, quantity, price, price);
|
||||||
}
|
}
|
||||||
@@ -130,6 +140,10 @@ impl Position {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let previous_quantity = self.quantity;
|
let previous_quantity = self.quantity;
|
||||||
|
self.last_buy_date = Some(self.last_buy_date.map_or(date, |previous| previous.max(date)));
|
||||||
|
if previous_quantity == 0 {
|
||||||
|
self.opened_date = Some(date);
|
||||||
|
}
|
||||||
let previous_average_price = self.average_price;
|
let previous_average_price = self.average_price;
|
||||||
let previous_average_cost = self.average_cost;
|
let previous_average_cost = self.average_cost;
|
||||||
let gross_amount = fixed_money_or_panic(
|
let gross_amount = fixed_money_or_panic(
|
||||||
@@ -267,6 +281,7 @@ impl Position {
|
|||||||
.checked_add(total_proceeds)
|
.checked_add(total_proceeds)
|
||||||
.ok_or_else(|| "fixed-point day sell value overflow".to_string())?;
|
.ok_or_else(|| "fixed-point day sell value overflow".to_string())?;
|
||||||
if self.quantity == 0 {
|
if self.quantity == 0 {
|
||||||
|
self.opened_date = None;
|
||||||
self.average_price = 0.0;
|
self.average_price = 0.0;
|
||||||
self.recalculate_average_cost();
|
self.recalculate_average_cost();
|
||||||
} else {
|
} else {
|
||||||
@@ -1224,6 +1239,8 @@ impl PortfolioState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let old_quantity = old_position.quantity;
|
let old_quantity = old_position.quantity;
|
||||||
|
let old_opened_date = old_position.opened_date;
|
||||||
|
let old_last_buy_date = old_position.last_buy_date;
|
||||||
let last_price = old_position.last_price;
|
let last_price = old_position.last_price;
|
||||||
let old_average_price = old_position.average_price;
|
let old_average_price = old_position.average_price;
|
||||||
let old_average_cost = old_position.average_cost;
|
let old_average_cost = old_position.average_cost;
|
||||||
@@ -1263,6 +1280,14 @@ impl PortfolioState {
|
|||||||
.entry(new_symbol.to_string())
|
.entry(new_symbol.to_string())
|
||||||
.or_insert_with(|| Position::new(new_symbol));
|
.or_insert_with(|| Position::new(new_symbol));
|
||||||
let successor_quantity_before = successor.quantity;
|
let successor_quantity_before = successor.quantity;
|
||||||
|
successor.opened_date = match (successor.opened_date, old_opened_date) {
|
||||||
|
(Some(current), Some(previous)) => Some(current.min(previous)),
|
||||||
|
(current, previous) => current.or(previous),
|
||||||
|
};
|
||||||
|
successor.last_buy_date = match (successor.last_buy_date, old_last_buy_date) {
|
||||||
|
(Some(current), Some(previous)) => Some(current.max(previous)),
|
||||||
|
(current, previous) => current.or(previous),
|
||||||
|
};
|
||||||
let successor_average_price_before = successor.average_price;
|
let successor_average_price_before = successor.average_price;
|
||||||
let successor_average_cost_before = successor.average_cost;
|
let successor_average_cost_before = successor.average_cost;
|
||||||
successor.lots.extend(converted_lots);
|
successor.lots.extend(converted_lots);
|
||||||
|
|||||||
@@ -264,6 +264,10 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
|||||||
title: "期货 runtime action 与提交校验".to_string(),
|
title: "期货 runtime action 与提交校验".to_string(),
|
||||||
detail: "runtimeExpressions.trading.actions 支持 futures_order、futures_open、futures_close、futures_close_today、futures_close_yesterday;字段包括 symbol、direction=long|short、quantityExpr/amountExpr、可选 limitPriceExpr、transactionCostExpr、whenExpr 和 reason。期货-only 策略把请求初始资金分配给期货账户且股票账户为0;股票+期货混合策略必须显式声明 futuresInitialCash,可选 stockInitialCash。合约必须先由 Source Lake 发布 futures_contract_daily、futures_contract_spec_history、futures_cost_margin_history 三张真实数据集;缺任一张时生成/回测必须失败,禁止手写默认乘数、保证金、费用或价格。订单进入撮合前继续检查上市/退市日期、停牌、trading_phase、限价 tick、涨跌停、反向挂单自成交、保证金和可平今昨仓。".to_string(),
|
detail: "runtimeExpressions.trading.actions 支持 futures_order、futures_open、futures_close、futures_close_today、futures_close_yesterday;字段包括 symbol、direction=long|short、quantityExpr/amountExpr、可选 limitPriceExpr、transactionCostExpr、whenExpr 和 reason。期货-only 策略把请求初始资金分配给期货账户且股票账户为0;股票+期货混合策略必须显式声明 futuresInitialCash,可选 stockInitialCash。合约必须先由 Source Lake 发布 futures_contract_daily、futures_contract_spec_history、futures_cost_margin_history 三张真实数据集;缺任一张时生成/回测必须失败,禁止手写默认乘数、保证金、费用或价格。订单进入撮合前继续检查上市/退市日期、停牌、trading_phase、限价 tick、涨跌停、反向挂单自成交、保证金和可平今昨仓。".to_string(),
|
||||||
},
|
},
|
||||||
|
ManualSection {
|
||||||
|
title: "trading.automatic_trade_protection(...)".to_string(),
|
||||||
|
detail: r#"当前股票/ETF策略的独立自动交易保护:trading.automatic_trade_protection({"buy_protection_days":3,"sell_cooldown_days":3,"max_holding_days":90,"locks":[{"symbol":"000001.SZ","start_date":"2026-09-11","end_date":null}]})。配置冻结到 runtimeExpressions.trading.automaticTradeProtection,回测、paper/live 共用内核;不并入全局风控。0/null/未填关闭对应周期;成交日及之后N个完整正式交易日内,买入保护禁止自动卖出及止盈止损,卖出冷却禁止自动增加仓位;只由真实成交启动或延长,拒绝/未成交/撤单不启动。最长持有按首次实际建仓后的正式交易日计数,加仓与部分卖出不重置,清仓后再开仓重置;日期锁定两端包含且高于自动退出,持仓占用真实预算和槽位。人工交易通过独立服务路径执行,仍校验权限、券商及T+1,不接受客户端origin旁路。持仓来源、实际成交或正式日历缺失时明确拒绝;期货与股票期货混合账户尚不支持此能力,不得悄悄忽略。旧trading.max_holding_days仍保留旧含义,不得和新配置声明不同最大周期。"#.to_string(),
|
||||||
|
},
|
||||||
ManualSection {
|
ManualSection {
|
||||||
title: "trading.rotation / order.* / order.modify / cancel.* / update_universe / subscribe".to_string(),
|
title: "trading.rotation / order.* / order.modify / cancel.* / update_universe / subscribe".to_string(),
|
||||||
detail: "支持股票显式下单、期货 runtime action、撤单、AlgoOrder、动态 universe 和账户资金动作。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段;需要模拟 平台内核 的日内订阅保护时,可写 trading.subscription_guard(true),未订阅 symbol 的显式订单会被拦截,TargetPortfolioSmart + AlgoOrder 会过滤未订阅标的。用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99, time_in_force=\"gtc\")、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices=VWAPOrder(930, 940), valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])、account.deposit_withdraw(100000, receiving_days=0)、account.finance_repay(50000)、account.set_management_fee_rate(0.001)。股票订单和 target_portfolio_smart 支持可选关键字 time_in_force=\"day|ioc|fok|gtc\",编译后写入 runtimeExpressions.trading.actions[].timeInForce:DAY 日内保留并在收盘 Expired,IOC 立即撤销未成交余量,FOK 必须全量可成交否则零成交,GTC 仅支持限价单并跨交易日保留;VWAP/TWAP 不接受 FOK/GTC。期货 action 必须由编译器写入结构化 runtimeExpressions,不得让策略源码直接构造 FuturesOrderIntent 或硬编码合约参数。symbol 使用标准证券/合约代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(),
|
detail: "支持股票显式下单、期货 runtime action、撤单、AlgoOrder、动态 universe 和账户资金动作。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段;需要模拟 平台内核 的日内订阅保护时,可写 trading.subscription_guard(true),未订阅 symbol 的显式订单会被拦截,TargetPortfolioSmart + AlgoOrder 会过滤未订阅标的。用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99, time_in_force=\"gtc\")、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices=VWAPOrder(930, 940), valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])、account.deposit_withdraw(100000, receiving_days=0)、account.finance_repay(50000)、account.set_management_fee_rate(0.001)。股票订单和 target_portfolio_smart 支持可选关键字 time_in_force=\"day|ioc|fok|gtc\",编译后写入 runtimeExpressions.trading.actions[].timeInForce:DAY 日内保留并在收盘 Expired,IOC 立即撤销未成交余量,FOK 必须全量可成交否则零成交,GTC 仅支持限价单并跨交易日保留;VWAP/TWAP 不接受 FOK/GTC。期货 action 必须由编译器写入结构化 runtimeExpressions,不得让策略源码直接构造 FuturesOrderIntent 或硬编码合约参数。symbol 使用标准证券/合约代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(),
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
use chrono::NaiveDate;
|
||||||
|
use fidc_core::holding_policy::{AutomaticTradeLock, AutomaticTradeProtection};
|
||||||
|
use fidc_core::{
|
||||||
|
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
|
||||||
|
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyMarketSnapshot, DataSet, Instrument,
|
||||||
|
MatchingType, OrderSide, PlatformExplicitOrderKind, PlatformExprStrategy,
|
||||||
|
PlatformExprStrategyConfig, PlatformTradeAction, PriceField,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn d(day: u32) -> NaiveDate {
|
||||||
|
NaiveDate::from_ymd_opt(2026, 9, day).unwrap()
|
||||||
|
}
|
||||||
|
fn data() -> DataSet {
|
||||||
|
let dates = [11, 14, 15, 16, 17, 18].map(d);
|
||||||
|
DataSet::from_components(
|
||||||
|
vec![Instrument {
|
||||||
|
symbol: "000001.SZ".into(),
|
||||||
|
name: "测试".into(),
|
||||||
|
board: "SZ".into(),
|
||||||
|
round_lot: 100,
|
||||||
|
listed_at: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()),
|
||||||
|
delisted_at: None,
|
||||||
|
status: "active".into(),
|
||||||
|
}],
|
||||||
|
dates
|
||||||
|
.iter()
|
||||||
|
.map(|date| DailyMarketSnapshot {
|
||||||
|
date: *date,
|
||||||
|
symbol: "000001.SZ".into(),
|
||||||
|
timestamp: Some(format!("{date} 15:00:00")),
|
||||||
|
day_open: 10.0,
|
||||||
|
open: 10.0,
|
||||||
|
high: 10.0,
|
||||||
|
low: 10.0,
|
||||||
|
close: 10.0,
|
||||||
|
last_price: 10.0,
|
||||||
|
bid1: 10.0,
|
||||||
|
ask1: 10.0,
|
||||||
|
prev_close: 10.0,
|
||||||
|
volume: 100_000,
|
||||||
|
minute_volume: 100_000,
|
||||||
|
bid1_volume: 100_000,
|
||||||
|
ask1_volume: 100_000,
|
||||||
|
trading_phase: Some("continuous".into()),
|
||||||
|
paused: false,
|
||||||
|
upper_limit: 11.0,
|
||||||
|
lower_limit: 9.0,
|
||||||
|
price_tick: 0.01,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
dates
|
||||||
|
.iter()
|
||||||
|
.map(|date| fidc_core::DailyFactorSnapshot {
|
||||||
|
date: *date,
|
||||||
|
symbol: "000001.SZ".into(),
|
||||||
|
market_cap_bn: 10.0,
|
||||||
|
free_float_cap_bn: 10.0,
|
||||||
|
pe_ttm: 10.0,
|
||||||
|
turnover_ratio: None,
|
||||||
|
effective_turnover_ratio: None,
|
||||||
|
adjustment_factor_backward1: Some(1.0),
|
||||||
|
extra_factors: Default::default(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
dates
|
||||||
|
.iter()
|
||||||
|
.map(|date| CandidateEligibility {
|
||||||
|
date: *date,
|
||||||
|
symbol: "000001.SZ".into(),
|
||||||
|
is_st: false,
|
||||||
|
is_star_st: false,
|
||||||
|
is_new_listing: false,
|
||||||
|
is_paused: false,
|
||||||
|
allow_buy: true,
|
||||||
|
allow_sell: true,
|
||||||
|
is_kcb: false,
|
||||||
|
is_one_yuan: false,
|
||||||
|
risk_level_code: None,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
dates
|
||||||
|
.iter()
|
||||||
|
.map(|date| BenchmarkSnapshot {
|
||||||
|
date: *date,
|
||||||
|
benchmark: "000300.SH".into(),
|
||||||
|
open: 100.0,
|
||||||
|
close: 100.0,
|
||||||
|
prev_close: 100.0,
|
||||||
|
volume: 1_000_000,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
fn action(quantity: &str, when: &str) -> PlatformTradeAction {
|
||||||
|
PlatformTradeAction::Order {
|
||||||
|
kind: PlatformExplicitOrderKind::Shares,
|
||||||
|
symbol: "000001.SZ".into(),
|
||||||
|
amount_expr: quantity.into(),
|
||||||
|
when_expr: Some(when.into()),
|
||||||
|
limit_price_expr: None,
|
||||||
|
time_in_force: None,
|
||||||
|
start_time_expr: None,
|
||||||
|
end_time_expr: None,
|
||||||
|
reason: "configured_strategy_action".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult {
|
||||||
|
let mut config = PlatformExprStrategyConfig::generic();
|
||||||
|
config.signal_symbol = "000001.SZ".into();
|
||||||
|
config.benchmark_symbol = "000300.SH".into();
|
||||||
|
config.rotation_enabled = false;
|
||||||
|
config.automatic_trade_protection = policy;
|
||||||
|
config.explicit_actions = vec![
|
||||||
|
action(
|
||||||
|
"100",
|
||||||
|
"decision_date == \"2026-09-11\" || decision_date == \"2026-09-18\"",
|
||||||
|
),
|
||||||
|
action("-100", "decision_date >= \"2026-09-14\""),
|
||||||
|
];
|
||||||
|
config.matching_type = MatchingType::CurrentBarClose;
|
||||||
|
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||||
|
.with_matching_type(MatchingType::CurrentBarClose);
|
||||||
|
BacktestEngine::new(
|
||||||
|
data(),
|
||||||
|
PlatformExprStrategy::new(config),
|
||||||
|
broker,
|
||||||
|
BacktestConfig {
|
||||||
|
initial_cash: 10_000.0,
|
||||||
|
benchmark_code: "000300.SH".into(),
|
||||||
|
start_date: Some(d(11)),
|
||||||
|
end_date: Some(d(18)),
|
||||||
|
decision_lag_trading_days: 0,
|
||||||
|
execution_price_field: PriceField::Close,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn framework_protection_uses_fills_and_covers_explicit_strategy_orders() {
|
||||||
|
let result = run(AutomaticTradeProtection {
|
||||||
|
buy_protection_days: 3,
|
||||||
|
sell_cooldown_days: 3,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.fills
|
||||||
|
.iter()
|
||||||
|
.map(|fill| (fill.date, fill.side, fill.quantity))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![(d(11), OrderSide::Buy, 100), (d(17), OrderSide::Sell, 100)]
|
||||||
|
);
|
||||||
|
assert!(!result.order_events.iter().any(|order| order.date == d(14)
|
||||||
|
|| order.date == d(15)
|
||||||
|
|| order.date == d(16)
|
||||||
|
|| order.date == d(18)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absolute_lock_blocks_initial_strategy_buy_without_a_rejected_order() {
|
||||||
|
let result = run(AutomaticTradeProtection {
|
||||||
|
locks: vec![AutomaticTradeLock {
|
||||||
|
symbol: "000001.SZ".into(),
|
||||||
|
start_date: d(11),
|
||||||
|
end_date: None,
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
assert!(result.fills.is_empty());
|
||||||
|
assert!(result.order_events.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maximum_holding_policy_applies_to_discrete_strategies_and_yields_to_buy_protection() {
|
||||||
|
let result = run(AutomaticTradeProtection {
|
||||||
|
max_holding_days: 1,
|
||||||
|
buy_protection_days: 3,
|
||||||
|
sell_cooldown_days: 3,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.fills
|
||||||
|
.iter()
|
||||||
|
.map(|fill| (fill.date, fill.side))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![(d(11), OrderSide::Buy), (d(17), OrderSide::Sell)]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
|
.order_events
|
||||||
|
.iter()
|
||||||
|
.any(|order| order.reason == "max_holding_days_exit")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serialized_framework_policy_survives_shared_alias_normalization_and_rejects_conflicts() {
|
||||||
|
let policy = serde_json::json!({"buy_protection_days":3,"sell_cooldown_days":3,"max_holding_days":90,"locks":[{"symbol":"000001.SZ","start_date":"2026-09-11","end_date":null}]});
|
||||||
|
for key in ["automaticTradeProtection", "automatic_trade_protection"] {
|
||||||
|
let value = serde_json::json!({"runtimeExpressions":{"trading":{key:policy}}});
|
||||||
|
let cfg = fidc_core::platform_expr_config_from_value("test", "000001.SZ", &value).unwrap();
|
||||||
|
assert_eq!(cfg.automatic_trade_protection.buy_protection_days, 3);
|
||||||
|
assert_eq!(cfg.max_holding_days, Some(90));
|
||||||
|
assert_eq!(cfg.automatic_trade_protection.locks.len(), 1);
|
||||||
|
}
|
||||||
|
let conflict = serde_json::json!({"runtimeExpressions":{"trading":{"maxHoldingDays":30,"automaticTradeProtection":policy}}});
|
||||||
|
assert!(
|
||||||
|
fidc_core::platform_expr_config_from_value("test", "000001.SZ", &conflict)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("conflicting maximum")
|
||||||
|
);
|
||||||
|
let unknown = serde_json::json!({"runtimeExpressions":{"trading":{"automaticTradeProtection":{"origin":"manual"}}}});
|
||||||
|
assert!(fidc_core::platform_expr_config_from_value("test", "000001.SZ", &unknown).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn locked_holding_keeps_its_slot_even_when_cash_can_buy_the_next_candidate() {
|
||||||
|
let base = data();
|
||||||
|
let dates = [11, 14, 15, 16, 17, 18].map(d);
|
||||||
|
let symbols = ["000001.SZ", "000002.SZ"];
|
||||||
|
let dataset = DataSet::from_components(
|
||||||
|
symbols
|
||||||
|
.iter()
|
||||||
|
.map(|symbol| {
|
||||||
|
let mut row = base.instruments()["000001.SZ"].clone();
|
||||||
|
row.symbol = (*symbol).into();
|
||||||
|
row
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
dates
|
||||||
|
.iter()
|
||||||
|
.flat_map(|date| {
|
||||||
|
symbols.iter().map(|symbol| {
|
||||||
|
let mut row = base.market(*date, "000001.SZ").unwrap().clone();
|
||||||
|
row.symbol = (*symbol).into();
|
||||||
|
row
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
dates
|
||||||
|
.iter()
|
||||||
|
.flat_map(|date| {
|
||||||
|
symbols.iter().map(|symbol| {
|
||||||
|
let mut row = base.factor(*date, "000001.SZ").unwrap().clone();
|
||||||
|
row.symbol = (*symbol).into();
|
||||||
|
row
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
dates
|
||||||
|
.iter()
|
||||||
|
.flat_map(|date| {
|
||||||
|
symbols.iter().map(|symbol| {
|
||||||
|
let mut row = base.candidate(*date, "000001.SZ").unwrap().clone();
|
||||||
|
row.symbol = (*symbol).into();
|
||||||
|
row
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
dates
|
||||||
|
.iter()
|
||||||
|
.map(|date| BenchmarkSnapshot {
|
||||||
|
date: *date,
|
||||||
|
benchmark: "000300.SH".into(),
|
||||||
|
open: 100.0,
|
||||||
|
close: 100.0,
|
||||||
|
prev_close: 100.0,
|
||||||
|
volume: 100_000,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut config = PlatformExprStrategyConfig::generic();
|
||||||
|
config.signal_symbol = "000001.SZ".into();
|
||||||
|
config.benchmark_symbol = "000300.SH".into();
|
||||||
|
config.strategy_name = "protection_test".into();
|
||||||
|
config.max_positions = 1;
|
||||||
|
config.selection_limit_expr = "1".into();
|
||||||
|
config.refresh_rate = 1;
|
||||||
|
config.exposure_expr = "0.5".into();
|
||||||
|
config.market_cap_lower_expr = "0".into();
|
||||||
|
config.market_cap_upper_expr = "100".into();
|
||||||
|
config.stock_filter_expr="(decision_date == \"2026-09-11\" && symbol == \"000001.SZ\") || (decision_date != \"2026-09-11\" && symbol == \"000002.SZ\")".into();
|
||||||
|
config.automatic_trade_protection = AutomaticTradeProtection {
|
||||||
|
locks: vec![AutomaticTradeLock {
|
||||||
|
symbol: "000001.SZ".into(),
|
||||||
|
start_date: d(14),
|
||||||
|
end_date: Some(d(16)),
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||||
|
.with_matching_type(MatchingType::CurrentBarClose);
|
||||||
|
let result = BacktestEngine::new(
|
||||||
|
dataset,
|
||||||
|
PlatformExprStrategy::new(config),
|
||||||
|
broker,
|
||||||
|
BacktestConfig {
|
||||||
|
initial_cash: 10_000.0,
|
||||||
|
benchmark_code: "000300.SH".into(),
|
||||||
|
start_date: Some(d(11)),
|
||||||
|
end_date: Some(d(18)),
|
||||||
|
decision_lag_trading_days: 0,
|
||||||
|
execution_price_field: PriceField::Close,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.fills
|
||||||
|
.first()
|
||||||
|
.map(|fill| (fill.symbol.as_str(), fill.date)),
|
||||||
|
Some(("000001.SZ", d(11)))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!result
|
||||||
|
.fills
|
||||||
|
.iter()
|
||||||
|
.any(|fill| [d(14), d(15), d(16)].contains(&fill.date)),
|
||||||
|
"{:?}",
|
||||||
|
result.fills
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.fills.iter().any(|fill| fill.symbol == "000002.SZ"
|
||||||
|
&& fill.side == OrderSide::Buy
|
||||||
|
&& fill.date == d(17)),
|
||||||
|
"{:?}",
|
||||||
|
result.fills
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# 策略级自动交易保护
|
||||||
|
|
||||||
|
## 统一合同
|
||||||
|
|
||||||
|
`runtimeExpressions.trading.automaticTradeProtection` 是每个股票/ETF策略自己的不可变配置。股票池、表达式轮动和显式订单复用 `holding_policy` 内核,不新增全局共享配置,也不修改未配置的历史策略。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"buy_protection_days":3,"sell_cooldown_days":3,"max_holding_days":90,"locks":[{"symbol":"000001.SZ","start_date":"2026-09-11","end_date":"2026-09-16"}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 周期为空、null或0关闭,必须为0—3650整数;锁定支持同股多个区间,起止日包含当日,截止null持续有效。
|
||||||
|
- 买入保护禁止自动减仓/清仓及止盈止损;卖出冷却禁止自动增加仓位。只有实际成交计时,部分成交延长对应最后成交日;未成交、拒绝、撤单不启动。
|
||||||
|
- 成交日及后续N个完整正式交易日均受保护。例如周五成交、N=3,保护到下周三结束,周四恢复;不按72小时或自然日替代。
|
||||||
|
- 最长持有从连续持仓第一次实际买入开始,跨正式交易日计数;加仓、部分卖出和有证据的证券转换不重置,完全卖出再买入开启新周期。锁定和买入保护优先于最长持有退出。
|
||||||
|
- 日期锁定禁止自动买卖,已接受的挂单不自动撤销;手工路径只绕过自动策略保护,不绕过账户授权、T+1、券商和风控。
|
||||||
|
- 保留的真实持仓继续占用资金与席位,不把未完成卖出当现金。最长持有退出先形成唯一最终目标,不能叠加一笔策略部分卖出和一笔框架全量卖出。
|
||||||
|
- 在线上下文重建必须注入已经校验的真实成交/持仓快照,不能把重建日或旧行情日当建仓日。期货或股票期货混合账户未纳入本合同,显式拒绝。
|
||||||
|
|
||||||
|
## 根因补充修复
|
||||||
|
|
||||||
|
组合 `decision_date == "2026-09-11" && symbol == "000001.SZ"` 会落到字符串表达式路径。旧代码遗漏日期等内建标识符的保留登记,又按“额外因子”注入NaN,覆盖同名真实日期,造成选股错误。现登记全部已注入内建字段,并禁止额外因子覆盖已存在的作用域变量。单独数字VM日期测试不足以发现该问题,新增日期+证券混合选择回归。
|
||||||
|
|
||||||
|
## 验证与边界
|
||||||
|
|
||||||
|
原生完整回测测试验证:显式策略真实模拟成交日启动3日保护/禁买、日期锁定零委托、最长持有让位于保护、锁定持仓占据资金与席位、解锁后才按候选顺序买入;序列化和别名归一不改max_holding_days字段,冲突策略拒绝。现有534核心用例通过(6个既有忽略项)。这些是隔离内核测试,不是GT实际成交验收。
|
||||||
@@ -0,0 +1,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忽略。新场景使用隔离合成账户/报价,未以此冒充原始市场样本。
|
||||||
|
|
||||||
|
## 真实分钟回放
|
||||||
|
|
||||||
|
- 同一冻结请求、信号及bundle,2025-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/ERROR,Runtime0行不能视为实际执行成功;行情`/readyz`仍503,THS -4302配额问题未恢复。
|
||||||
|
|
||||||
|
## 未完成
|
||||||
|
|
||||||
|
next-open全天量容量和动态滑点使用全天high/low/volume的问题没有被本次修复覆盖,仍按P0时点问题处理。新的执行观察规格位于`/Users/boris/WorkSpace/docs/fidc/execution-observation-prd-20260911.md`,只是后续实现规格,不是已部署能力。禁止静默改用昨量、自动关闭风控、修改旧结果或把后续一分钟量回填到开盘。
|
||||||
|
|
||||||
|
自然Paper/Live还需要合格模型、正式审批和真实可用行情;不开放2026封存,不替研究模型审批。当前实盘列表为空,不自行补建。
|
||||||
|
|
||||||
|
## 证据
|
||||||
|
|
||||||
|
`/srv/fidc/canonical/run/research/execution-leg-risk-20260911/`:
|
||||||
|
|
||||||
|
- `engine-focused.log`、`engine-full.log`、`trading-full.log`、`backtest-full.log`。
|
||||||
|
- `minute-replay/request.json`、`submission.json`、`result.json`、`comparison.json`。
|
||||||
|
- `deploy-before.json`、`deploy-after.json`、`running-binary-verification.json`、`post-deploy-log-audit.json`。
|
||||||
|
- 官方部署日志、研究审计脚本与执行观察设计稿。不改旧证据目录和WFT V18制品。
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# 执行价风控与共享信号账户隔离验收
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
本次修复已通过测试并发布 177。只证明一元股请求阶段价格修复、同一共享信号的账户隔离和既有真实样本结果不变;完整生产闭环尚未完成。next-open 全天容量、动态滑点的日内可见性及逐成交腿风控仍是未关闭项,不能称为全部成交无未来信息。
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
- `risk_control.rs` 的 Buy 一元股规则改用本次 `check_price`,不再读取日线 `is_one_yuan` 或当天更早的 `day_open`。无效价格拒绝,其他缺失风险事实仍拒绝;显式 Selection 规则保留。
|
||||||
|
- Trading 共用 `risk.rs` 的 Paper/Live 订单前检查使用新鲜 `last_price`,不再被日线标记或开盘价覆盖。选股阶段的开关和日线标记单独处理。
|
||||||
|
- 缺执行价格继续输出具体 `missing_execution_price field=open`,保留 `historical_price_fallback=false`;停牌等权威状态仍优先,不因新通用校验丢失根因。
|
||||||
|
- 没有修改共享信号内容、模型、账号权限、运行配置、Source Lake、研究 checkpoint 或既有回测数据。
|
||||||
|
|
||||||
|
## 账户隔离组合
|
||||||
|
|
||||||
|
隔离共享核心测试使用同一份经过原生校验的 `fidc.signal-book/v2`,信号只表达保留 50% 持仓。当前价 10、止损 10%、止盈 20%;每个账户独立计算实际订单。
|
||||||
|
|
||||||
|
| 原数量 | 买入价 | 买入费用总额 | 预期剩余 | 结果 |
|
||||||
|
| ---: | ---: | ---: | ---: | --- |
|
||||||
|
| 1,000 | 8.00 | 0 | 0 | 止盈优先于半仓目标 |
|
||||||
|
| 1,000 | 10.00 | 0 | 500 | 按本账户数量减半 |
|
||||||
|
| 3,000 | 10.00 | 0 | 1,500 | 不共用其他账户数量 |
|
||||||
|
| 1,000 | 12.00 | 0 | 0 | 止损 |
|
||||||
|
| 1,000 | 11.11 | 0 | 500 | 尚未跨过止损阈值 |
|
||||||
|
| 1,000 | 11.11 | 2.00 | 0 | 含费用成本跨过止损阈值 |
|
||||||
|
|
||||||
|
六种账户卖出后,当天再消费同一买入目标均不得买回;独立未卖出账户可正常买入。同一信号版本不变,策略规划不预先修改持仓。这些是隔离合成账户测试,不是券商委托/成交证据。
|
||||||
|
|
||||||
|
## 回归与真实回放
|
||||||
|
|
||||||
|
- Engine:656 通过,8 个专用测试忽略。
|
||||||
|
- Trading 工作区:537 通过,9 个专用测试忽略。
|
||||||
|
- Backtest Runner:370 通过、3 忽略;API:99 通过、1 忽略。
|
||||||
|
- 一元股专项覆盖真实执行价为 0.9/1.0/1.2、旧标记与新价格相反、缺失其他风险事实、NaN/无效价及开关独立性。
|
||||||
|
|
||||||
|
实际 HTTP 回测使用原始冻结请求、信号和 bundle,未复制结果:
|
||||||
|
|
||||||
|
- 原基准:`btr_req_260d0f3179d40fda5c918d48eba0a239bd335406c82a1cbe`。
|
||||||
|
- 新运行:`btr_1789091571964_2429476_0`。
|
||||||
|
- 区间:2025-02-05 至 2025-02-10;初始资金 10,000,000;目标 10 仓;next-open;滑点 0.002;佣金万三、最低 5。
|
||||||
|
- 两次均 28 成交,最终资产 10,089,448.918844,收益 0.89448918844%。
|
||||||
|
- 订单、成交、账户事件、权益、持仓、风险审计六项摘要相同。
|
||||||
|
- Canonical SHA256:`befa50b3ec5b94adafede459903db7e2542797cf0eefe2de32900afc83ca1481`。
|
||||||
|
- 服务端 3.954 秒,客户端含轮询 6.072 秒。此短区间已有缓存样本不能代表全市场冷态或多年性能。
|
||||||
|
|
||||||
|
## 发布
|
||||||
|
|
||||||
|
- Engine `d3c36e947894fd220b62ecd6fbfe02473f70bd2c`,tag `v2026.9.11`。
|
||||||
|
- Trading `dfcec36bd1c0f92e73cd30073540eb39dbd02835`,tag `v2026.9.11`。
|
||||||
|
- Backtest service `75202cc3b876daf99d0d2dffb988ca456c34aabf`,重新链接上述引擎。
|
||||||
|
- 只使用官方 installer,以 Boris 构建和运行。发布后 Backtest/Runtime/Paper/Live 的进程和 HTTP `/healthz` 正常,运行二进制核对独立清单,不仅检查源码 HEAD。实时行情 `/readyz` 仍为 503,原因如下,不能宣称自然交易正常。
|
||||||
|
- 原 3 Paper / 1 Live 配置和状态摘要前后相同;本次投影 Paper 为 `52a909117fe8f01ae35a327bd86310e2583d291609bba6596dc0f49a2b10559c`,Live 为 `aaec1e9dbc984012e9fe677e54db1efb860edd59d5840d1dc87c4b230b26bac6`。仅与本次相同投影的发布前数据对比,不与此前其他字段投影混比。
|
||||||
|
- Source PID 2267019、因子主进程 PID 2178403、NRestarts 均不变。本轮未调用 Source/因子重启入口;不能由主 PID 不变推断全部因子子任务已经验收。
|
||||||
|
|
||||||
|
## 未关闭问题
|
||||||
|
|
||||||
|
### 实时行情配额
|
||||||
|
|
||||||
|
发布后文件日志审查发现 THS `-4302`:本周行情用量超过 1.5 亿。受保护的行情源目录只返回 `ths_realtime`,enabled=true、ready=false;没有已配置可用的授权备用源。行情 `/readyz` 返回503、snapshot_count=0,实盘日志反复记录实际执行日2026-09-11请求150证券、收到0新鲜行情,因此 next-open 规划失败。
|
||||||
|
|
||||||
|
所查尾部8,000行日志中,配额告警最早已出现在01:30:12 UTC(上海09:30),早于本轮09:58的Trading发布。不能把该故障归因于本次一元股代码或用重启解决。不得拿昨日收盘、Source历史数据或手工报价代替实时价格;恢复账户配额或配置正式授权的可用行情源后,才能继续自然交易验收。
|
||||||
|
|
||||||
|
Paper的3条WARN为启动重建的PG读取,分别约1.015/1.122/1.460秒;本轮未见ERROR,但这只是采样范围,不能称全部日志无异常。证据:`realtime-quota-timeline.json`、`realtime-provider-readiness.json`、`post-deploy-file-log-audit.json`。Runtime无新采样日志不等于实际调度通过。
|
||||||
|
|
||||||
|
### 执行容量与校准
|
||||||
|
|
||||||
|
独立依赖探针确认:保持 next-open 订单和开盘价不变,仅修改执行日后来形成的全天量,成交量从 100 变为 1,000;仅修改全天 high/low,动态滑点成交价从 10.305 变为 11.000。探针是合成输入,不冒充市场证据。
|
||||||
|
|
||||||
|
详见 `/Users/boris/WorkSpace/docs/fidc/execution-time-capacity-coordination-20260911.md`。下一步须分离实测执行时点容量和声明的容量估计、冻结校准数据时钟、覆盖挂单逐成交腿;不能偷偷改为昨日成交量、关闭限制或使用未来一分钟量。V18 研究只允许新不可变后继评估,不能改现有结果。
|
||||||
|
|
||||||
|
自然 Paper 观察与正式 Live 仍需真实合格版本和正式审批。当前研究控制模型仅 23 个验证日,2026 留出期继续封存;不得为演示闭环降低门槛、伪造 observed、代替审批或手工发证券订单。
|
||||||
|
|
||||||
|
### 并发代码合并
|
||||||
|
|
||||||
|
报告推送时远端新增 `33924b1/f2e228e` 的策略自动交易保护。已保留并合并至main `f7d16fb`,177源码同步,组合引擎回归666通过、8忽略。该合并后的新保护尚未由本任务部署,线上仍使用本报告列出的d3c36e9/dfcec36清单;不能把源码同步当作发布或把对方功能归为本次已完成的自然交易验收。
|
||||||
|
|
||||||
|
随后 Trading main 新增 `4ff7ee9aeefa2e1013098212dc75b4969499a1a6`,本机与177均已正常快进同步,合并组合工作区545通过、10忽略。此为并发功能合并后的源码测试,同样不改变本次发布清单;不重复部署另一个任务尚在验收的完整交易保护功能。
|
||||||
|
|
||||||
|
## 证据
|
||||||
|
|
||||||
|
177 根目录:`/srv/fidc/canonical/run/research/execution-risk-signal-audit-20260911/`。
|
||||||
|
|
||||||
|
- `engine-full-tests-v2.log`、`trading-full-tests.log`、`backtest-full-tests.log`。
|
||||||
|
- `deploy-before.json`、`deploy-after.json`、`running-binary-verification.json`。
|
||||||
|
- `same-signal-backtest/request.json`、`submission.json`、`result.json`、`comparison.json`。
|
||||||
|
- `execution-time-dependency-probe.json` SHA256:`feeb69275b8ad537f16e4c119cc7e59dd3a15773334fb591e27d7afdf34311d3`。
|
||||||
|
- 官方部署日志与独立探针源码保存在相同证据根,不写入交易数据库或修改原始行情。
|
||||||
@@ -0,0 +1,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 项显式忽略),新增顺序/旧排名方向/选股排除补位验证。该记录不是实盘成交验收,也不代表手选与自动候选混合来源完整实现。
|
||||||
Reference in New Issue
Block a user