Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75e5e32281 | |||
| 7d05f8f7c7 | |||
| d01f32ca5b | |||
| 3dd7b2bd50 | |||
| c8f6ed102c | |||
| 4664f1a2d3 | |||
| 40481e8825 | |||
| 2473cc04bb | |||
| 3fa1004ec5 | |||
| c4632bacf1 | |||
| 7dcaae594a | |||
| 999bf5bd01 | |||
| b281045df5 | |||
| 35acb1c7e7 | |||
| bbbd9cf3e0 | |||
| 5dc5ef9df5 | |||
| fe8f6c1c26 | |||
| 30e8227099 | |||
| 588da4958f | |||
| bc4754288e | |||
| 6b0cdbcecc | |||
| 5ff05e0d3d | |||
| bab4d47b46 | |||
| fdd26667c9 | |||
| 29522b69fe | |||
| a54489fe92 | |||
| 20c14437c6 | |||
| dce5454ec8 | |||
| 72b64451ac | |||
| d17d67d6ca | |||
| 63c577bd76 | |||
| 8c190597ae | |||
| ad063264cf | |||
| 0108c91bae | |||
| ee2865829d | |||
| e66460c4e9 | |||
| 2811886a52 | |||
| 3b5a7cd318 | |||
| 3fe2da3ee0 | |||
| ee77028907 | |||
| 1bcaa0b3d8 | |||
| 1703a7aa5e |
Generated
+14
@@ -192,6 +192,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"ta-lib",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
@@ -537,6 +538,19 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ta-lib"
|
||||
version = "0.8.1"
|
||||
source = "git+https://github.com/TA-Lib/ta-lib.git?rev=dd5a90259a3f9e04e2da9f38bf0719a841b40108#dd5a90259a3f9e04e2da9f38bf0719a841b40108"
|
||||
dependencies = [
|
||||
"ta-lib-dispatch",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ta-lib-dispatch"
|
||||
version = "0.1.2"
|
||||
source = "git+https://github.com/TA-Lib/ta-lib.git?rev=dd5a90259a3f9e04e2da9f38bf0719a841b40108#dd5a90259a3f9e04e2da9f38bf0719a841b40108"
|
||||
|
||||
[[package]]
|
||||
name = "thin-vec"
|
||||
version = "0.2.16"
|
||||
|
||||
@@ -15,3 +15,4 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
thiserror.workspace = true
|
||||
ta-lib = { git = "https://github.com/TA-Lib/ta-lib.git", rev = "dd5a90259a3f9e04e2da9f38bf0719a841b40108" }
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
use fidc_core::factor_events::{self, Expr, Frame};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{self, Read};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Request {
|
||||
expressions: std::collections::BTreeMap<String, Expr>,
|
||||
frame: Frame,
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut input = String::new();
|
||||
io::stdin().read_to_string(&mut input)?;
|
||||
let output = if input.trim().is_empty() {
|
||||
factor_events::catalog()
|
||||
} else if serde_json::from_str::<Value>(&input)?.get("rank_history").is_some() {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Rank { dates:Vec<chrono::NaiveDate>, universe:Vec<String>, values:std::collections::BTreeMap<String,Vec<Option<f64>>> }
|
||||
let value:Value=serde_json::from_str(&input)?;
|
||||
let request:Rank=serde_json::from_value(value["rank_history"].clone())?;
|
||||
json!({"result":fidc_core::factor_cross_section::rank_history(&request.dates,&request.universe,&request.values)?})
|
||||
} else {
|
||||
let request: Request = serde_json::from_str(&input)?;
|
||||
let results = request
|
||||
.expressions
|
||||
.iter()
|
||||
.map(|(id, expr)| {
|
||||
let result = match factor_events::evaluate(expr, &request.frame) {
|
||||
Ok(v) => json!({"result":v}),
|
||||
Err(e) => json!({"error":e}),
|
||||
};
|
||||
(id.clone(), result)
|
||||
})
|
||||
.collect::<std::collections::BTreeMap<String, Value>>();
|
||||
json!({"contract":factor_events::CONTRACT,"results":results,"read_only":true})
|
||||
};
|
||||
println!("{}", serde_json::to_string(&output)?);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use std::io::{self, Read};
|
||||
fn main() {
|
||||
let mut input=String::new();io::stdin().read_to_string(&mut input).unwrap();
|
||||
let request=serde_json::from_str(&input).unwrap();
|
||||
match fidc_core::market_event_context::aggregate(request) {
|
||||
Ok(value)=>println!("{}",serde_json::to_string(&value).unwrap()),
|
||||
Err(error)=>{eprintln!("{error}");std::process::exit(1);}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use std::io::Read;
|
||||
fn main() {
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_to_string(&mut input).unwrap();
|
||||
let value: serde_json::Value = serde_json::from_str(&input).unwrap();
|
||||
let spec: fidc_core::daily_patterns::PatternSpec =
|
||||
serde_json::from_value(value["spec"].clone()).unwrap();
|
||||
let bars: Vec<fidc_core::session_events::MinuteBar> =
|
||||
serde_json::from_value(value["bars"].clone()).unwrap();
|
||||
let result = fidc_core::session_events::evaluate(
|
||||
&spec.validate().unwrap(),
|
||||
value["symbol"].as_str().unwrap(),
|
||||
&bars,
|
||||
serde_json::from_value(value["decision_at"].clone()).unwrap(),
|
||||
);
|
||||
match result {
|
||||
Ok(row) => println!(
|
||||
"{}",
|
||||
serde_json::json!({"contract":fidc_core::session_events::CONTRACT,"row":row,"read_only":true,"source_evidence_verified":false})
|
||||
),
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -491,6 +491,7 @@ pub struct DataSetSnapshotComponents {
|
||||
pub benchmarks: Vec<BenchmarkSnapshot>,
|
||||
pub corporate_actions: Vec<CorporateAction>,
|
||||
pub execution_quotes: Vec<IntradayExecutionQuote>,
|
||||
pub completed_minute_bars: Vec<crate::session_events::MinuteBar>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -1418,6 +1419,7 @@ pub struct DataSet {
|
||||
eligible_universe_by_date: Arc<OnceLock<BTreeMap<NaiveDate, Vec<EligibleUniverseSnapshot>>>>,
|
||||
benchmark_code: String,
|
||||
futures_params_by_symbol: Arc<HashMap<String, Vec<FuturesTradingParameter>>>,
|
||||
completed_minute_bars: Arc<BTreeMap<(NaiveDate,String),Vec<crate::session_events::MinuteBar>>>,
|
||||
}
|
||||
|
||||
struct DailySymbolRows<'a, T> {
|
||||
@@ -1850,9 +1852,9 @@ impl DataSet {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(symbol_id, series)| {
|
||||
series.as_ref().map(|series| {
|
||||
(symbol_by_id[symbol_id].to_string(), Arc::clone(series))
|
||||
})
|
||||
series
|
||||
.as_ref()
|
||||
.map(|series| (symbol_by_id[symbol_id].to_string(), Arc::clone(series)))
|
||||
})
|
||||
.collect::<AHashMap<_, _>>();
|
||||
|
||||
@@ -1876,9 +1878,9 @@ impl DataSet {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(symbol_id, series)| {
|
||||
series.as_ref().map(|series| {
|
||||
(symbol_by_id[symbol_id].to_string(), Arc::clone(series))
|
||||
})
|
||||
series
|
||||
.as_ref()
|
||||
.map(|series| (symbol_by_id[symbol_id].to_string(), Arc::clone(series)))
|
||||
})
|
||||
.collect::<AHashMap<_, _>>();
|
||||
let factor_texts = factor_texts
|
||||
@@ -1900,16 +1902,10 @@ impl DataSet {
|
||||
|
||||
let factor_market_cap_order_by_date =
|
||||
build_factor_market_cap_order(&factor_by_date, &factor_symbol_ids_by_date);
|
||||
let market_row_positions_by_date = build_dense_row_positions(
|
||||
&market_by_date,
|
||||
&market_symbol_ids_by_date,
|
||||
symbol_count,
|
||||
);
|
||||
let factor_row_positions_by_date = build_dense_row_positions(
|
||||
&factor_by_date,
|
||||
&factor_symbol_ids_by_date,
|
||||
symbol_count,
|
||||
);
|
||||
let market_row_positions_by_date =
|
||||
build_dense_row_positions(&market_by_date, &market_symbol_ids_by_date, symbol_count);
|
||||
let factor_row_positions_by_date =
|
||||
build_dense_row_positions(&factor_by_date, &factor_symbol_ids_by_date, symbol_count);
|
||||
let candidate_row_positions_by_date = build_dense_row_positions(
|
||||
&candidate_by_date,
|
||||
&candidate_symbol_ids_by_date,
|
||||
@@ -1960,6 +1956,7 @@ impl DataSet {
|
||||
eligible_universe_by_date: Arc::new(OnceLock::new()),
|
||||
benchmark_code,
|
||||
futures_params_by_symbol: Arc::new(futures_params_by_symbol),
|
||||
completed_minute_bars: Arc::new(BTreeMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2321,7 +2318,10 @@ impl DataSet {
|
||||
}
|
||||
|
||||
for (component, strong_count) in [
|
||||
("daily market panel", Arc::strong_count(&self.market_by_date)),
|
||||
(
|
||||
"daily market panel",
|
||||
Arc::strong_count(&self.market_by_date),
|
||||
),
|
||||
(
|
||||
"market series by symbol",
|
||||
Arc::strong_count(&self.market_series_by_symbol),
|
||||
@@ -2456,7 +2456,8 @@ impl DataSet {
|
||||
.flat_map(|rows_by_symbol| rows_by_symbol.values())
|
||||
.map(Vec::len)
|
||||
.sum();
|
||||
let mut execution_quote_dates = execution_quotes_by_date.keys().copied().collect::<Vec<_>>();
|
||||
let mut execution_quote_dates =
|
||||
execution_quotes_by_date.keys().copied().collect::<Vec<_>>();
|
||||
execution_quote_dates.sort_unstable();
|
||||
self.execution_quotes_by_date = Arc::new(execution_quotes_by_date);
|
||||
self.execution_quote_dates = Arc::new(execution_quote_dates);
|
||||
@@ -2629,9 +2630,21 @@ impl DataSet {
|
||||
benchmarks,
|
||||
corporate_actions,
|
||||
execution_quotes,
|
||||
completed_minute_bars:self.completed_minute_bars.values().flatten().cloned().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_completed_minute_bars(mut self,bars:Vec<crate::session_events::MinuteBar>)->Result<Self,String> {
|
||||
self.completed_minute_bars=crate::session_events::bar_store(bars)?;Ok(self)
|
||||
}
|
||||
|
||||
pub fn with_shared_completed_minute_bars(mut self,bars:crate::session_events::BarStore)->Self {self.completed_minute_bars=bars;self}
|
||||
pub fn completed_minute_bar_count(&self)->usize {self.completed_minute_bars.values().map(Vec::len).sum()}
|
||||
|
||||
pub fn completed_minute_bars_on(&self,date:NaiveDate,symbol:&str)->&[crate::session_events::MinuteBar] {
|
||||
self.completed_minute_bars.get(&(date,symbol.into())).map(Vec::as_slice).unwrap_or(&[])
|
||||
}
|
||||
|
||||
pub fn benchmark_series(&self) -> Vec<BenchmarkSnapshot> {
|
||||
self.benchmark_by_date.values().cloned().collect()
|
||||
}
|
||||
@@ -3362,6 +3375,12 @@ impl DataSet {
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
pub fn is_reference_only_benchmark(&self, symbol: &str) -> bool {
|
||||
if symbol != self.benchmark_code() { return false; }
|
||||
let Some(symbol_id) = self.symbol_id(symbol) else { return true; };
|
||||
!self.candidate_symbol_ids_by_date.values().any(|ids| ids.contains(&symbol_id))
|
||||
}
|
||||
|
||||
pub fn bundle_on(&self, date: NaiveDate) -> Result<DailySnapshotBundle, DataSetError> {
|
||||
let benchmark = self
|
||||
.benchmark(date)
|
||||
@@ -5221,10 +5240,7 @@ mod tests {
|
||||
[data.symbol_id("000001.SZ").unwrap() as usize]
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert!(!Arc::ptr_eq(
|
||||
&market_series_before,
|
||||
market_series_after
|
||||
));
|
||||
assert!(!Arc::ptr_eq(&market_series_before, market_series_after));
|
||||
assert!(Arc::ptr_eq(&daily_base_before, &market_series_after.base));
|
||||
assert_eq!(
|
||||
serde_json::to_value(market_series_after.snapshot_at(0)).unwrap(),
|
||||
@@ -6178,11 +6194,8 @@ mod tests {
|
||||
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
|
||||
let quote = IntradayExecutionQuote {
|
||||
date,
|
||||
timestamp: NaiveDateTime::parse_from_str(
|
||||
"2025-01-02 10:18:00",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
.unwrap(),
|
||||
timestamp: NaiveDateTime::parse_from_str("2025-01-02 10:18:00", "%Y-%m-%d %H:%M:%S")
|
||||
.unwrap(),
|
||||
symbol: "000001.SZ".to_string(),
|
||||
last_price: 10.0,
|
||||
bid1: 10.0,
|
||||
@@ -6222,7 +6235,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_selection_uses_structured_instrument_dates_and_status_only() {
|
||||
fn baseline_selection_uses_dated_lifecycle_not_latest_undated_status() {
|
||||
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
|
||||
let instrument = |name: &str, status: &str, delisted_at: Option<NaiveDate>| Instrument {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
@@ -6250,7 +6263,7 @@ mod tests {
|
||||
Some(&instrument("退市测试", "active", None)),
|
||||
date
|
||||
));
|
||||
assert!(!instrument_passes_baseline_selection(
|
||||
assert!(instrument_passes_baseline_selection(
|
||||
Some(&instrument("正常名称", "delisted", None)),
|
||||
date
|
||||
));
|
||||
@@ -6365,13 +6378,16 @@ mod tests {
|
||||
"'adjustment_factor_backward1'",
|
||||
] {
|
||||
for typed_value in [None, Some(1.0)] {
|
||||
assert!(matches!(
|
||||
normalize_factor_snapshots(vec![snapshot(
|
||||
typed_value,
|
||||
BTreeMap::from([(Cow::Borrowed(field), 2.0)]),
|
||||
)]),
|
||||
Err(DataSetError::ReservedTypedFactorInExtraMap { .. })
|
||||
), "reserved alias accepted: {field}");
|
||||
assert!(
|
||||
matches!(
|
||||
normalize_factor_snapshots(vec![snapshot(
|
||||
typed_value,
|
||||
BTreeMap::from([(Cow::Borrowed(field), 2.0)]),
|
||||
)]),
|
||||
Err(DataSetError::ReservedTypedFactorInExtraMap { .. })
|
||||
),
|
||||
"reserved alias accepted: {field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+128
-23
@@ -91,6 +91,9 @@ impl Default for ProcessEventRetention {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DailyEquityPoint {
|
||||
/// Close-of-signal-day cash baseline before lagged trading begins.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub signal_baseline: bool,
|
||||
#[serde(with = "date_format")]
|
||||
pub date: NaiveDate,
|
||||
pub cash: f64,
|
||||
@@ -109,6 +112,14 @@ pub struct DailyEquityPoint {
|
||||
pub diagnostics: String,
|
||||
}
|
||||
|
||||
impl DailyEquityPoint {
|
||||
pub fn benchmark_reference_close(&self) -> f64 {
|
||||
if self.signal_baseline { self.benchmark_close }
|
||||
else if self.benchmark_prev_close.is_finite() && self.benchmark_prev_close > f64::EPSILON { self.benchmark_prev_close }
|
||||
else { self.benchmark_close }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BacktestResult {
|
||||
pub strategy_name: String,
|
||||
@@ -334,7 +345,7 @@ impl BacktestResult {
|
||||
let mut previous_benchmark = self
|
||||
.equity_curve
|
||||
.first()
|
||||
.map(|point| point.benchmark_prev_close)
|
||||
.map(DailyEquityPoint::benchmark_reference_close)
|
||||
.unwrap_or_default();
|
||||
for point in &self.equity_curve {
|
||||
let point_nav = if point.unit_nav.is_finite() && point.unit_nav > 0.0 {
|
||||
@@ -454,13 +465,20 @@ pub struct BacktestEngine<S, C, R> {
|
||||
futures_cost_model: FuturesTransactionCostModel,
|
||||
futures_validation_config: FuturesValidationConfig,
|
||||
execution_quote_loader: Option<ExecutionQuoteLoader>,
|
||||
preplanned_decision_quote_symbols_by_date:
|
||||
Option<Arc<BTreeMap<NaiveDate, BTreeSet<String>>>>,
|
||||
preplanned_decision_quote_symbols_by_date: Option<Arc<BTreeMap<NaiveDate, BTreeSet<String>>>>,
|
||||
execution_quote_request_cache:
|
||||
BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>,
|
||||
execution_absence_notes: BTreeMap<NaiveDate, Vec<String>>,
|
||||
execution_lifecycle_reported: BTreeSet<(String, String)>,
|
||||
risk_free_rate_contract: Option<RiskFreeRateContract>,
|
||||
}
|
||||
|
||||
fn all_instruments_have_dated_absence(data: &DataSet, date: NaiveDate) -> bool {
|
||||
let mut instruments = data.instruments().values()
|
||||
.filter(|instrument| !data.is_reference_only_benchmark(&instrument.symbol)).peekable();
|
||||
instruments.peek().is_some() && instruments.all(|instrument| instrument.dated_market_absence_reason(date).is_some())
|
||||
}
|
||||
|
||||
fn backtest_execution_schedule(
|
||||
data: &DataSet,
|
||||
start_date: Option<NaiveDate>,
|
||||
@@ -483,10 +501,15 @@ fn backtest_execution_schedule(
|
||||
if decision_lag_trading_days == 0 {
|
||||
if has_decision_inputs(execution_date) {
|
||||
schedule.push((execution_date, Some((calendar_idx, execution_date))));
|
||||
} else if all_instruments_have_dated_absence(data, execution_date) {
|
||||
schedule.push((execution_date, None));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !has_execution_market(execution_date) {
|
||||
if all_instruments_have_dated_absence(data, execution_date) {
|
||||
schedule.push((execution_date, None));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let decision_slot = calendar_idx
|
||||
@@ -497,6 +520,7 @@ fn backtest_execution_schedule(
|
||||
schedule.push((execution_date, decision_slot));
|
||||
}
|
||||
None => schedule.push((execution_date, None)),
|
||||
Some((_, decision_date)) if all_instruments_have_dated_absence(data, decision_date) => schedule.push((execution_date, None)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -544,6 +568,8 @@ impl<S, C, R> BacktestEngine<S, C, R> {
|
||||
execution_quote_loader: None,
|
||||
preplanned_decision_quote_symbols_by_date: None,
|
||||
execution_quote_request_cache: BTreeSet::new(),
|
||||
execution_absence_notes: BTreeMap::new(),
|
||||
execution_lifecycle_reported: BTreeSet::new(),
|
||||
risk_free_rate_contract: None,
|
||||
}
|
||||
}
|
||||
@@ -758,6 +784,31 @@ where
|
||||
end_time: Option<NaiveTime>,
|
||||
symbols: &mut BTreeSet<String>,
|
||||
) -> Result<(), BacktestError> {
|
||||
let mut available = BTreeSet::new();
|
||||
for symbol in symbols.iter() {
|
||||
let instrument = self.data.instrument(symbol).ok_or_else(|| BacktestError::Execution(format!(
|
||||
"execution_data_missing reason=instrument_metadata_or_code_mapping_missing symbol={symbol} execution_date={execution_date}"
|
||||
)))?;
|
||||
if let Some(reason) = instrument.dated_market_absence_reason(execution_date) {
|
||||
if self.data.price(execution_date, symbol, PriceField::Close).is_some()
|
||||
|| !self.data.execution_quotes_on(execution_date, symbol).is_empty()
|
||||
{
|
||||
return Err(BacktestError::Execution(format!(
|
||||
"execution_data_conflict reason={reason} symbol={symbol} execution_date={execution_date} listed_at={:?} delisted_at={:?}",
|
||||
instrument.listed_at, instrument.delisted_at
|
||||
)));
|
||||
}
|
||||
if self.execution_lifecycle_reported.insert((symbol.clone(), reason.to_string())) {
|
||||
self.execution_absence_notes.entry(execution_date).or_default().push(format!(
|
||||
"execution_data_absence reason={reason} symbol={symbol} execution_date={execution_date} listed_at={:?} delisted_at={:?} no_price_fill=true",
|
||||
instrument.listed_at, instrument.delisted_at
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
available.insert(symbol.clone());
|
||||
}
|
||||
*symbols = available;
|
||||
symbols.retain(|symbol| {
|
||||
let request_key = (execution_date, symbol.clone(), start_time, end_time);
|
||||
if self.execution_quote_request_cache.contains(&request_key) {
|
||||
@@ -825,9 +876,6 @@ where
|
||||
let mut paused_with_quotes = Vec::new();
|
||||
let mut missing_daily_market = Vec::new();
|
||||
for symbol in requested_symbols {
|
||||
let Some(_candidate) = self.data.candidate(execution_date, symbol) else {
|
||||
continue;
|
||||
};
|
||||
let Some(market) = self.data.market(execution_date, symbol) else {
|
||||
missing_daily_market.push(symbol.clone());
|
||||
continue;
|
||||
@@ -2181,12 +2229,13 @@ where
|
||||
date: execution_date,
|
||||
})?;
|
||||
let notes = join_text_parts(corporate_action_notes.into_iter());
|
||||
let absence = all_instruments_have_dated_absence(&self.data, execution_date);
|
||||
let diagnostics = join_text_parts(
|
||||
std::iter::once(format!(
|
||||
"decision_lag_warmup lag_days={} execution_index={}",
|
||||
self.config.decision_lag_trading_days, execution_idx
|
||||
))
|
||||
.chain(broker_diagnostics.into_iter()),
|
||||
std::iter::once(if absence {
|
||||
format!("execution_data_absence reason=all_instruments_outside_dated_lifecycle execution_date={execution_date} cash_period_retained=true no_price_fill=true")
|
||||
} else { format!("decision_lag_warmup lag_days={} execution_index={}", self.config.decision_lag_trading_days, execution_idx) })
|
||||
.chain(broker_diagnostics.into_iter())
|
||||
.chain(self.execution_absence_notes.remove(&execution_date).unwrap_or_default()),
|
||||
);
|
||||
let holdings_for_day = portfolio.holdings_summary(execution_date);
|
||||
let holding_start = result.daily_holdings.len();
|
||||
@@ -2203,6 +2252,7 @@ where
|
||||
previous_external_cash_flow_total = portfolio.external_cash_flow_total();
|
||||
|
||||
result.equity_curve.push(DailyEquityPoint {
|
||||
signal_baseline: execution_idx == 0,
|
||||
date: execution_date,
|
||||
cash: aggregate_cash,
|
||||
market_value: aggregate_market_value,
|
||||
@@ -2538,9 +2588,8 @@ where
|
||||
.map(Arc::clone)
|
||||
{
|
||||
let empty_symbols = BTreeSet::new();
|
||||
let decision_quote_symbols = preplanned
|
||||
.get(&execution_date)
|
||||
.unwrap_or(&empty_symbols);
|
||||
let decision_quote_symbols =
|
||||
preplanned.get(&execution_date).unwrap_or(&empty_symbols);
|
||||
self.ensure_execution_quotes_for_symbols_at_times(
|
||||
execution_date,
|
||||
decision_quote_symbols,
|
||||
@@ -3354,7 +3403,8 @@ where
|
||||
decision
|
||||
.diagnostics
|
||||
.into_iter()
|
||||
.chain(broker_diagnostics.into_iter()),
|
||||
.chain(broker_diagnostics.into_iter())
|
||||
.chain(self.execution_absence_notes.remove(&execution_date).unwrap_or_default()),
|
||||
);
|
||||
let holdings_for_day = portfolio.holdings_summary(execution_date);
|
||||
let holding_start = result.daily_holdings.len();
|
||||
@@ -3371,6 +3421,7 @@ where
|
||||
previous_external_cash_flow_total = portfolio.external_cash_flow_total();
|
||||
|
||||
result.equity_curve.push(DailyEquityPoint {
|
||||
signal_baseline: false,
|
||||
date: execution_date,
|
||||
cash: aggregate_cash,
|
||||
market_value: aggregate_market_value,
|
||||
@@ -3953,17 +4004,11 @@ where
|
||||
let Some(instrument) = self.data.instrument(&symbol) else {
|
||||
continue;
|
||||
};
|
||||
let is_unresolved = instrument.is_delisted_on_or_before(date)
|
||||
|| (instrument.status.eq_ignore_ascii_case("delisted")
|
||||
&& instrument.delisted_at.is_none()
|
||||
&& self.data.market(date, &symbol).is_none());
|
||||
let is_unresolved = instrument.is_delisted_on_or_before(date);
|
||||
if !is_unresolved {
|
||||
continue;
|
||||
}
|
||||
let effective_delisted_at = instrument
|
||||
.delisted_at
|
||||
.or_else(|| self.data.calendar().previous_day(date))
|
||||
.unwrap_or(date);
|
||||
let effective_delisted_at = instrument.delisted_at.expect("dated delisting checked");
|
||||
let reason = format!(
|
||||
concat!(
|
||||
"unresolved_delisted_position symbol={} quantity={} effective_date={} status={} ",
|
||||
@@ -5532,6 +5577,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wholly_prelisting_universe_retains_cash_days_without_fabricating_prices() {
|
||||
let dates = [d(2025, 1, 2), d(2025, 1, 3), d(2025, 1, 6)];
|
||||
let mut engine = engine_with_matching(MatchingType::CurrentBarClose, PriceField::Close, 0);
|
||||
engine.config.end_date = Some(dates[2]);
|
||||
let mut markets = vec![market(dates[2], 10.0, 10.0)];
|
||||
markets.extend(dates.iter().map(|date| DailyMarketSnapshot { symbol: "000852.SH".into(), ..market(*date, 1000.0, 1000.0) }));
|
||||
engine.data = DataSet::from_components(
|
||||
vec![Instrument { listed_at: Some(dates[2]), ..default_instrument() }, Instrument { symbol: "000852.SH".into(), listed_at: None, ..default_instrument() }],
|
||||
markets, vec![factor(dates[2])], vec![candidate(dates[2])],
|
||||
dates.iter().map(|date| benchmark(*date)).collect(),
|
||||
).unwrap();
|
||||
assert_eq!(super::backtest_execution_dates(&engine.data, Some(dates[0]), Some(dates[2]), 0), dates);
|
||||
assert_eq!(super::backtest_execution_dates(&engine.data, Some(dates[0]), Some(dates[2]), 1), dates);
|
||||
let result = engine.run().unwrap();
|
||||
assert_eq!(result.equity_curve.len(), 3);
|
||||
for point in &result.equity_curve[..2] {
|
||||
assert_eq!(point.total_equity, 100_000.0);
|
||||
assert_eq!(point.market_value, 0.0);
|
||||
assert!(point.diagnostics.contains("cash_period_retained=true"));
|
||||
}
|
||||
assert!(result.order_events.is_empty());
|
||||
assert!(engine.data.market(dates[0], SYMBOL).is_none());
|
||||
assert!(result.equity_curve[0].signal_baseline);
|
||||
assert!(!result.equity_curve[1].signal_baseline);
|
||||
assert!(!super::all_instruments_have_dated_absence(&dataset(), dates[0]));
|
||||
}
|
||||
|
||||
fn engine_with_matching(
|
||||
matching_type: MatchingType,
|
||||
execution_price_field: PriceField,
|
||||
@@ -5985,6 +6058,38 @@ mod tests {
|
||||
.expect("zero-volume stock may have no minute bars");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_quote_filter_skips_only_dated_legal_absence_before_loading() {
|
||||
let date = d(2025, 9, 10);
|
||||
for (symbol, listed_at, delisted_at, reason) in [
|
||||
("920038.BJ", Some(d(2026, 8, 5)), None, "not_yet_listed"),
|
||||
("563360.SH", Some(d(2026, 8, 5)), None, "not_yet_listed"),
|
||||
("000001.SZ", Some(d(2010, 1, 1)), Some(d(2025, 9, 9)), "delisted"),
|
||||
] {
|
||||
let instrument = Instrument { symbol: symbol.into(), listed_at, delisted_at, ..default_instrument() };
|
||||
let data = DataSet::from_components(vec![instrument], vec![], vec![], vec![], vec![benchmark(date)]).unwrap();
|
||||
let mut engine = full_day_coverage_engine(data, date);
|
||||
engine.execution_quote_loader = Some(Box::new(|_| panic!("legal lifecycle absence must not load prices")));
|
||||
engine.load_missing_execution_quotes(date, None, None, &mut BTreeSet::from([symbol.to_string()])).unwrap();
|
||||
let notes = engine.execution_absence_notes.get(&date).unwrap();
|
||||
assert!(notes[0].contains(reason));
|
||||
assert!(notes[0].contains(symbol));
|
||||
engine.load_missing_execution_quotes(date, None, None, &mut BTreeSet::from([symbol.to_string()])).unwrap();
|
||||
assert_eq!(engine.execution_absence_notes[&date].len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_identity_or_missing_candidate_does_not_waive_quote_coverage() {
|
||||
let date = d(2025, 9, 10);
|
||||
let data = DataSet::from_components(vec![default_instrument()], vec![], vec![], vec![], vec![benchmark(date)]).unwrap();
|
||||
let mut engine = full_day_coverage_engine(data, date);
|
||||
let error = engine.load_missing_execution_quotes(date, None, None, &mut BTreeSet::from(["unmapped".to_string()])).unwrap_err();
|
||||
assert!(error.to_string().contains("instrument_metadata_or_code_mapping_missing"));
|
||||
let error = engine.validate_full_day_execution_quote_coverage(date, &[SYMBOL.to_string()]).unwrap_err();
|
||||
assert!(error.to_string().contains("missing_daily_market"));
|
||||
}
|
||||
|
||||
fn run_scheduled_next_open_with_dataset(dataset: DataSet) -> super::BacktestResult {
|
||||
run_scheduled_next_open_with_dataset_and_broker(
|
||||
dataset,
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Cross-sectional operators require an explicit complete universe, never a UI page.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub const OPERATORS: &[&str] = &[
|
||||
"RANK",
|
||||
"PERCENTILE",
|
||||
"TOP",
|
||||
"BOTTOM",
|
||||
"TOP_PERCENT",
|
||||
"BOTTOM_PERCENT",
|
||||
"WINSORIZE",
|
||||
"INDUSTRY_NEUTRALIZE",
|
||||
"SIZE_NEUTRALIZE",
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Observation {
|
||||
pub symbol: String,
|
||||
pub value: f64,
|
||||
pub industry: Option<String>,
|
||||
pub market_cap: Option<f64>,
|
||||
}
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Output {
|
||||
pub symbol: String,
|
||||
pub value: f64,
|
||||
}
|
||||
|
||||
/// Every date ranks the same frozen research universe; unknown inputs invalidate the whole date.
|
||||
pub fn rank_history(
|
||||
dates: &[chrono::NaiveDate], universe: &[String], values: &BTreeMap<String, Vec<Option<f64>>>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
use serde_json::json;
|
||||
if dates.is_empty() || dates.windows(2).any(|w| w[0] >= w[1]) || universe.len() < 2
|
||||
|| universe.len() > 20_000 || dates.len().saturating_mul(universe.len()) > 2_000_000
|
||||
|| universe.iter().collect::<BTreeSet<_>>().len() != universe.len()
|
||||
|| values.keys().collect::<BTreeSet<_>>() != universe.iter().collect::<BTreeSet<_>>()
|
||||
|| values.values().any(|v| v.len() != dates.len() || v.iter().flatten().any(|v| !v.is_finite())) {
|
||||
return Err("research_rank_history_incomplete_or_invalid_universe".into());
|
||||
}
|
||||
let mut rank = universe.iter().map(|s|(s.clone(),vec![None;dates.len()])).collect::<BTreeMap<_,_>>();
|
||||
let mut percentile = rank.clone();
|
||||
let mut unknown_dates = Vec::new();
|
||||
for (i, date) in dates.iter().enumerate() {
|
||||
let missing = universe.iter().filter(|s|values[*s][i].is_none()).collect::<Vec<_>>();
|
||||
if !missing.is_empty() {
|
||||
unknown_dates.push(json!({"date":date,"missing_count":missing.len(),"missing_symbol_sample":missing.iter().take(20).collect::<Vec<_>>(),"sample_limit":20}));
|
||||
continue;
|
||||
}
|
||||
let observations = universe.iter().map(|s|Observation{symbol:s.clone(),value:values[s][i].unwrap(),industry:None,market_cap:None}).collect::<Vec<_>>();
|
||||
for item in evaluate("RANK", universe, &observations, 0.0)? {rank.get_mut(&item.symbol).unwrap()[i]=Some(item.value);}
|
||||
for item in evaluate("PERCENTILE", universe, &observations, 0.0)? {percentile.get_mut(&item.symbol).unwrap()[i]=Some(item.value);}
|
||||
}
|
||||
Ok(json!({"rank":rank,"percentile":percentile,"unknown_dates":unknown_dates,
|
||||
"universe":universe,"dates":dates,"tie_policy":"average_rank_descending",
|
||||
"membership_policy":"fixed_research_scope_not_historical_index_membership"}))
|
||||
}
|
||||
|
||||
fn mean(values: &[f64]) -> f64 {
|
||||
let base = values[0];
|
||||
base + values
|
||||
.iter()
|
||||
.skip(1)
|
||||
.map(|v| (v - base) / values.len() as f64)
|
||||
.sum::<f64>()
|
||||
}
|
||||
fn quantile(sorted: &[f64], p: f64) -> f64 {
|
||||
let x = p * (sorted.len() - 1) as f64;
|
||||
let l = x.floor() as usize;
|
||||
let r = x.ceil() as usize;
|
||||
sorted[l] + (sorted[r] - sorted[l]) * (x - l as f64)
|
||||
}
|
||||
|
||||
pub fn evaluate(
|
||||
name: &str,
|
||||
universe: &[String],
|
||||
rows: &[Observation],
|
||||
threshold: f64,
|
||||
) -> Result<Vec<Output>, String> {
|
||||
let expected = universe.iter().collect::<BTreeSet<_>>();
|
||||
if rows.is_empty()
|
||||
|| rows.len() > 20_000
|
||||
|| expected.len() != universe.len()
|
||||
|| rows.len() != universe.len()
|
||||
|| rows.iter().map(|r| &r.symbol).collect::<BTreeSet<_>>() != expected
|
||||
|| rows.iter().any(|r| !r.value.is_finite())
|
||||
{
|
||||
return Err("cross_section_incomplete_or_invalid_universe".into());
|
||||
}
|
||||
if !OPERATORS.contains(&name) || !threshold.is_finite() {
|
||||
return Err("cross_section_operator_invalid".into());
|
||||
}
|
||||
if matches!(name, "TOP" | "BOTTOM") && (threshold < 1.0 || threshold.fract() != 0.0)
|
||||
|| matches!(name, "TOP_PERCENT" | "BOTTOM_PERCENT") && !(0.0..=1.0).contains(&threshold)
|
||||
|| name == "WINSORIZE" && !(0.0..0.5).contains(&threshold)
|
||||
{
|
||||
return Err("cross_section_threshold_invalid".into());
|
||||
}
|
||||
let mut sorted = rows.iter().map(|r| r.value).collect::<Vec<_>>();
|
||||
sorted.sort_by(f64::total_cmp);
|
||||
let mut industry_values: BTreeMap<&str, Vec<f64>> = BTreeMap::new();
|
||||
if name == "INDUSTRY_NEUTRALIZE" {
|
||||
for row in rows {
|
||||
let industry = row
|
||||
.industry
|
||||
.as_deref()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.ok_or("cross_section_pit_industry_missing")?;
|
||||
industry_values.entry(industry).or_default().push(row.value);
|
||||
}
|
||||
}
|
||||
let size = if name == "SIZE_NEUTRALIZE" {
|
||||
let x = rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
r.market_cap
|
||||
.filter(|v| v.is_finite() && *v > 0.0)
|
||||
.map(f64::ln)
|
||||
.ok_or("cross_section_market_cap_missing")
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let xm = mean(&x);
|
||||
let ym = mean(&sorted);
|
||||
let variance = x.iter().map(|v| (v - xm).powi(2)).sum::<f64>();
|
||||
if variance == 0.0 || rows.len() < 3 {
|
||||
return Err("cross_section_size_regression_unidentified".into());
|
||||
}
|
||||
let beta = x
|
||||
.iter()
|
||||
.zip(rows)
|
||||
.map(|(x, y)| (x - xm) * (y.value - ym))
|
||||
.sum::<f64>()
|
||||
/ variance;
|
||||
Some((x, xm, ym, beta))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
rows.iter()
|
||||
.enumerate()
|
||||
.map(|(index, row)| {
|
||||
let low = sorted.partition_point(|v| *v < row.value);
|
||||
let high = sorted.partition_point(|v| *v <= row.value);
|
||||
let rank = (low + 1 + high) as f64 / 2.0;
|
||||
let descending = (rows.len() + 1) as f64 - rank;
|
||||
let percentile = if rows.len() == 1 {
|
||||
0.5
|
||||
} else {
|
||||
(rank - 1.0) / (rows.len() - 1) as f64
|
||||
};
|
||||
let value = match name {
|
||||
"RANK" => descending,
|
||||
"PERCENTILE" => percentile,
|
||||
"TOP" => f64::from(descending <= threshold),
|
||||
"BOTTOM" => f64::from(rank <= threshold),
|
||||
"TOP_PERCENT" => f64::from(descending <= threshold * rows.len() as f64),
|
||||
"BOTTOM_PERCENT" => f64::from(rank <= threshold * rows.len() as f64),
|
||||
"WINSORIZE" => row.value.clamp(
|
||||
quantile(&sorted, threshold),
|
||||
quantile(&sorted, 1.0 - threshold),
|
||||
),
|
||||
"INDUSTRY_NEUTRALIZE" => {
|
||||
row.value - mean(&industry_values[row.industry.as_deref().unwrap()])
|
||||
}
|
||||
"SIZE_NEUTRALIZE" => {
|
||||
let (x, xm, ym, beta) = size.as_ref().unwrap();
|
||||
row.value - (ym + beta * (x[index] - xm))
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
if !value.is_finite() {
|
||||
return Err("cross_section_result_nonfinite".into());
|
||||
}
|
||||
Ok(Output {
|
||||
symbol: row.symbol.clone(),
|
||||
value,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn historical_ranks_keep_ties_and_unknown_full_cross_sections() {
|
||||
let dates=["2026-09-07","2026-09-08","2026-09-09"].map(|d|d.parse().unwrap());
|
||||
let universe=vec!["A".into(),"B".into(),"C".into()];
|
||||
let values=BTreeMap::from([("A".into(),vec![None,Some(10.0),Some(20.0)]),("B".into(),vec![Some(10.0),Some(10.0),Some(10.0)]),("C".into(),vec![Some(20.0),Some(5.0),Some(15.0)])]);
|
||||
let out=rank_history(&dates,&universe,&values).unwrap();
|
||||
assert_eq!(out["rank"]["A"],serde_json::json!([null,1.5,1.0]));
|
||||
assert_eq!(out["rank"]["C"],serde_json::json!([null,3.0,2.0]));
|
||||
assert_eq!(out["unknown_dates"][0]["missing_count"],1);
|
||||
let earlier=values.iter().map(|(s,v)|(s.clone(),v[..2].to_vec())).collect();
|
||||
let first=rank_history(&dates[..2],&universe,&earlier).unwrap();
|
||||
assert_eq!(&out["rank"]["A"].as_array().unwrap()[..2],first["rank"]["A"].as_array().unwrap());
|
||||
assert!(rank_history(&dates,&universe[..2],&values).is_err());
|
||||
}
|
||||
fn rows() -> Vec<Observation> {
|
||||
[1.0, 3.0, 3.0, 4.0]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &value)| Observation {
|
||||
symbol: format!("S{i}"),
|
||||
value,
|
||||
industry: Some(if i < 2 { "A" } else { "B" }.into()),
|
||||
market_cap: Some(10.0 + i as f64),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[test]
|
||||
fn ties_keep_equal_rank_and_missing_universe_rejects() {
|
||||
let r = rows();
|
||||
let u = r.iter().map(|r| r.symbol.clone()).collect::<Vec<_>>();
|
||||
let out = evaluate("RANK", &u, &r, 0.0).unwrap();
|
||||
assert_eq!(
|
||||
out.iter().map(|r| r.value).collect::<Vec<_>>(),
|
||||
vec![4.0, 2.5, 2.5, 1.0]
|
||||
);
|
||||
assert!(evaluate("RANK", &u, &r[..3], 0.0).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn neutralization_preserves_input_order() {
|
||||
let r = rows();
|
||||
let u = r.iter().map(|r| r.symbol.clone()).collect::<Vec<_>>();
|
||||
let out = evaluate("INDUSTRY_NEUTRALIZE", &u, &r, 0.0).unwrap();
|
||||
assert_eq!(
|
||||
out.iter().map(|r| r.value).collect::<Vec<_>>(),
|
||||
vec![-1.0, 1.0, -0.5, 0.5]
|
||||
);
|
||||
assert!(evaluate("TOP_PERCENT", &u, &r, 20.0).is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -70,8 +70,17 @@ impl Instrument {
|
||||
|
||||
pub fn is_active_on(&self, date: NaiveDate) -> bool {
|
||||
self.listed_at.is_none_or(|listed_at| listed_at <= date)
|
||||
&& !self.is_delisted_before(date)
|
||||
&& !(self.status.eq_ignore_ascii_case("inactive") && self.delisted_at.is_none())
|
||||
&& !self.is_delisted_on_or_before(date)
|
||||
}
|
||||
|
||||
pub fn dated_market_absence_reason(&self, date: NaiveDate) -> Option<&'static str> {
|
||||
if self.listed_at.is_some_and(|listed| date < listed) {
|
||||
Some("not_yet_listed")
|
||||
} else if self.is_delisted_on_or_before(date) {
|
||||
Some("delisted")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +116,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_is_dated_and_latest_undated_terminal_status_is_not_historical_evidence() {
|
||||
let mut item = instrument("BJS", 100);
|
||||
let listing = chrono::NaiveDate::from_ymd_opt(2026, 8, 5).unwrap();
|
||||
let removal = chrono::NaiveDate::from_ymd_opt(2026, 9, 10).unwrap();
|
||||
item.listed_at = Some(listing);
|
||||
item.delisted_at = Some(removal);
|
||||
assert_eq!(item.dated_market_absence_reason(listing.pred_opt().unwrap()), Some("not_yet_listed"));
|
||||
assert!(item.is_active_on(listing));
|
||||
assert!(!item.is_active_on(removal));
|
||||
assert_eq!(item.dated_market_absence_reason(removal), Some("delisted"));
|
||||
item.delisted_at = None;
|
||||
for status in ["delisting", "delisted", "inactive", "terminated"] {
|
||||
item.status = status.into();
|
||||
assert!(item.is_active_on(listing));
|
||||
assert_eq!(item.dated_market_absence_reason(listing), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_quantity_rules_are_case_insensitive_without_allocating_normalized_boards() {
|
||||
let kcb = instrument(" kSh ", 100);
|
||||
|
||||
@@ -2,6 +2,12 @@ pub mod broker;
|
||||
pub mod calendar;
|
||||
pub mod cost;
|
||||
pub mod data;
|
||||
pub mod daily_patterns;
|
||||
pub mod pattern_context;
|
||||
pub mod session_events;
|
||||
pub mod factor_events;
|
||||
pub mod factor_cross_section;
|
||||
pub mod market_event_context;
|
||||
pub mod engine;
|
||||
pub mod event_bus;
|
||||
pub mod events;
|
||||
@@ -19,6 +25,7 @@ pub mod risk_control;
|
||||
pub mod rules;
|
||||
pub mod scheduler;
|
||||
pub mod strategy;
|
||||
pub mod signal_contract;
|
||||
pub mod strategy_ai;
|
||||
pub mod universe;
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
//! Complete published daily cross sections, independent of trading candidates and accounts.
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub const CONTRACT: &str = "fidc_market_event_context_v1";
|
||||
pub fn implementation_sha256() -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
format!("{:x}", Sha256::digest(include_bytes!("market_event_context.rs")))
|
||||
}
|
||||
pub const COMMON_FIELDS: &[&str] = &[
|
||||
"market_breadth", "market_return", "market_limit_up_count", "market_limit_down_count",
|
||||
"market_limit_up_rate", "market_broken_limit_rate", "market_high_board", "market_profit_effect",
|
||||
];
|
||||
pub const INDUSTRY_FIELDS: &[&str] = &[
|
||||
"industry_close", "industry_return_20", "industry_breadth", "industry_rank", "industry_size",
|
||||
];
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Observation {
|
||||
pub symbol: String,
|
||||
pub industry: Option<String>,
|
||||
pub close: Option<f64>,
|
||||
pub high: Option<f64>,
|
||||
pub previous_close: Option<f64>,
|
||||
pub upper_limit: Option<f64>,
|
||||
pub lower_limit: Option<f64>,
|
||||
pub no_limit: Option<bool>,
|
||||
pub paused: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Day {
|
||||
pub date: NaiveDate,
|
||||
pub universe: Vec<String>,
|
||||
pub rows: Vec<Observation>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Deserialize, Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct State {
|
||||
pub last_date: Option<NaiveDate>,
|
||||
pub streaks: BTreeMap<String, Option<u32>>,
|
||||
pub limit_ups: BTreeSet<String>,
|
||||
pub industry_history: BTreeMap<String, Vec<f64>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Request {
|
||||
pub days: Vec<Day>,
|
||||
#[serde(default)]
|
||||
pub previous: State,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct OutputDay {
|
||||
pub date: NaiveDate,
|
||||
pub common: BTreeMap<String, Option<f64>>,
|
||||
pub industries: BTreeMap<String, BTreeMap<String, Option<f64>>>,
|
||||
pub members: BTreeMap<String, Option<String>>,
|
||||
pub securities: usize,
|
||||
pub active: usize,
|
||||
pub paused: usize,
|
||||
pub no_limit: usize,
|
||||
pub profit_effect_members: Vec<String>,
|
||||
pub profit_effect_missing: Vec<String>,
|
||||
pub industry_missing: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Output {
|
||||
pub contract: &'static str,
|
||||
pub days: Vec<OutputDay>,
|
||||
pub state: State,
|
||||
}
|
||||
|
||||
fn positive(value: Option<f64>, symbol: &str, field: &str) -> Result<f64, String> {
|
||||
value.filter(|v| v.is_finite() && *v > 0.0)
|
||||
.ok_or_else(|| format!("market_event_input_invalid: {symbol} {field}"))
|
||||
}
|
||||
fn average(values: impl Iterator<Item = f64>, n: usize) -> f64 {
|
||||
values.map(|v| v / n as f64).sum()
|
||||
}
|
||||
|
||||
pub fn aggregate(request: Request) -> Result<Output, String> {
|
||||
let mut state = request.previous;
|
||||
if request.days.is_empty() || request.days.len() > 30
|
||||
|| request.days.iter().map(|d| d.rows.len()).sum::<usize>() > 60_000
|
||||
|| state.streaks.len() > 20_000 || state.limit_ups.len() > 20_000
|
||||
|| state.industry_history.len() > 2000
|
||||
|| state.industry_history.values().any(|v| v.is_empty() || v.len() > 21
|
||||
|| v.iter().any(|x| !x.is_finite() || *x <= 0.0))
|
||||
|| state.last_date.is_none() && (!state.streaks.is_empty() || !state.limit_ups.is_empty() || !state.industry_history.is_empty()) {
|
||||
return Err("market_event_history_budget_or_state_invalid".into());
|
||||
}
|
||||
let mut output = Vec::new();
|
||||
for day in request.days {
|
||||
if state.last_date.is_some_and(|d| d >= day.date)
|
||||
|| day.universe.is_empty() || day.universe.len() > 20_000
|
||||
|| day.universe.iter().collect::<BTreeSet<_>>().len() != day.universe.len()
|
||||
|| day.rows.len() != day.universe.len()
|
||||
|| day.rows.iter().map(|r| &r.symbol).collect::<BTreeSet<_>>() != day.universe.iter().collect::<BTreeSet<_>>() {
|
||||
return Err(format!("market_event_incomplete_cross_section: {}", day.date));
|
||||
}
|
||||
let mut returns = BTreeMap::new();
|
||||
let mut groups: BTreeMap<String, Vec<f64>> = BTreeMap::new();
|
||||
let mut members = BTreeMap::new();
|
||||
let mut streaks = BTreeMap::new();
|
||||
let mut ups = BTreeSet::new();
|
||||
let mut downs = 0; let mut touched = 0; let mut broken = 0; let mut paused = 0; let mut unlimited = 0;
|
||||
for row in &day.rows {
|
||||
let industry = row.industry.clone().filter(|s| !s.trim().is_empty());
|
||||
members.insert(row.symbol.clone(), industry.clone());
|
||||
match row.paused {
|
||||
Some(true) => {
|
||||
paused += 1;
|
||||
streaks.insert(row.symbol.clone(), state.streaks.get(&row.symbol).copied().flatten());
|
||||
continue;
|
||||
},
|
||||
Some(false) => {},
|
||||
None => return Err(format!("market_event_pause_state_missing: {} {}", day.date, row.symbol)),
|
||||
}
|
||||
let c = positive(row.close, &row.symbol, "close")?;
|
||||
let h = positive(row.high, &row.symbol, "high")?;
|
||||
let p = positive(row.previous_close, &row.symbol, "previous_close")?;
|
||||
if h + 1e-8 < c { return Err(format!("market_event_high_below_close: {}", row.symbol)); }
|
||||
let change = c / p - 1.0;
|
||||
returns.insert(row.symbol.clone(), change);
|
||||
if let Some(industry) = industry { groups.entry(industry).or_default().push(change); }
|
||||
let is_up = match row.no_limit {
|
||||
Some(true) => { unlimited += 1; false },
|
||||
Some(false) => {
|
||||
let upper = positive(row.upper_limit, &row.symbol, "upper_limit")?;
|
||||
let lower = positive(row.lower_limit, &row.symbol, "lower_limit")?;
|
||||
if lower >= upper || c > upper + 1e-8 || c < lower - 1e-8 {
|
||||
return Err(format!("market_event_limit_bounds_invalid: {} {}", day.date, row.symbol));
|
||||
}
|
||||
let at_up = (c - upper).abs() <= 1e-8;
|
||||
if (c - lower).abs() <= 1e-8 { downs += 1; }
|
||||
if h >= upper - 1e-8 { touched += 1; if !at_up { broken += 1; } }
|
||||
at_up
|
||||
},
|
||||
None => return Err(format!("market_event_limit_policy_missing: {}", row.symbol)),
|
||||
};
|
||||
if is_up {
|
||||
ups.insert(row.symbol.clone());
|
||||
// The first observed limit-up may already be a continuing streak.
|
||||
streaks.insert(row.symbol.clone(), state.streaks.get(&row.symbol).copied().flatten().map(|v| v + 1));
|
||||
} else { streaks.insert(row.symbol.clone(), Some(0)); }
|
||||
}
|
||||
let active = returns.len();
|
||||
if active == 0 { return Err(format!("market_event_no_active_market: {}", day.date)); }
|
||||
let previous_ups = state.limit_ups.iter().cloned().collect::<Vec<_>>();
|
||||
let profit_missing = previous_ups.iter().filter(|s| !returns.contains_key(*s)).cloned().collect::<Vec<_>>();
|
||||
let profit = if previous_ups.is_empty() || !profit_missing.is_empty() { None }
|
||||
else { Some(average(previous_ups.iter().map(|s| returns[s]), previous_ups.len())) };
|
||||
let board = if ups.iter().any(|s| streaks[s].is_none()) { None }
|
||||
else { Some(ups.iter().map(|s| streaks[s].unwrap()).max().unwrap_or(0) as f64) };
|
||||
let common = BTreeMap::from([
|
||||
("market_breadth".into(), Some(returns.values().filter(|v| **v > 0.0).count() as f64 / active as f64)),
|
||||
("market_return".into(), Some(average(returns.values().copied(), active))),
|
||||
("market_limit_up_count".into(), Some(ups.len() as f64)),
|
||||
("market_limit_down_count".into(), Some(downs as f64)),
|
||||
("market_limit_up_rate".into(), (touched > 0).then(|| ups.len() as f64 / touched as f64)),
|
||||
("market_broken_limit_rate".into(), (touched > 0).then(|| broken as f64 / touched as f64)),
|
||||
("market_high_board".into(), board),
|
||||
("market_profit_effect".into(), profit),
|
||||
]);
|
||||
let mut industries = BTreeMap::new();
|
||||
// A disappeared group breaks its continuous history; no stale NAV is carried forward.
|
||||
state.industry_history.retain(|key, _| groups.contains_key(key));
|
||||
for (industry, values) in groups {
|
||||
let history = state.industry_history.entry(industry.clone()).or_default();
|
||||
let nav = history.last().copied().unwrap_or(1.0) * (1.0 + average(values.iter().copied(), values.len()));
|
||||
history.push(nav);
|
||||
if history.len() > 21 { history.remove(0); }
|
||||
let momentum = (history.len() == 21).then(|| nav / history[0] - 1.0);
|
||||
industries.insert(industry, BTreeMap::from([
|
||||
("industry_close".into(), Some(nav)), ("industry_return_20".into(), momentum),
|
||||
("industry_breadth".into(), Some(values.iter().filter(|v| **v > 0.0).count() as f64 / values.len() as f64)),
|
||||
]));
|
||||
}
|
||||
let universe = industries.keys().cloned().collect::<Vec<_>>();
|
||||
let known = industries.values().all(|g| g["industry_return_20"].is_some());
|
||||
let ranks = if known && !universe.is_empty() {
|
||||
crate::factor_cross_section::evaluate("RANK", &universe, &industries.iter().map(|(s,g)|
|
||||
crate::factor_cross_section::Observation {symbol:s.clone(), value:g["industry_return_20"].unwrap(),industry:None,market_cap:None}).collect::<Vec<_>>(),0.0)?
|
||||
.into_iter().map(|r|(r.symbol,r.value)).collect::<BTreeMap<_,_>>()
|
||||
} else { BTreeMap::new() };
|
||||
for (name, fields) in &mut industries {
|
||||
fields.insert("industry_rank".into(), ranks.get(name).copied());
|
||||
fields.insert("industry_size".into(), Some(universe.len() as f64));
|
||||
}
|
||||
let industry_missing=members.iter().filter(|(_,group)|group.is_none()).map(|(s,_)|s.clone()).collect::<Vec<_>>();
|
||||
if !industry_missing.is_empty() {
|
||||
// An unclassified member may belong to any group; never silently shrink a group.
|
||||
state.industry_history.clear();
|
||||
for fields in industries.values_mut() { for value in fields.values_mut() { *value=None; } }
|
||||
}
|
||||
output.push(OutputDay { date:day.date, common, industries, members, securities:day.rows.len(), active, paused,
|
||||
no_limit:unlimited, profit_effect_members:previous_ups, profit_effect_missing:profit_missing, industry_missing });
|
||||
state.last_date = Some(day.date); state.streaks = streaks; state.limit_ups = ups;
|
||||
}
|
||||
Ok(Output {contract:CONTRACT, days:output, state})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn day(n: u32, up: bool) -> Day {
|
||||
Day {date:NaiveDate::from_ymd_opt(2026,9,n).unwrap(), universe:vec!["A".into(),"B".into()], rows:vec![
|
||||
Observation{symbol:"A".into(),industry:Some("I".into()),close:Some(if up {11.0}else{10.0}),high:Some(11.0),previous_close:Some(10.0),upper_limit:Some(11.0),lower_limit:Some(9.0),no_limit:Some(false),paused:Some(false)},
|
||||
Observation{symbol:"B".into(),industry:Some("J".into()),close:Some(9.0),high:Some(10.0),previous_close:Some(10.0),upper_limit:Some(11.0),lower_limit:Some(9.0),no_limit:Some(false),paused:Some(false)}]}
|
||||
}
|
||||
#[test]
|
||||
fn formulas_use_real_limits_and_full_denominators() {
|
||||
let r=aggregate(Request{days:vec![day(1,false),day(2,true),day(3,true)],previous:State::default()}).unwrap();
|
||||
let d=&r.days[1];
|
||||
assert_eq!(d.common["market_breadth"],Some(0.5));
|
||||
assert_eq!(d.common["market_limit_down_count"],Some(1.0));
|
||||
assert_eq!(d.common["market_limit_up_rate"],Some(1.0));
|
||||
assert_eq!(r.days[0].common["market_limit_up_rate"],Some(0.0));
|
||||
assert_eq!(r.days[0].common["market_broken_limit_rate"],Some(1.0));
|
||||
assert_eq!(r.days[2].common["market_high_board"],Some(2.0));
|
||||
assert!((r.days[2].common["market_profit_effect"].unwrap()-0.1).abs()<1e-12);
|
||||
assert_eq!(r.days[0].common["market_profit_effect"],None);
|
||||
}
|
||||
#[test]
|
||||
fn missing_duplicate_and_unproven_limit_states_fail() {
|
||||
let mut d=day(1,true);d.rows.pop();assert!(aggregate(Request{days:vec![d],previous:State::default()}).is_err());
|
||||
let mut d=day(1,true);d.rows[0].upper_limit=None;assert!(aggregate(Request{days:vec![d],previous:State::default()}).is_err());
|
||||
let mut d=day(1,true);d.rows[0].no_limit=Some(true);d.rows[0].upper_limit=None;
|
||||
assert_eq!(aggregate(Request{days:vec![d],previous:State::default()}).unwrap().days[0].no_limit,1);
|
||||
}
|
||||
#[test]
|
||||
fn chunking_and_future_append_preserve_history() {
|
||||
let first=aggregate(Request{days:vec![day(1,false),day(2,true)],previous:State::default()}).unwrap();
|
||||
let next=aggregate(Request{days:vec![day(3,true)],previous:first.state}).unwrap();
|
||||
let full=aggregate(Request{days:vec![day(1,false),day(2,true),day(3,true)],previous:State::default()}).unwrap();
|
||||
assert_eq!(serde_json::to_value(&first.days).unwrap(),serde_json::to_value(&full.days[..2]).unwrap());
|
||||
assert_eq!(serde_json::to_value(&next.days).unwrap(),serde_json::to_value(&full.days[2..]).unwrap());
|
||||
let unknown=aggregate(Request{days:vec![day(1,true)],previous:State::default()}).unwrap();
|
||||
assert_eq!(unknown.days[0].common["market_high_board"],None);
|
||||
}
|
||||
#[test]
|
||||
fn missing_industry_does_not_invent_groups_or_disable_independent_market_facts() {
|
||||
let mut missing=day(2,true);missing.rows[0].industry=None;
|
||||
let r=aggregate(Request{days:vec![day(1,false),missing,day(3,true)],previous:State::default()}).unwrap();
|
||||
assert_eq!(r.days[1].common["market_breadth"],Some(0.5));
|
||||
assert_eq!(r.days[1].industry_missing,vec!["A"]);
|
||||
assert!(r.days[1].industries.values().flat_map(|g|g.values()).all(Option::is_none));
|
||||
assert_eq!(r.days[2].industries["I"]["industry_return_20"],None);
|
||||
}
|
||||
}
|
||||
@@ -108,13 +108,7 @@ pub fn compute_backtest_metrics(
|
||||
};
|
||||
|
||||
let trade_days = equity_curve.len();
|
||||
let benchmark_start = if first_point.benchmark_prev_close.is_finite()
|
||||
&& first_point.benchmark_prev_close > f64::EPSILON
|
||||
{
|
||||
first_point.benchmark_prev_close
|
||||
} else {
|
||||
first_point.benchmark_close
|
||||
};
|
||||
let benchmark_start = first_point.benchmark_reference_close();
|
||||
let explicit_unit_nav = equity_curve.iter().any(|point| {
|
||||
point.external_cash_flow.abs() > f64::EPSILON
|
||||
|| (point.unit_nav.is_finite()
|
||||
@@ -780,6 +774,7 @@ mod tests {
|
||||
benchmark_prev_close: f64,
|
||||
) -> DailyEquityPoint {
|
||||
DailyEquityPoint {
|
||||
signal_baseline: false,
|
||||
date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(),
|
||||
cash: total_equity,
|
||||
market_value: 0.0,
|
||||
@@ -804,11 +799,21 @@ mod tests {
|
||||
assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_baseline_uses_same_close_for_strategy_and_benchmark() {
|
||||
let mut baseline=equity_point("2026-09-04",100.0,4548.0499,4552.5784);
|
||||
baseline.signal_baseline=true;
|
||||
let curve=vec![baseline,equity_point("2026-09-08",104.0,4558.7371,4575.0245)];
|
||||
let metrics=compute_backtest_metrics(&curve,&[],&[],&[],100.0,None).unwrap();
|
||||
assert!((metrics.benchmark_cumulative_return-(4558.7371/4548.0499-1.0)).abs()<1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_cash_flow_is_excluded_from_return_and_reported_separately() {
|
||||
let curve = vec![
|
||||
equity_point("2025-01-02", 100.0, 100.0, 100.0),
|
||||
DailyEquityPoint {
|
||||
signal_baseline: false,
|
||||
date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
|
||||
cash: 220.0,
|
||||
market_value: 0.0,
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
//! Explicit reference identities and frozen rank universes shared by all daily runtimes.
|
||||
use crate::{
|
||||
daily_patterns::{dataset_series, evaluate_with_context, PatternSpec, ResearchContext},
|
||||
factor_events::{field_dependencies, Expr},
|
||||
DataSet,
|
||||
};
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub const CONTRACT: &str = "fidc_pattern_execution_context_v1";
|
||||
pub const CONTEXT_FIELDS: &[&str] = &[
|
||||
"index_open",
|
||||
"index_high",
|
||||
"index_low",
|
||||
"index_close",
|
||||
"scope_rank",
|
||||
"scope_percentile",
|
||||
"scope_size",
|
||||
];
|
||||
const STOCK_FIELDS: &[&str] = &[
|
||||
"open",
|
||||
"high",
|
||||
"low",
|
||||
"close",
|
||||
"volume",
|
||||
"raw_open",
|
||||
"raw_high",
|
||||
"raw_low",
|
||||
"raw_close",
|
||||
"prev_close",
|
||||
"amount",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExecutionContext {
|
||||
pub contract: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub benchmark: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rank_expression: Option<Expr>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub rank_universe: Vec<String>,
|
||||
}
|
||||
|
||||
fn valid_symbol(s: &str) -> bool {
|
||||
let Some((code, market)) = s.split_once('.') else {
|
||||
return false;
|
||||
};
|
||||
code.len() == 6
|
||||
&& code.bytes().all(|c| c.is_ascii_digit())
|
||||
&& matches!(market, "SH" | "SZ" | "BJ" | "CSI")
|
||||
}
|
||||
|
||||
impl ExecutionContext {
|
||||
pub fn fields(&self, expression: &Expr) -> BTreeSet<String> {
|
||||
let mut fields = field_dependencies(expression);
|
||||
if let Some(rank) = &self.rank_expression {
|
||||
fields.extend(field_dependencies(rank));
|
||||
}
|
||||
fields
|
||||
}
|
||||
pub fn validate(&self, expression: &Expr) -> Result<(), String> {
|
||||
if self.contract != CONTRACT {
|
||||
return Err("pattern_context_contract_invalid".into());
|
||||
}
|
||||
let needed = field_dependencies(expression);
|
||||
let ranked = needed.iter().any(|f| f.starts_with("scope_"));
|
||||
if ranked != self.rank_expression.is_some() || !ranked && !self.rank_universe.is_empty() {
|
||||
return Err("pattern_rank_expression_and_universe_required".into());
|
||||
}
|
||||
if ranked
|
||||
&& (self.rank_universe.len() < 2
|
||||
|| self.rank_universe.len() > 20_000
|
||||
|| self.rank_universe.iter().any(|s| !valid_symbol(s))
|
||||
|| self.rank_universe.iter().collect::<BTreeSet<_>>().len()
|
||||
!= self.rank_universe.len())
|
||||
{
|
||||
return Err("pattern_rank_universe_invalid".into());
|
||||
}
|
||||
if let Some(rank) = &self.rank_expression {
|
||||
let fields = field_dependencies(rank);
|
||||
if fields
|
||||
.iter()
|
||||
.any(|f| !STOCK_FIELDS.contains(&f.as_str()) && !f.starts_with("index_"))
|
||||
{
|
||||
return Err("pattern_rank_expression_invalid_or_recursive".into());
|
||||
}
|
||||
}
|
||||
let fields = self.fields(expression);
|
||||
if fields
|
||||
.iter()
|
||||
.any(|f| !STOCK_FIELDS.contains(&f.as_str()) && !CONTEXT_FIELDS.contains(&f.as_str()))
|
||||
{
|
||||
return Err("pattern_context_unmapped_field".into());
|
||||
}
|
||||
let index = fields.iter().any(|f| f.starts_with("index_"));
|
||||
if index != self.benchmark.is_some()
|
||||
|| self
|
||||
.benchmark
|
||||
.as_ref()
|
||||
.is_some_and(|s| !valid_symbol(s) || s.ends_with(".BJ"))
|
||||
{
|
||||
return Err("pattern_reference_index_required".into());
|
||||
}
|
||||
if !index && !ranked {
|
||||
return Err("pattern_unused_context".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_dataset_context(
|
||||
spec: &PatternSpec,
|
||||
data: &DataSet,
|
||||
date: NaiveDate,
|
||||
) -> Result<ResearchContext, String> {
|
||||
let Some(config) = &spec.execution_context else {
|
||||
return Ok(ResearchContext::default());
|
||||
};
|
||||
config.validate(
|
||||
spec.expression
|
||||
.as_ref()
|
||||
.ok_or("pattern_context_requires_expression")?,
|
||||
)?;
|
||||
let days = data.calendar().trailing_days(date, spec.history_len());
|
||||
if days.len() != spec.history_len() || days.last() != Some(&date) {
|
||||
return Err("pattern_context_calendar_incomplete".into());
|
||||
}
|
||||
let needed = config.fields(spec.expression.as_ref().unwrap());
|
||||
let mut context = ResearchContext::default();
|
||||
if let Some(symbol) = &config.benchmark {
|
||||
for name in needed.iter().filter(|f| f.starts_with("index_")) {
|
||||
let values = days
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let value = if let Some(b) = data.market(*d, symbol) {
|
||||
match name.as_str() {
|
||||
"index_open" => Some(b.open),
|
||||
"index_high" => Some(b.high),
|
||||
"index_low" => Some(b.low),
|
||||
"index_close" => Some(b.close),
|
||||
_ => None,
|
||||
}
|
||||
} else if let Some(b) = data.benchmark(*d).filter(|b| &b.benchmark == symbol) {
|
||||
match name.as_str() {
|
||||
"index_open" => Some(b.open),
|
||||
"index_close" => Some(b.close),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
value
|
||||
.filter(|v| v.is_finite() && *v > 0.0)
|
||||
.map(Some)
|
||||
.ok_or_else(|| format!("pattern_reference_missing: {symbol} {d} {name}"))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
context.common.insert(name.clone(), values);
|
||||
}
|
||||
}
|
||||
if let Some(expression) = &config.rank_expression {
|
||||
let mut input = spec.clone();
|
||||
input.execution_context = None;
|
||||
input.expression = Some(expression.clone());
|
||||
let mut values = BTreeMap::new();
|
||||
for symbol in &config.rank_universe {
|
||||
let row = evaluate_with_context(
|
||||
&input,
|
||||
&days,
|
||||
&dataset_series(data, &days, symbol),
|
||||
&context.common,
|
||||
true,
|
||||
)?;
|
||||
if let Some(reason) = row.exclusion {
|
||||
return Err(format!("pattern_rank_member_incomplete: {symbol} {reason}"));
|
||||
}
|
||||
values.insert(
|
||||
symbol.clone(),
|
||||
serde_json::from_value::<Vec<Option<f64>>>(
|
||||
row.values["expression"]["values"].clone(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?,
|
||||
);
|
||||
}
|
||||
let ranks =
|
||||
crate::factor_cross_section::rank_history(&days, &config.rank_universe, &values)?;
|
||||
for symbol in &config.rank_universe {
|
||||
let decode = |value: &Value| {
|
||||
serde_json::from_value::<Vec<Option<f64>>>(value.clone()).map_err(|e| e.to_string())
|
||||
};
|
||||
context.by_symbol.insert(
|
||||
symbol.clone(),
|
||||
BTreeMap::from([
|
||||
("scope_rank".into(), decode(&ranks["rank"][symbol])?),
|
||||
(
|
||||
"scope_percentile".into(),
|
||||
decode(&ranks["percentile"][symbol])?,
|
||||
),
|
||||
(
|
||||
"scope_size".into(),
|
||||
vec![Some(config.rank_universe.len() as f64); days.len()],
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(context)
|
||||
}
|
||||
|
||||
pub fn specs_in_value(value: &Value) -> Result<Vec<PatternSpec>, String> {
|
||||
let mut specs = Vec::new();
|
||||
match value {
|
||||
Value::String(text) => specs.extend(crate::daily_patterns::expression_specs(text)?),
|
||||
Value::Array(items) => {
|
||||
for v in items {
|
||||
specs.extend(specs_in_value(v)?);
|
||||
}
|
||||
}
|
||||
Value::Object(items) => {
|
||||
for v in items.values() {
|
||||
specs.extend(specs_in_value(v)?);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(specs)
|
||||
}
|
||||
|
||||
pub fn required_symbols(value: &Value) -> Result<(BTreeSet<String>, BTreeSet<String>), String> {
|
||||
let (mut indices, mut stocks) = (BTreeSet::new(), BTreeSet::new());
|
||||
for spec in specs_in_value(value)? {
|
||||
if let Some(context) = spec.execution_context {
|
||||
if let Some(index) = context.benchmark {
|
||||
indices.insert(index);
|
||||
}
|
||||
stocks.extend(context.rank_universe);
|
||||
}
|
||||
}
|
||||
Ok((indices, stocks))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{BenchmarkSnapshot, DailyFactorSnapshot, DailyMarketSnapshot, Instrument};
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn normalized_rule_does_not_turn_an_omitted_window_into_explicit_null() {
|
||||
let expression:Expr=serde_json::from_value(json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":1}]})).unwrap();
|
||||
assert!(serde_json::to_value(expression).unwrap().get("window").is_none());
|
||||
}
|
||||
fn data(future: bool, reference: bool) -> DataSet {
|
||||
let mut days = vec![
|
||||
NaiveDate::from_ymd_opt(2026, 9, 4).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2026, 9, 7).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2026, 9, 8).unwrap(),
|
||||
];
|
||||
if future {
|
||||
days.push(NaiveDate::from_ymd_opt(2026, 9, 9).unwrap());
|
||||
}
|
||||
let symbols = vec!["000001.SZ", "000002.SZ", "000003.SZ"];
|
||||
let mut instruments = symbols
|
||||
.iter()
|
||||
.map(|s| Instrument {
|
||||
symbol: s.to_string(),
|
||||
name: s.to_string(),
|
||||
board: "SZ_MAIN".into(),
|
||||
round_lot: 100,
|
||||
listed_at: None,
|
||||
delisted_at: None,
|
||||
status: "active".into(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if reference {
|
||||
instruments.push(Instrument {
|
||||
symbol: "399006.SZ".into(),
|
||||
name: "reference".into(),
|
||||
board: "INDEX".into(),
|
||||
round_lot: 1,
|
||||
listed_at: None,
|
||||
delisted_at: None,
|
||||
status: "active".into(),
|
||||
});
|
||||
}
|
||||
let mut market = vec![];
|
||||
let mut factors = vec![];
|
||||
let mut benchmark = vec![];
|
||||
for (i, d) in days.iter().enumerate() {
|
||||
for (n, s) in symbols.iter().enumerate() {
|
||||
let c = [
|
||||
[10., 12., 11., 1000.],
|
||||
[10., 11., 12., 1.],
|
||||
[10., 10., 13., 1.],
|
||||
][n][i];
|
||||
market.push(DailyMarketSnapshot {
|
||||
date: *d,
|
||||
symbol: s.to_string(),
|
||||
timestamp: None,
|
||||
day_open: c,
|
||||
open: c,
|
||||
high: c,
|
||||
low: c,
|
||||
close: c,
|
||||
last_price: c,
|
||||
bid1: c,
|
||||
ask1: c,
|
||||
prev_close: 10.,
|
||||
volume: 100000,
|
||||
minute_volume: 0,
|
||||
bid1_volume: 10000,
|
||||
ask1_volume: 10000,
|
||||
trading_phase: None,
|
||||
paused: false,
|
||||
upper_limit: c * 2.,
|
||||
lower_limit: c / 2.,
|
||||
price_tick: 0.01,
|
||||
});
|
||||
factors.push(DailyFactorSnapshot {
|
||||
date: *d,
|
||||
symbol: s.to_string(),
|
||||
market_cap_bn: 1.,
|
||||
free_float_cap_bn: 1.,
|
||||
pe_ttm: 10.,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: Some(1.),
|
||||
extra_factors: Default::default(),
|
||||
});
|
||||
}
|
||||
if reference {
|
||||
let mut row = market.last().unwrap().clone();
|
||||
row.symbol = "399006.SZ".into();
|
||||
row.open = 30.;
|
||||
row.high = 30.;
|
||||
row.low = 30.;
|
||||
row.close = 30.;
|
||||
market.push(row);
|
||||
}
|
||||
benchmark.push(BenchmarkSnapshot {
|
||||
date: *d,
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 4000.,
|
||||
close: 4000.,
|
||||
prev_close: 4000.,
|
||||
volume: 1000,
|
||||
});
|
||||
}
|
||||
DataSet::from_components(instruments, market, factors, vec![], benchmark).unwrap()
|
||||
}
|
||||
fn spec(rank: bool) -> PatternSpec {
|
||||
let expression = if rank {
|
||||
json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"scope_rank"},{"kind":"number","value":2}]})
|
||||
} else {
|
||||
json!({"kind":"operator","name":"LT","args":[{"kind":"field","name":"index_close"},{"kind":"number","value":100}]})
|
||||
};
|
||||
let context = if rank {
|
||||
json!({"contract":CONTRACT,"rank_expression":{"kind":"operator","name":"PCT_CHANGE","window":1,"args":[{"kind":"field","name":"close"}]},"rank_universe":["000001.SZ","000002.SZ","000003.SZ"]})
|
||||
} else {
|
||||
json!({"contract":CONTRACT,"benchmark":"399006.SZ"})
|
||||
};
|
||||
serde_json::from_value::<PatternSpec>(json!({"template":"expression","parameters":{"history_window":3},"expression":expression,"execution_context":context})).unwrap().validate().unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn dataset_rank_is_full_scope_causal_and_equal_to_pure_cross_section() {
|
||||
let spec = spec(true);
|
||||
let date = NaiveDate::from_ymd_opt(2026, 9, 8).unwrap();
|
||||
let original = build_dataset_context(&spec, &data(false, true), date).unwrap();
|
||||
let future = build_dataset_context(&spec, &data(true, true), date).unwrap();
|
||||
assert_eq!(original.by_symbol, future.by_symbol);
|
||||
assert_eq!(original.by_symbol["000001.SZ"]["scope_rank"][2], Some(3.));
|
||||
assert_eq!(original.by_symbol["000002.SZ"]["scope_rank"][2], Some(2.));
|
||||
assert_eq!(original.by_symbol["000003.SZ"]["scope_rank"][2], Some(1.));
|
||||
assert!(
|
||||
crate::daily_patterns::evaluate_dataset(&spec, &data(false, true), date, "000001.SZ")
|
||||
.unwrap()
|
||||
.matched
|
||||
);
|
||||
let mut incomplete = data(false, true).snapshot_components();
|
||||
incomplete.market.retain(|r| r.symbol != "000003.SZ");
|
||||
let broken = DataSet::from_components(
|
||||
incomplete.instruments,
|
||||
incomplete.market,
|
||||
incomplete.factors,
|
||||
incomplete.candidates,
|
||||
incomplete.benchmarks,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(build_dataset_context(&spec, &broken, date).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn reference_index_never_defaults_to_performance_benchmark() {
|
||||
let spec = spec(false);
|
||||
let date = NaiveDate::from_ymd_opt(2026, 9, 8).unwrap();
|
||||
assert!(
|
||||
crate::daily_patterns::evaluate_dataset(&spec, &data(false, true), date, "000001.SZ")
|
||||
.unwrap()
|
||||
.matched
|
||||
);
|
||||
assert!(build_dataset_context(&spec, &data(false, false), date)
|
||||
.unwrap_err()
|
||||
.contains("399006.SZ"));
|
||||
}
|
||||
#[test]
|
||||
fn runtime_contract_rejects_missing_range_and_recursive_ranks() {
|
||||
let mut missing = spec(true);
|
||||
missing
|
||||
.execution_context
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.rank_universe
|
||||
.clear();
|
||||
assert!(missing.validate().is_err());
|
||||
let mut recursive = spec(true);
|
||||
recursive
|
||||
.execution_context
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.rank_expression = Some(Expr::Field {
|
||||
name: "scope_rank".into(),
|
||||
});
|
||||
assert!(recursive.validate().is_err());
|
||||
}
|
||||
}
|
||||
@@ -525,6 +525,7 @@ pub enum PlatformAccountActionKind {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PlatformTradeAction {
|
||||
ConsumeSignal,
|
||||
Order {
|
||||
kind: PlatformExplicitOrderKind,
|
||||
symbol: String,
|
||||
@@ -607,6 +608,7 @@ pub struct PlatformPositionTargetRule {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlatformExprStrategyConfig {
|
||||
pub signal_book: Option<Arc<crate::signal_contract::ValidatedSignalBook>>,
|
||||
pub strategy_name: String,
|
||||
pub market: String,
|
||||
pub benchmark_symbol: String,
|
||||
@@ -672,6 +674,7 @@ pub struct PlatformExprStrategyConfig {
|
||||
pub completed_session_factor_fields: BTreeSet<String>,
|
||||
pub candidate_symbols_by_date: BTreeMap<NaiveDate, BTreeSet<String>>,
|
||||
pub intraday_execution_time: Option<NaiveTime>,
|
||||
pub session_event_times: Vec<NaiveTime>,
|
||||
pub explicit_action_times: Vec<NaiveTime>,
|
||||
pub delayed_limit_open_exit_enabled: bool,
|
||||
pub delayed_limit_open_exit_time: Option<NaiveTime>,
|
||||
@@ -688,6 +691,7 @@ pub struct PlatformExprStrategyConfig {
|
||||
impl PlatformExprStrategyConfig {
|
||||
pub fn generic() -> Self {
|
||||
Self {
|
||||
signal_book: None,
|
||||
strategy_name: "platform-expression".to_string(),
|
||||
market: "CN_A".to_string(),
|
||||
benchmark_symbol: String::new(),
|
||||
@@ -753,6 +757,7 @@ impl PlatformExprStrategyConfig {
|
||||
completed_session_factor_fields: BTreeSet::new(),
|
||||
candidate_symbols_by_date: BTreeMap::new(),
|
||||
intraday_execution_time: None,
|
||||
session_event_times: Vec::new(),
|
||||
explicit_action_times: Vec::new(),
|
||||
delayed_limit_open_exit_enabled: false,
|
||||
delayed_limit_open_exit_time: None,
|
||||
@@ -1287,6 +1292,7 @@ struct RuntimeHelperBinding {
|
||||
|
||||
#[derive(Clone)]
|
||||
enum CompiledRuntimeHelperArgs {
|
||||
DailyPattern { spec: crate::daily_patterns::PatternSpec, identity: String },
|
||||
RollingMean {
|
||||
field: String,
|
||||
lookback: usize,
|
||||
@@ -1344,6 +1350,11 @@ enum RuntimeHelperResolution {
|
||||
}
|
||||
|
||||
pub struct PlatformExprStrategy {
|
||||
pattern_results_date: RefCell<Option<NaiveDate>>,
|
||||
pattern_results: RefCell<BTreeMap<(NaiveDate, String, String), crate::daily_patterns::PatternResult>>,
|
||||
pattern_contexts: RefCell<BTreeMap<String,crate::daily_patterns::ResearchContext>>,
|
||||
pattern_specs: RefCell<BTreeMap<String,String>>,
|
||||
pattern_frame_at:RefCell<Option<NaiveDateTime>>,
|
||||
config: PlatformExprStrategyConfig,
|
||||
engine: Engine,
|
||||
rebalance_day_counter: usize,
|
||||
@@ -1579,13 +1590,7 @@ impl PlatformExprStrategy {
|
||||
.filter(|position| position.quantity > 0)
|
||||
.filter_map(|position| {
|
||||
let instrument = ctx.data.instrument(&position.symbol)?;
|
||||
let unresolved = instrument.is_delisted_on_or_before(ctx.execution_date)
|
||||
|| (instrument.status.eq_ignore_ascii_case("delisted")
|
||||
&& instrument.delisted_at.is_none()
|
||||
&& ctx
|
||||
.data
|
||||
.market(ctx.execution_date, &position.symbol)
|
||||
.is_none());
|
||||
let unresolved = instrument.is_delisted_on_or_before(ctx.execution_date);
|
||||
unresolved.then(|| position.symbol.clone())
|
||||
})
|
||||
.collect()
|
||||
@@ -1792,6 +1797,11 @@ impl PlatformExprStrategy {
|
||||
stock_extra_factor_identifiers,
|
||||
stock_extra_factor_map_required,
|
||||
stock_text_factors_required,
|
||||
pattern_results: RefCell::new(BTreeMap::new()),
|
||||
pattern_contexts: RefCell::new(BTreeMap::new()),
|
||||
pattern_specs: RefCell::new(BTreeMap::new()),
|
||||
pattern_frame_at:RefCell::new(None),
|
||||
pattern_results_date: RefCell::new(None),
|
||||
stock_state_cache_date: RefCell::new(None),
|
||||
stock_state_cache_calendar_index: RefCell::new(None),
|
||||
stock_state_cache: RefCell::new(AHashMap::new()),
|
||||
@@ -1906,6 +1916,7 @@ impl PlatformExprStrategy {
|
||||
}
|
||||
for (index, action) in self.config.explicit_actions.iter().enumerate() {
|
||||
match action {
|
||||
PlatformTradeAction::ConsumeSignal => {}
|
||||
PlatformTradeAction::Order {
|
||||
amount_expr,
|
||||
limit_price_expr,
|
||||
@@ -2355,7 +2366,9 @@ impl PlatformExprStrategy {
|
||||
fn is_runtime_helper(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"factor"
|
||||
"pattern_signal"
|
||||
| "pattern_score"
|
||||
| "factor"
|
||||
| "day_factor"
|
||||
| "rolling_mean"
|
||||
| "rolling_mean_current"
|
||||
@@ -3844,16 +3857,9 @@ impl PlatformExprStrategy {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !defer_execution_risk
|
||||
&& self
|
||||
.buy_rejection_reason(
|
||||
ctx,
|
||||
execution_date,
|
||||
symbol,
|
||||
self.stock_state(ctx, execution_date, symbol)?.as_ref(),
|
||||
)?
|
||||
.is_some()
|
||||
{
|
||||
if !defer_execution_risk && self.buy_rejection_reason(
|
||||
ctx, execution_date, symbol, self.stock_state(ctx, execution_date, symbol)?.as_ref(),
|
||||
)?.is_some() {
|
||||
continue;
|
||||
}
|
||||
let decision_stock = self.stock_state_with_factor_date(
|
||||
@@ -4293,13 +4299,10 @@ impl PlatformExprStrategy {
|
||||
.factor_snapshot_rows_on(date)
|
||||
.iter()
|
||||
.flat_map(|row| {
|
||||
row.extra_factors
|
||||
.keys()
|
||||
.map(|key| key.to_string())
|
||||
.chain(
|
||||
row.adjustment_factor_backward1
|
||||
.map(|_| BACKWARD_ADJUSTMENT_FACTOR_FIELD.to_string()),
|
||||
)
|
||||
row.extra_factors.keys().map(|key| key.to_string()).chain(
|
||||
row.adjustment_factor_backward1
|
||||
.map(|_| BACKWARD_ADJUSTMENT_FACTOR_FIELD.to_string()),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
@@ -5701,6 +5704,55 @@ impl PlatformExprStrategy {
|
||||
args: &CompiledRuntimeHelperArgs,
|
||||
) -> Result<RuntimeHelperResolution, BacktestError> {
|
||||
match args {
|
||||
CompiledRuntimeHelperArgs::DailyPattern { spec, identity } => {
|
||||
if *self.pattern_results_date.borrow()!=Some(ctx.execution_date) {
|
||||
self.pattern_results.borrow_mut().clear();self.pattern_contexts.borrow_mut().clear();self.pattern_specs.borrow_mut().clear();
|
||||
*self.pattern_results_date.borrow_mut()=Some(ctx.execution_date);
|
||||
}
|
||||
if *self.pattern_frame_at.borrow()!=ctx.active_datetime {
|
||||
self.pattern_results.borrow_mut().clear();*self.pattern_frame_at.borrow_mut()=ctx.active_datetime;
|
||||
}
|
||||
if spec.template=="session_event" {
|
||||
if self.config.matching_type!=MatchingType::MinuteLast {return Err(BacktestError::Execution("session_event_requires_minute_last".into()));}
|
||||
let active=ctx.active_datetime.ok_or_else(||BacktestError::Execution("session_event_requires_explicit_clock".into()))?;
|
||||
let clock=active.time();
|
||||
if !((NaiveTime::from_hms_opt(9,31,0).unwrap()<=clock&&clock<=NaiveTime::from_hms_opt(11,30,0).unwrap())||(NaiveTime::from_hms_opt(13,1,0).unwrap()<=clock&&clock<=NaiveTime::from_hms_opt(15,0,0).unwrap())) {
|
||||
return Ok(if helper=="pattern_signal"{RuntimeHelperResolution::Boolean(false)}else{RuntimeHelperResolution::Number(0.)});
|
||||
}
|
||||
let stock=stock.ok_or_else(||BacktestError::Execution("session_event_requires_stock".into()))?;
|
||||
let key=(active.date(),stock.symbol.to_string(),identity.clone());
|
||||
if !self.pattern_results.borrow().contains_key(&key) {
|
||||
let result=crate::session_events::evaluate(spec,&stock.symbol,ctx.data.completed_minute_bars_on(active.date(),&stock.symbol),active).map_err(BacktestError::Execution)?;
|
||||
self.pattern_results.borrow_mut().insert(key.clone(),result);
|
||||
self.pattern_specs.borrow_mut().insert(identity.clone(),serde_json::to_string(spec).unwrap());
|
||||
}
|
||||
let rows=self.pattern_results.borrow();let result=&rows[&key];
|
||||
return if helper=="pattern_signal" {Ok(RuntimeHelperResolution::Boolean(result.matched))}else{result.score.map(RuntimeHelperResolution::Number).ok_or_else(||BacktestError::Execution("session_score_unknown".into()))};
|
||||
}
|
||||
if !matches!(self.config.matching_type,MatchingType::NextBarOpen|MatchingType::MinuteLast) {
|
||||
return Err(BacktestError::Execution("daily_pattern_requires_next_bar_open: 完整日线形态只能在下一交易日执行".into()));
|
||||
}
|
||||
let date = if self.config.matching_type==MatchingType::MinuteLast {ctx.data.previous_trading_date(ctx.execution_date,1).ok_or_else(||BacktestError::Execution("daily_pattern_previous_completed_date_missing".into()))?} else {day.date.min(ctx.decision_date)};
|
||||
// Lagged replay retains the decision day's schedule label; execution is on a later session.
|
||||
if !ctx.is_lagged_execution() && ctx.active_datetime.is_some_and(|t| t.date() == date && t.time() < NaiveTime::from_hms_opt(16, 0, 0).unwrap()) {
|
||||
return Err(BacktestError::Execution(format!("daily_pattern_not_yet_visible: 不允许使用未完成的当日日线; decision_date={date}, execution_date={}, active_datetime={:?}",ctx.execution_date,ctx.active_datetime)));
|
||||
}
|
||||
let stock = stock.ok_or_else(|| BacktestError::Execution("pattern_signal requires stock context".into()))?;
|
||||
let key = (date, stock.symbol.to_string(), identity.clone());
|
||||
if !self.pattern_results.borrow().contains_key(&key) {
|
||||
if !self.pattern_contexts.borrow().contains_key(&key.2) {
|
||||
let context=crate::pattern_context::build_dataset_context(spec,ctx.data,date).map_err(BacktestError::Execution)?;
|
||||
self.pattern_contexts.borrow_mut().insert(key.2.clone(),context);
|
||||
self.pattern_specs.borrow_mut().insert(key.2.clone(),serde_json::to_string(spec).unwrap());
|
||||
}
|
||||
let result = crate::daily_patterns::evaluate_dataset_context(spec,ctx.data,date,&stock.symbol,&self.pattern_contexts.borrow()[&key.2]).map_err(BacktestError::Execution)?;
|
||||
self.pattern_results.borrow_mut().insert(key.clone(),result);
|
||||
}
|
||||
let results = self.pattern_results.borrow();
|
||||
let result = &results[&key];
|
||||
if helper == "pattern_signal" { Ok(RuntimeHelperResolution::Boolean(result.matched)) }
|
||||
else { result.score.map(RuntimeHelperResolution::Number).ok_or_else(|| BacktestError::Execution(format!("pattern_score_unavailable: {} {date}; 先通过形态筛选再排序",stock.symbol))) }
|
||||
}
|
||||
CompiledRuntimeHelperArgs::RollingMean {
|
||||
field,
|
||||
lookback,
|
||||
@@ -6137,7 +6189,7 @@ impl PlatformExprStrategy {
|
||||
if Self::is_reserved_scope_name(identifier.as_str())
|
||||
|| self.prelude_declared_identifiers.contains(identifier)
|
||||
|| (!self.stock_extra_factor_identifiers.contains(identifier)
|
||||
&& !item.extra_factors.contains_key(identifier)
|
||||
&& !item.extra_factors.contains_key(identifier.as_str())
|
||||
&& !day.available_factor_names.contains(identifier)
|
||||
&& !day.available_text_factor_names.contains(identifier))
|
||||
{
|
||||
@@ -6148,7 +6200,7 @@ impl PlatformExprStrategy {
|
||||
} else {
|
||||
let value = item
|
||||
.extra_factors
|
||||
.get(identifier)
|
||||
.get(identifier.as_str())
|
||||
.copied()
|
||||
.unwrap_or(f64::NAN);
|
||||
scope.push_dynamic(identifier.clone(), Dynamic::from(value));
|
||||
@@ -6952,6 +7004,14 @@ impl PlatformExprStrategy {
|
||||
))
|
||||
};
|
||||
match helper {
|
||||
"pattern_signal" | "pattern_score" if args.len() == 1 => {
|
||||
let text: String = serde_json::from_str(&args[0]).ok()?;
|
||||
let spec: crate::daily_patterns::PatternSpec = serde_json::from_str(&text).ok()?;
|
||||
let spec=spec.validate().ok()?;
|
||||
use sha2::Digest;
|
||||
let identity=format!("{:x}",sha2::Sha256::digest(serde_json::to_vec(&spec).ok()?));
|
||||
Some(CompiledRuntimeHelperArgs::DailyPattern { spec, identity })
|
||||
}
|
||||
"rolling_mean" | "sma" | "ma" => {
|
||||
let (field, lookback) = field_lookback()?;
|
||||
Some(CompiledRuntimeHelperArgs::RollingMean {
|
||||
@@ -7028,8 +7088,8 @@ impl PlatformExprStrategy {
|
||||
|
||||
fn numeric_vm_helper_type(helper: &str) -> Option<NumericVmValueType> {
|
||||
match helper {
|
||||
"has_dividend" | "has_split" | "is_margin_stock" => Some(NumericVmValueType::Boolean),
|
||||
"rolling_mean"
|
||||
"pattern_signal" | "has_dividend" | "has_split" | "is_margin_stock" => Some(NumericVmValueType::Boolean),
|
||||
"pattern_score" | "rolling_mean"
|
||||
| "sma"
|
||||
| "ma"
|
||||
| "rolling_mean_current"
|
||||
@@ -7171,6 +7231,7 @@ impl PlatformExprStrategy {
|
||||
return self.resolve_compiled_runtime_helper(ctx, day, stock, helper, &compiled_args);
|
||||
}
|
||||
match helper {
|
||||
"pattern_signal" | "pattern_score" => Err(BacktestError::Execution("daily_pattern_spec_invalid: 需要一个有效的模板 JSON 字符串参数".into())),
|
||||
"factor" => {
|
||||
let key = Self::normalize_runtime_factor_key(&Self::parse_string_or_identifier(
|
||||
args.first().map(String::as_str).unwrap_or_default(),
|
||||
@@ -9054,7 +9115,10 @@ impl PlatformExprStrategy {
|
||||
self.stock_state(ctx, date, symbol).map(Some)
|
||||
}
|
||||
|
||||
fn unscheduled_explicit_actions_are_due(&self, decision_date: NaiveDate) -> bool {
|
||||
fn unscheduled_explicit_actions_are_due(&self, decision_date: NaiveDate, execution_date: NaiveDate) -> bool {
|
||||
if let Some(book) = &self.config.signal_book {
|
||||
return book.is_due_on(execution_date);
|
||||
}
|
||||
self.config.signal_rebalance_dates.is_empty()
|
||||
|| self.config.signal_rebalance_dates.contains(&decision_date)
|
||||
}
|
||||
@@ -9082,6 +9146,12 @@ impl PlatformExprStrategy {
|
||||
let mut diagnostics = Vec::new();
|
||||
for action in &self.config.explicit_actions {
|
||||
match action {
|
||||
PlatformTradeAction::ConsumeSignal => {
|
||||
let book = self.config.signal_book.as_ref().ok_or_else(||
|
||||
BacktestError::Execution("signal_book_not_loaded".into()))?;
|
||||
intents.extend(book.intents(ctx).map_err(BacktestError::Execution)?);
|
||||
diagnostics.push(format!("signal_book_consumed version={} decision_date={}", book.version_sha256(), ctx.decision_date));
|
||||
}
|
||||
PlatformTradeAction::Order {
|
||||
kind,
|
||||
symbol,
|
||||
@@ -10544,10 +10614,7 @@ impl PlatformExprStrategy {
|
||||
) -> std::ops::Range<usize> {
|
||||
if !matches!(
|
||||
self.config.market_cap_field.as_str(),
|
||||
"market_cap"
|
||||
| "market_cap_bn"
|
||||
| "candidate_market_cap"
|
||||
| "candidate_market_cap_bn"
|
||||
"market_cap" | "market_cap_bn" | "candidate_market_cap" | "candidate_market_cap_bn"
|
||||
) || !band_low.is_finite()
|
||||
|| !band_high.is_finite()
|
||||
{
|
||||
@@ -10565,8 +10632,7 @@ impl PlatformExprStrategy {
|
||||
};
|
||||
let start = symbol_ids.partition_point(|symbol_id| market_cap(*symbol_id) < band_low);
|
||||
let end = start
|
||||
+ symbol_ids[start..]
|
||||
.partition_point(|symbol_id| market_cap(*symbol_id) <= band_high);
|
||||
+ symbol_ids[start..].partition_point(|symbol_id| market_cap(*symbol_id) <= band_high);
|
||||
start..end
|
||||
}
|
||||
|
||||
@@ -11332,6 +11398,7 @@ impl PlatformExprStrategy {
|
||||
matches!(
|
||||
action,
|
||||
PlatformTradeAction::Order { .. }
|
||||
| PlatformTradeAction::ConsumeSignal
|
||||
| PlatformTradeAction::TargetPortfolioSmart { .. }
|
||||
| PlatformTradeAction::Modify { .. }
|
||||
)
|
||||
@@ -11712,11 +11779,8 @@ impl PlatformExprStrategy {
|
||||
&execution_day,
|
||||
&factor_day,
|
||||
)?;
|
||||
let field_value = self.selection_field_value_from_caps(
|
||||
market_cap_bn,
|
||||
free_float_cap_bn,
|
||||
&stock,
|
||||
);
|
||||
let field_value =
|
||||
self.selection_field_value_from_caps(market_cap_bn, free_float_cap_bn, &stock);
|
||||
if !field_value.is_finite() {
|
||||
if diagnostics.len() < 12 {
|
||||
diagnostics.push(format!(
|
||||
@@ -12233,13 +12297,15 @@ impl Strategy for PlatformExprStrategy {
|
||||
.is_some()
|
||||
&& self.config.rotation_enabled
|
||||
{
|
||||
rules.push(
|
||||
self.config
|
||||
.rebalance_schedule
|
||||
.as_ref()
|
||||
.expect("checked timed rebalance schedule")
|
||||
.as_schedule_rule(ScheduleStage::OnDay),
|
||||
);
|
||||
let schedule=self.config.rebalance_schedule.as_ref().expect("checked timed rebalance schedule");
|
||||
if self.config.session_event_times.is_empty() {
|
||||
rules.push(schedule.as_schedule_rule(ScheduleStage::OnDay));
|
||||
} else {
|
||||
for time in &self.config.session_event_times {
|
||||
let mut timed=schedule.clone();timed.time_rule=Some(ScheduleTimeRule::physical_time(time.hour(),time.minute()));
|
||||
rules.push(timed.as_schedule_rule(ScheduleStage::OnDay));
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.config.explicit_actions.is_empty() {
|
||||
return rules;
|
||||
@@ -12296,11 +12362,13 @@ impl Strategy for PlatformExprStrategy {
|
||||
decision.merge_from(rotation?);
|
||||
}
|
||||
self.attach_buy_denials(ctx, &mut decision)?;
|
||||
self.append_pattern_diagnostics(&mut decision);
|
||||
Ok(decision)
|
||||
}
|
||||
|
||||
fn decision_quote_times(&self) -> Vec<NaiveTime> {
|
||||
let mut times = BTreeSet::new();
|
||||
times.extend(self.config.session_event_times.iter().copied());
|
||||
if self.uses_intraday_execution_quotes() {
|
||||
if self.config.explicit_action_times.is_empty() {
|
||||
times.insert(self.intraday_execution_start_time());
|
||||
@@ -12342,10 +12410,11 @@ impl Strategy for PlatformExprStrategy {
|
||||
if self.config.explicit_action_stage == PlatformExplicitActionStage::OpenAuction
|
||||
&& !self.config.explicit_actions.is_empty()
|
||||
&& self.config.explicit_action_schedule.is_none()
|
||||
&& self.unscheduled_explicit_actions_are_due(ctx.decision_date)
|
||||
&& self.unscheduled_explicit_actions_are_due(ctx.decision_date, ctx.execution_date)
|
||||
{
|
||||
let mut decision = self.explicit_action_decision(ctx)?;
|
||||
self.attach_buy_denials(ctx, &mut decision)?;
|
||||
self.append_pattern_diagnostics(&mut decision);
|
||||
return Ok(decision);
|
||||
}
|
||||
Ok(StrategyDecision::default())
|
||||
@@ -12354,19 +12423,23 @@ impl Strategy for PlatformExprStrategy {
|
||||
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
|
||||
let mut decision = self.compute_day_decision(ctx)?;
|
||||
self.attach_buy_denials(ctx, &mut decision)?;
|
||||
self.append_pattern_diagnostics(&mut decision);
|
||||
Ok(decision)
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformExprStrategy {
|
||||
fn attach_buy_denials(&self, ctx: &StrategyContext<'_>, decision: &mut StrategyDecision) -> Result<(), BacktestError> {
|
||||
if self.config.buy_filter_expr.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let symbols = decision.potential_buy_symbols(ctx.open_orders);
|
||||
if symbols.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(book) = &self.config.signal_book {
|
||||
decision.buy_denials.extend(book.buy_denials(ctx).map_err(BacktestError::Execution)?);
|
||||
}
|
||||
if self.config.buy_filter_expr.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let day = self.day_state(ctx, ctx.decision_date)?;
|
||||
let (market_date, _, factor_date) = self.selection_dates(ctx);
|
||||
let execution_time = ctx.active_datetime.filter(|value| value.date() == market_date)
|
||||
@@ -12392,6 +12465,31 @@ impl PlatformExprStrategy {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_pattern_diagnostics(&self, decision: &mut StrategyDecision) {
|
||||
let mut contexts=BTreeMap::<String,serde_json::Value>::new();
|
||||
for ((date, symbol, spec), result) in self.pattern_results.borrow().iter() {
|
||||
if result.values["session_contract"]==crate::session_events::CONTRACT {
|
||||
let record=serde_json::json!({"event":"session_event_decision","date":date,"symbol":symbol,"spec_sha256":spec,"matched":result.matched,"signal_bar_end":result.values["signal_bar_end"],"decision_at":result.values["decision_at"],"exclusion":result.exclusion}).to_string();
|
||||
if !decision.diagnostics.contains(&record){decision.diagnostics.push(record)}
|
||||
continue;
|
||||
}
|
||||
if result.values["execution_context_latest"].as_object().is_some_and(|v|!v.is_empty()) {
|
||||
let group=contexts.entry(spec.clone()).or_insert_with(||serde_json::json!({"event":"daily_pattern_context_decisions","date":date,"spec_sha256":spec,"evaluated":0,"matched":0,"excluded":0,"sample_limit":20,"samples":[]}));
|
||||
group["evaluated"]=serde_json::json!(group["evaluated"].as_u64().unwrap()+1);
|
||||
group["matched"]=serde_json::json!(group["matched"].as_u64().unwrap()+u64::from(result.matched));
|
||||
group["excluded"]=serde_json::json!(group["excluded"].as_u64().unwrap()+u64::from(result.exclusion.is_some()));
|
||||
let samples=group["samples"].as_array_mut().unwrap();
|
||||
if samples.len()<20 {samples.push(serde_json::json!({"symbol":symbol,"matched":result.matched,"context":result.values["execution_context_latest"],"exclusion":result.exclusion}));}
|
||||
continue;
|
||||
}
|
||||
if let Some(evidence) = &result.exclusion {
|
||||
let record = serde_json::json!({"event":"daily_pattern_excluded","date":date,"symbol":symbol,"spec":self.pattern_specs.borrow().get(spec),"spec_sha256":spec,"evidence":evidence}).to_string();
|
||||
if !decision.diagnostics.contains(&record) { decision.diagnostics.push(record); }
|
||||
}
|
||||
}
|
||||
for value in contexts.values() {let record=value.to_string();if !decision.diagnostics.contains(&record){decision.diagnostics.push(record);}}
|
||||
}
|
||||
|
||||
fn compute_day_decision(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
|
||||
if self.config.rotation_enabled
|
||||
&& self
|
||||
@@ -12453,7 +12551,7 @@ impl PlatformExprStrategy {
|
||||
let (explicit_action_intents, mut explicit_action_diagnostics) = if !in_skip_window
|
||||
&& self.config.explicit_action_stage == PlatformExplicitActionStage::OnDay
|
||||
&& self.config.explicit_action_schedule.is_none()
|
||||
&& self.unscheduled_explicit_actions_are_due(decision_date)
|
||||
&& self.unscheduled_explicit_actions_are_due(decision_date, execution_date)
|
||||
{
|
||||
self.explicit_action_intents(ctx, decision_date, &day)?
|
||||
} else {
|
||||
@@ -13925,16 +14023,10 @@ impl PlatformExprStrategy {
|
||||
if target_value <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
if !defer_execution_risk
|
||||
&& self
|
||||
.buy_rejection_reason(
|
||||
ctx,
|
||||
execution_date,
|
||||
symbol,
|
||||
self.stock_state(ctx, execution_date, symbol)?.as_ref(),
|
||||
)?
|
||||
.is_some()
|
||||
{
|
||||
if !defer_execution_risk && let Some(reason) = self.buy_rejection_reason(
|
||||
ctx, execution_date, symbol, self.stock_state(ctx, execution_date, symbol)?.as_ref(),
|
||||
)? {
|
||||
risk_decisions.push(FidcRiskDecisionAudit::rejected_buy_plan(execution_date, symbol, &reason));
|
||||
continue;
|
||||
}
|
||||
if !self.stock_passes_expr(ctx, &day, &decision_stock)? {
|
||||
@@ -14211,6 +14303,61 @@ mod tests {
|
||||
assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn periodic_selected_bjse_buy_rejection_is_audited_without_creating_an_order() {
|
||||
let dates = [d(2026, 8, 5), d(2026, 8, 6)];
|
||||
let symbol = "920038.BJ";
|
||||
let data = single_symbol_platform_data(&dates, symbol);
|
||||
let portfolio = PortfolioState::new(100_000.0);
|
||||
let subscriptions = BTreeSet::new();
|
||||
let ctx = StrategyContext {
|
||||
execution_date: dates[1], decision_date: dates[1], decision_index: 1, data: &data,
|
||||
portfolio: &portfolio, futures_account: None, open_orders: &[], dynamic_universe: None,
|
||||
subscriptions: &subscriptions, process_events: &[], active_process_event: None,
|
||||
active_datetime: None, order_events: &[], fills: &[],
|
||||
};
|
||||
let mut cfg = PlatformExprStrategyConfig::generic();
|
||||
cfg.signal_symbol = symbol.into();
|
||||
cfg.stock_filter_expr = "close > 0".into();
|
||||
cfg.hold_until_exit_enabled = true;
|
||||
cfg.target_portfolio_daily_enabled = true;
|
||||
cfg.daily_top_up_enabled = true;
|
||||
cfg.daily_position_target_adjust_enabled = true;
|
||||
cfg.rebalance_existing_positions = true;
|
||||
cfg.risk_config.static_rules.reject_bjse_selection = false;
|
||||
cfg.risk_config.static_rules.reject_bjse_buy = true;
|
||||
let decision = PlatformExprStrategy::new(cfg.clone()).on_day(&ctx).unwrap();
|
||||
assert!(decision.order_intents.is_empty());
|
||||
assert!(decision.risk_decisions.iter().any(|audit| audit.symbol == symbol && audit.stage == "buy_planning" && audit.rule_code == "bjse" && !audit.accepted));
|
||||
cfg.risk_config.static_rules.reject_bjse_buy = false;
|
||||
let allowed = PlatformExprStrategy::new(cfg).on_day(&ctx).unwrap();
|
||||
assert!(!allowed.order_intents.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_pattern_runtime_uses_the_shared_kernel_and_rejects_early_visibility() {
|
||||
let dates=(0..21).map(|n|d(2025,1,1)+chrono::Duration::days(n)).collect::<Vec<_>>();
|
||||
let symbol="000001.SZ";
|
||||
let mut parts=single_symbol_platform_data(&dates,symbol).snapshot_components();
|
||||
for f in &mut parts.factors {f.adjustment_factor_backward1=Some(1.0);}
|
||||
// Future execution-day prices must not enter the previous day's rule.
|
||||
let last=parts.market.last_mut().unwrap();last.close=5.0;last.low=5.0;last.last_price=5.0;
|
||||
let data=DataSet::from_components(parts.instruments,parts.market,parts.factors,parts.candidates,parts.benchmarks).unwrap();
|
||||
let portfolio=PortfolioState::new(100_000.0);let subscriptions=BTreeSet::new();
|
||||
let mut ctx=StrategyContext {execution_date:dates[20],decision_date:dates[19],decision_index:19,data:&data,portfolio:&portfolio,futures_account:None,open_orders:&[],dynamic_universe:None,subscriptions:&subscriptions,process_events:&[],active_process_event:None,active_datetime:None,order_events:&[],fills:&[]};
|
||||
let mut cfg=PlatformExprStrategyConfig::generic();cfg.signal_symbol=symbol.into();cfg.matching_type=MatchingType::NextBarOpen;
|
||||
let mut strategy=PlatformExprStrategy::new(cfg);
|
||||
let expression=r#"pattern_signal("{\"template\":\"ma_below\",\"parameters\":{\"ma_window\":20}}")"#;
|
||||
let day=strategy.day_state(&ctx,dates[19]).unwrap();let stock=strategy.stock_state(&ctx,dates[19],symbol).unwrap();
|
||||
assert!(!strategy.eval_bool(&ctx,expression,&day,Some(&stock),None).unwrap());
|
||||
ctx.active_datetime=dates[19].and_hms_opt(10,18,0);
|
||||
assert!(!strategy.eval_bool(&ctx,expression,&day,Some(&stock),None).unwrap());
|
||||
ctx.execution_date=dates[19];
|
||||
assert!(strategy.eval_bool(&ctx,expression,&day,Some(&stock),None).unwrap_err().to_string().contains("not_yet_visible"));
|
||||
ctx.active_datetime=None;strategy.config.matching_type=MatchingType::CurrentBarClose;
|
||||
assert!(strategy.eval_bool(&ctx,expression,&day,Some(&stock),None).unwrap_err().to_string().contains("requires_next_bar_open"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buy_quote_filter_rejects_missing_intraday_quote_not_daily_close() {
|
||||
let date = d(2025, 1, 2);
|
||||
@@ -14236,6 +14383,28 @@ mod tests {
|
||||
assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_event_uses_previous_bar_and_recomputes_at_each_minute_without_becoming_a_quote() {
|
||||
let date = d(2026,9,8); let symbol = "000001.SZ";
|
||||
let bars = (0..=33).map(|i| {
|
||||
let timestamp = date.and_hms_opt(9,30,0).unwrap() + chrono::Duration::minutes(i);
|
||||
let close = if i==31 {11.} else {10.};
|
||||
crate::session_events::MinuteBar {symbol:symbol.into(),timestamp,available_at:timestamp,open:close,high:close,low:close,close,volume:100.,amount:close*100.}
|
||||
}).collect();
|
||||
let data = single_symbol_platform_data(&[date],symbol).with_completed_minute_bars(bars).unwrap();
|
||||
let portfolio=PortfolioState::new(100_000.); let subscriptions=BTreeSet::new();
|
||||
let mut ctx=StrategyContext {execution_date:date,decision_date:date,decision_index:0,data:&data,portfolio:&portfolio,futures_account:None,open_orders:&[],dynamic_universe:None,subscriptions:&subscriptions,process_events:&[],active_process_event:None,active_datetime:None,order_events:&[],fills:&[]};
|
||||
let mut config=PlatformExprStrategyConfig::generic();config.signal_symbol=symbol.into();config.matching_type=MatchingType::MinuteLast;
|
||||
let strategy=PlatformExprStrategy::new(config);
|
||||
let expression=r#"pattern_signal("{\"template\":\"session_event\",\"session_event\":\"OPENING_RANGE_BREAKOUT_UP\",\"parameters\":{}}")"#;
|
||||
let day=strategy.day_state(&ctx,date).unwrap();let stock=strategy.stock_state(&ctx,date,symbol).unwrap();
|
||||
for (minute,expected) in [(1,false),(2,true),(3,false)] {
|
||||
ctx.active_datetime=date.and_hms_opt(10,minute,0);
|
||||
assert_eq!(strategy.eval_bool(&ctx,expression,&day,Some(&stock),None).unwrap(),expected);
|
||||
}
|
||||
assert!(data.snapshot_components().execution_quotes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buy_filter_uses_active_schedule_time_instead_of_first_configured_time() {
|
||||
let date = d(2025, 1, 2);
|
||||
@@ -16633,15 +16802,16 @@ mod tests {
|
||||
cfg.market_cap_field = "market_cap".to_string();
|
||||
let strategy = PlatformExprStrategy::new(cfg.clone());
|
||||
|
||||
let range = strategy.market_cap_ordered_selection_range(
|
||||
&factor_day,
|
||||
symbol_ids,
|
||||
10.0,
|
||||
20.0,
|
||||
);
|
||||
let range =
|
||||
strategy.market_cap_ordered_selection_range(&factor_day, symbol_ids, 10.0, 20.0);
|
||||
let selected_caps = symbol_ids[range]
|
||||
.iter()
|
||||
.map(|symbol_id| factor_day.factor(*symbol_id).expect("factor row").market_cap_bn)
|
||||
.map(|symbol_id| {
|
||||
factor_day
|
||||
.factor(*symbol_id)
|
||||
.expect("factor row")
|
||||
.market_cap_bn
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(selected_caps, vec![10.0, 20.0]);
|
||||
|
||||
@@ -16652,8 +16822,12 @@ mod tests {
|
||||
);
|
||||
cfg.market_cap_field = "free_float_cap".to_string();
|
||||
assert_eq!(
|
||||
PlatformExprStrategy::new(cfg)
|
||||
.market_cap_ordered_selection_range(&factor_day, symbol_ids, 10.0, 20.0),
|
||||
PlatformExprStrategy::new(cfg).market_cap_ordered_selection_range(
|
||||
&factor_day,
|
||||
symbol_ids,
|
||||
10.0,
|
||||
20.0
|
||||
),
|
||||
0..symbol_ids.len()
|
||||
);
|
||||
}
|
||||
@@ -25369,9 +25543,9 @@ mod tests {
|
||||
}];
|
||||
let mut strategy = PlatformExprStrategy::new(config);
|
||||
|
||||
assert!(strategy.unscheduled_explicit_actions_are_due(first));
|
||||
assert!(!strategy.unscheduled_explicit_actions_are_due(between));
|
||||
assert!(strategy.unscheduled_explicit_actions_are_due(second));
|
||||
assert!(strategy.unscheduled_explicit_actions_are_due(first, first));
|
||||
assert!(!strategy.unscheduled_explicit_actions_are_due(between, between));
|
||||
assert!(strategy.unscheduled_explicit_actions_are_due(second, second));
|
||||
|
||||
let mut decide = |date, decision_index| {
|
||||
let ctx = StrategyContext {
|
||||
|
||||
@@ -227,6 +227,8 @@ const RUNTIME_HELPER_FUNCTIONS: &[&str] = &[
|
||||
"factor",
|
||||
"day_factor",
|
||||
"rolling_mean",
|
||||
"pattern_signal",
|
||||
"pattern_score",
|
||||
"rolling_mean_current",
|
||||
"rolling_max_current",
|
||||
"rolling_return_stddev_current",
|
||||
|
||||
@@ -17,6 +17,8 @@ use crate::{
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StrategyRuntimeSpec {
|
||||
#[serde(default)]
|
||||
pub signal_book: Option<crate::signal_contract::SignalBook>,
|
||||
#[serde(default, alias = "strategy_id")]
|
||||
pub strategy_id: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -2538,6 +2540,10 @@ pub fn platform_expr_config_from_spec(
|
||||
cfg.benchmark_symbol = normalize_symbol(&cfg.benchmark_symbol, None);
|
||||
}
|
||||
let trade_times = spec_trade_times(spec);
|
||||
if crate::pattern_context::specs_in_value(&serde_json::to_value(spec).map_err(|e|e.to_string())?)?.iter().any(|p|p.template=="session_event") {
|
||||
if trade_times.is_empty() {return Err("session_event_requires_explicit_trade_times".into());}
|
||||
cfg.session_event_times=trade_times.clone();
|
||||
}
|
||||
let explicit_trading_schedule = spec
|
||||
.runtime_expressions
|
||||
.as_ref()
|
||||
@@ -2595,6 +2601,22 @@ pub fn platform_expr_config_from_spec(
|
||||
}
|
||||
cfg.strict_value_budget = true;
|
||||
|
||||
if let Some(raw) = &spec.signal_book {
|
||||
let book = raw.clone().validate()?;
|
||||
if cfg.explicit_actions.len() != 1 || !matches!(cfg.explicit_actions[0], PlatformTradeAction::ConsumeSignal) {
|
||||
return Err("signal_book_requires_one_consume_signal_action".into());
|
||||
}
|
||||
if !cfg.signal_rebalance_dates.is_empty() && cfg.signal_rebalance_dates != book.decision_dates() {
|
||||
return Err("signal_book_schedule_does_not_match_strategy".into());
|
||||
}
|
||||
cfg.rotation_enabled = false;
|
||||
cfg.signal_rebalance_dates = book.decision_dates();
|
||||
cfg.initial_subscriptions.extend(book.symbols());
|
||||
cfg.signal_book = Some(std::sync::Arc::new(book));
|
||||
} else if cfg.explicit_actions.iter().any(|action| matches!(action, PlatformTradeAction::ConsumeSignal)) {
|
||||
return Err("consume_signal_requires_verified_signal_book".into());
|
||||
}
|
||||
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
@@ -2747,6 +2769,7 @@ fn parse_platform_trade_action(
|
||||
None => None,
|
||||
};
|
||||
match kind.as_str() {
|
||||
"consume_signal" if when_expr.is_none() && time_in_force.is_none() => Some(PlatformTradeAction::ConsumeSignal),
|
||||
"target_portfolio_smart" => Some(PlatformTradeAction::TargetPortfolioSmart {
|
||||
target_weights_expr: action
|
||||
.target_weights_expr
|
||||
@@ -4458,6 +4481,20 @@ mod tests {
|
||||
assert_eq!(cfg.delayed_limit_open_exit_time, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_rotation_keeps_every_declared_clock_not_only_the_last_one() {
|
||||
use crate::Strategy;
|
||||
let literal=serde_json::to_string(&serde_json::json!({"template":"session_event","session_event":"INTRADAY_VOLUME_SPIKE","parameters":{}}).to_string()).unwrap();
|
||||
let mut spec=serde_json::json!({"rebalance":{"tradeTimes":["09:35","10:40","14:59"]},"runtimeExpressions":{"schedule":{"frequency":"daily","time":"14:59"},"trading":{"rotationEnabled":true,"buyFilterExpr":format!("pattern_signal({literal})")}},"execution":{"matchingType":"minute_last"}});
|
||||
let config=platform_expr_config_from_value("session","000300.SH",&spec).unwrap();
|
||||
assert_eq!(config.session_event_times.len(),3);
|
||||
let strategy=crate::PlatformExprStrategy::new(config);
|
||||
assert_eq!(strategy.schedule_rules().len(),3);
|
||||
assert_eq!(strategy.decision_quote_times().len(),3);
|
||||
spec["rebalance"]["tradeTimes"]=serde_json::json!([]);
|
||||
assert!(platform_expr_config_from_value("session","000300.SH",&spec).unwrap_err().to_string().contains("explicit_trade_times"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_trading_schedule_overrides_rebalance_trade_times() {
|
||||
let spec = serde_json::json!({
|
||||
|
||||
@@ -1047,8 +1047,6 @@ impl PortfolioState {
|
||||
let unresolved_delisting = current_market_missing
|
||||
&& data.instrument(&position.symbol).is_some_and(|instrument| {
|
||||
instrument.is_delisted_on_or_before(date)
|
||||
|| (instrument.status.eq_ignore_ascii_case("delisted")
|
||||
&& instrument.delisted_at.is_none())
|
||||
});
|
||||
if unresolved_delisting {
|
||||
position.last_price = 0.0;
|
||||
@@ -1068,11 +1066,13 @@ impl PortfolioState {
|
||||
position.refresh_day_pnl();
|
||||
continue;
|
||||
}
|
||||
let confirmed_pause = data.market(date, &position.symbol).is_some_and(|row| row.paused)
|
||||
|| data.candidate(date, &position.symbol).is_some_and(|row| row.is_paused);
|
||||
let price = data
|
||||
.price(date, &position.symbol, field)
|
||||
.or_else(|| data.price_on_or_before(date, &position.symbol, field))
|
||||
.or_else(|| confirmed_pause.then(|| data.price_on_or_before(date, &position.symbol, field)).flatten())
|
||||
.or_else(|| {
|
||||
(position.last_price.is_finite() && position.last_price > 0.0)
|
||||
(confirmed_pause && position.last_price.is_finite() && position.last_price > 0.0)
|
||||
.then_some(position.last_price)
|
||||
})
|
||||
.ok_or_else(|| DataSetError::MissingSnapshot {
|
||||
@@ -1774,7 +1774,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portfolio_carries_last_price_when_position_market_row_is_missing() {
|
||||
fn portfolio_missing_market_requires_formal_suspension_before_carrying_price() {
|
||||
let prev_date = NaiveDate::from_ymd_opt(2025, 5, 26).unwrap();
|
||||
let missing_date = NaiveDate::from_ymd_opt(2025, 5, 27).unwrap();
|
||||
let mut portfolio = PortfolioState::new(10_000.0);
|
||||
@@ -1832,9 +1832,23 @@ mod tests {
|
||||
.update_prices(prev_date, &dataset, PriceField::Close)
|
||||
.expect("previous close");
|
||||
portfolio.begin_trading_day();
|
||||
portfolio
|
||||
let error = portfolio
|
||||
.update_prices(missing_date, &dataset, PriceField::Close)
|
||||
.expect("missing current row should carry previous close");
|
||||
.expect_err("unclassified missing current price must not be filled from history");
|
||||
assert!(error.to_string().contains("601028.SH"));
|
||||
let paused_dataset = DataSet::from_components(
|
||||
vec![dataset.instrument("601028.SH").unwrap().clone()],
|
||||
vec![dataset.market(prev_date, "601028.SH").unwrap().clone()],
|
||||
Vec::new(),
|
||||
vec![crate::data::CandidateEligibility {
|
||||
date: missing_date, symbol: "601028.SH".into(), is_st: false, is_star_st: false,
|
||||
is_new_listing: false, is_paused: true, allow_buy: false, allow_sell: false,
|
||||
is_kcb: false, is_one_yuan: false, risk_level_code: None,
|
||||
}],
|
||||
vec![dataset.benchmark(prev_date).unwrap().clone()],
|
||||
).unwrap();
|
||||
portfolio.update_prices(missing_date, &paused_dataset, PriceField::Close)
|
||||
.expect("dated suspension permits keeping the last known valuation, not creating a fill");
|
||||
|
||||
let position = portfolio.position("601028.SH").expect("position");
|
||||
assert!((position.last_price - 10.3).abs() < 1e-6);
|
||||
|
||||
@@ -138,6 +138,16 @@ pub struct FidcRiskDecisionAudit {
|
||||
}
|
||||
|
||||
impl FidcRiskDecisionAudit {
|
||||
pub fn rejected_buy_plan(date: NaiveDate, symbol: &str, reason: &str) -> Self {
|
||||
Self {
|
||||
date, symbol: symbol.into(), scope: RiskCheckScope::Buy,
|
||||
stage: "buy_planning".into(), accepted: false,
|
||||
rule_code: reason.into(), reason: reason.into(),
|
||||
config_version: Some("inline_risk_policy".into()), data_epoch: date.to_string(),
|
||||
selection_batch_id: None, order_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rejected_selection(
|
||||
date: NaiveDate,
|
||||
symbol: impl Into<String>,
|
||||
@@ -208,14 +218,8 @@ impl ChinaAShareRiskControl {
|
||||
{
|
||||
return Some("inactive_or_delisted");
|
||||
}
|
||||
let status = instrument.status.trim().to_ascii_lowercase();
|
||||
let terminal_status = matches!(
|
||||
status.as_str(),
|
||||
"inactive" | "delisted" | "terminated" | "expired"
|
||||
);
|
||||
if terminal_status && instrument.delisted_at.is_none() {
|
||||
return Some("inactive_or_delisted");
|
||||
}
|
||||
// Latest reference status has no historical as-of date. Execution-day
|
||||
// risk snapshots remain authoritative; missing quotes are not waived.
|
||||
None
|
||||
}
|
||||
|
||||
@@ -843,7 +847,7 @@ mod tests {
|
||||
Some(&instrument("delisted", None)),
|
||||
date,
|
||||
),
|
||||
Some("inactive_or_delisted")
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
ChinaAShareRiskControl::instrument_rejection_reason(
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
//! Completed, same-session minute events. These bars never become execution quotes.
|
||||
use crate::{
|
||||
daily_patterns::{PatternResult, PatternSpec},
|
||||
factor_events::{Expr, Frame},
|
||||
};
|
||||
use chrono::{FixedOffset, NaiveDateTime, NaiveTime, TimeZone, Timelike};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub const CONTRACT: &str = "fidc_completed_session_events_v1";
|
||||
pub const EVENTS: &[&str] = &[
|
||||
"PRICE_CROSS_VWAP_UP",
|
||||
"PRICE_CROSS_VWAP_DOWN",
|
||||
"INTRADAY_HIGH_BREAKOUT",
|
||||
"INTRADAY_LOW_BREAKDOWN",
|
||||
"OPENING_RANGE_BREAKOUT_UP",
|
||||
"OPENING_RANGE_BREAKOUT_DOWN",
|
||||
"INTRADAY_VOLUME_SPIKE",
|
||||
"MORNING_HIGH_BREAKOUT",
|
||||
"MORNING_LOW_BREAKDOWN",
|
||||
"AFTERNOON_MOMENTUM_UP",
|
||||
"AFTERNOON_MOMENTUM_DOWN",
|
||||
"LATE_SESSION_STRENGTH",
|
||||
"LATE_SESSION_WEAKNESS",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MinuteBar {
|
||||
pub symbol: String,
|
||||
pub timestamp: NaiveDateTime,
|
||||
pub available_at: NaiveDateTime,
|
||||
pub open: f64,
|
||||
pub high: f64,
|
||||
pub low: f64,
|
||||
pub close: f64,
|
||||
pub volume: f64,
|
||||
pub amount: f64,
|
||||
}
|
||||
pub type BarStore = Arc<BTreeMap<(chrono::NaiveDate, String), Vec<MinuteBar>>>;
|
||||
pub fn bar_store(bars: Vec<MinuteBar>) -> Result<BarStore, String> {
|
||||
let mut groups = BTreeMap::<(chrono::NaiveDate, String), Vec<MinuteBar>>::new();
|
||||
for bar in bars {
|
||||
groups
|
||||
.entry((bar.timestamp.date(), bar.symbol.clone()))
|
||||
.or_default()
|
||||
.push(bar);
|
||||
}
|
||||
for rows in groups.values_mut() {
|
||||
rows.sort_by_key(|r| r.timestamp);
|
||||
if rows
|
||||
.windows(2)
|
||||
.any(|pair| pair[0].timestamp == pair[1].timestamp)
|
||||
{
|
||||
return Err("duplicate_completed_minute_bar".into());
|
||||
}
|
||||
}
|
||||
Ok(Arc::new(groups))
|
||||
}
|
||||
fn f(name: &str) -> Expr {
|
||||
Expr::Field { name: name.into() }
|
||||
}
|
||||
fn n(value: f64) -> Expr {
|
||||
Expr::Number { value }
|
||||
}
|
||||
fn op(name: &str, args: Vec<Expr>, window: Option<usize>) -> Expr {
|
||||
Expr::Operator {
|
||||
name: name.into(),
|
||||
args,
|
||||
window,
|
||||
}
|
||||
}
|
||||
fn time(minutes: u32) -> NaiveTime {
|
||||
NaiveTime::from_hms_opt(minutes / 60, minutes % 60, 0).unwrap()
|
||||
}
|
||||
|
||||
pub fn is_regular_label(t: NaiveTime) -> bool {
|
||||
t.second() == 0 && (time(570) <= t && t <= time(690) || time(780) < t && t <= time(900))
|
||||
}
|
||||
|
||||
pub fn expression(event: &str, p: &BTreeMap<String, Value>) -> Result<Expr, String> {
|
||||
let cross = |up: bool, a: Expr, b: Expr| {
|
||||
op(
|
||||
if up { "CROSS_ABOVE" } else { "CROSS_BELOW" },
|
||||
vec![a, b],
|
||||
None,
|
||||
)
|
||||
};
|
||||
Ok(match event {
|
||||
"PRICE_CROSS_VWAP_UP" => cross(true, f("close"), f("session_vwap")),
|
||||
"PRICE_CROSS_VWAP_DOWN" => cross(false, f("close"), f("session_vwap")),
|
||||
"INTRADAY_HIGH_BREAKOUT" => op(
|
||||
"GT",
|
||||
vec![
|
||||
f("close"),
|
||||
op("LAG", vec![op("CUMMAX", vec![f("high")], None)], Some(1)),
|
||||
],
|
||||
None,
|
||||
),
|
||||
"INTRADAY_LOW_BREAKDOWN" => op(
|
||||
"LT",
|
||||
vec![
|
||||
f("close"),
|
||||
op("LAG", vec![op("CUMMIN", vec![f("low")], None)], Some(1)),
|
||||
],
|
||||
None,
|
||||
),
|
||||
"OPENING_RANGE_BREAKOUT_UP" => cross(true, f("close"), f("opening_high")),
|
||||
"OPENING_RANGE_BREAKOUT_DOWN" => cross(false, f("close"), f("opening_low")),
|
||||
"MORNING_HIGH_BREAKOUT" => cross(true, f("close"), f("morning_high")),
|
||||
"MORNING_LOW_BREAKDOWN" => cross(false, f("close"), f("morning_low")),
|
||||
"AFTERNOON_MOMENTUM_UP" => cross(true, f("afternoon_return"), n(0.)),
|
||||
"AFTERNOON_MOMENTUM_DOWN" => cross(false, f("afternoon_return"), n(0.)),
|
||||
"LATE_SESSION_STRENGTH" => cross(true, f("late_return"), n(0.)),
|
||||
"LATE_SESSION_WEAKNESS" => cross(false, f("late_return"), n(0.)),
|
||||
"INTRADAY_VOLUME_SPIKE" => op(
|
||||
"GTE",
|
||||
vec![
|
||||
f("volume"),
|
||||
op(
|
||||
"MUL",
|
||||
vec![
|
||||
op(
|
||||
"LAG",
|
||||
vec![op(
|
||||
"ROLLING_MEAN",
|
||||
vec![f("volume")],
|
||||
Some(p["volume_window"].as_u64().unwrap() as usize),
|
||||
)],
|
||||
Some(1),
|
||||
),
|
||||
n(p["volume_multiple"].as_f64().unwrap()),
|
||||
],
|
||||
None,
|
||||
),
|
||||
],
|
||||
None,
|
||||
),
|
||||
_ => return Err("session_event_not_registered".into()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn evaluate(
|
||||
spec: &PatternSpec,
|
||||
symbol: &str,
|
||||
bars: &[MinuteBar],
|
||||
decision: NaiveDateTime,
|
||||
) -> Result<PatternResult, String> {
|
||||
let mut result = PatternResult {
|
||||
symbol: symbol.into(),
|
||||
name: None,
|
||||
matched: false,
|
||||
score: None,
|
||||
checks: vec![],
|
||||
values: json!({}),
|
||||
anchor: Value::Null,
|
||||
exclusion: None,
|
||||
};
|
||||
if bars.is_empty() {
|
||||
return Err(format!(
|
||||
"session_source_missing: {symbol} {}",
|
||||
decision.date()
|
||||
));
|
||||
}
|
||||
let visible = bars
|
||||
.iter()
|
||||
.filter(|b| {
|
||||
b.timestamp.date() == decision.date()
|
||||
&& b.timestamp < decision
|
||||
&& b.available_at <= decision
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if visible.is_empty() {
|
||||
result.exclusion = Some(json!({"reason":"session_before_first_completed_bar"}));
|
||||
return Ok(result);
|
||||
}
|
||||
let last = visible.last().unwrap().timestamp;
|
||||
let expected = (570..=690)
|
||||
.chain(781..=900)
|
||||
.map(|m| decision.date().and_time(time(m)))
|
||||
.filter(|t| *t < decision)
|
||||
.last();
|
||||
if expected != Some(last) {
|
||||
return Err(format!(
|
||||
"session_latest_bar_missing: {symbol} expected={expected:?} actual={last}"
|
||||
));
|
||||
}
|
||||
let mut indexed = BTreeMap::new();
|
||||
for b in &visible {
|
||||
if b.symbol != symbol
|
||||
|| !is_regular_label(b.timestamp.time())
|
||||
|| b.available_at < b.timestamp
|
||||
|| [b.open, b.high, b.low, b.close, b.volume, b.amount]
|
||||
.iter()
|
||||
.any(|v| !v.is_finite())
|
||||
|| b.low <= 0.
|
||||
|| b.open <= 0.
|
||||
|| b.close <= 0.
|
||||
|| b.high < b.open.max(b.close)
|
||||
|| b.low > b.open.min(b.close)
|
||||
|| b.volume < 0.
|
||||
|| b.amount < 0.
|
||||
|| indexed.insert(b.timestamp, b).is_some()
|
||||
{
|
||||
return Err(format!("session_bar_invalid: {symbol} {}", b.timestamp));
|
||||
}
|
||||
}
|
||||
for minute in (571..=690).chain(781..=900) {
|
||||
let stamp = decision.date().and_time(time(minute));
|
||||
if stamp <= last && !indexed.contains_key(&stamp) {
|
||||
return Err(format!(
|
||||
"session_bar_gap: {symbol} {stamp}; no filling or calendar compression"
|
||||
));
|
||||
}
|
||||
}
|
||||
let opening_end = time(570 + spec.n("opening_minutes") as u32);
|
||||
let (mut volume, mut amount) = (0., 0.);
|
||||
let (mut opening_high, mut opening_low) = (f64::NEG_INFINITY, f64::INFINITY);
|
||||
let (mut morning_high, mut morning_low) = (f64::NEG_INFINITY, f64::INFINITY);
|
||||
let (mut morning_close, mut late_close) = (None, None);
|
||||
let mut fields: BTreeMap<String, Vec<Option<f64>>> = [
|
||||
"open",
|
||||
"high",
|
||||
"low",
|
||||
"close",
|
||||
"volume",
|
||||
"amount",
|
||||
"session_vwap",
|
||||
"opening_high",
|
||||
"opening_low",
|
||||
"morning_high",
|
||||
"morning_low",
|
||||
"afternoon_return",
|
||||
"late_return",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|s| (s.into(), vec![]))
|
||||
.collect();
|
||||
let mut timestamps = vec![];
|
||||
let mut available_at = vec![];
|
||||
let zone = FixedOffset::east_opt(8 * 3600).unwrap();
|
||||
for b in indexed.values() {
|
||||
let t = b.timestamp.time();
|
||||
volume += b.volume;
|
||||
amount += b.amount;
|
||||
if t <= opening_end {
|
||||
opening_high = opening_high.max(b.high);
|
||||
opening_low = opening_low.min(b.low);
|
||||
}
|
||||
if t <= time(690) {
|
||||
morning_high = morning_high.max(b.high);
|
||||
morning_low = morning_low.min(b.low);
|
||||
}
|
||||
if t == time(690) {
|
||||
morning_close = Some(b.close);
|
||||
}
|
||||
if t == time(870) {
|
||||
late_close = Some(b.close);
|
||||
}
|
||||
for (name, value) in [
|
||||
("open", Some(b.open)),
|
||||
("high", Some(b.high)),
|
||||
("low", Some(b.low)),
|
||||
("close", Some(b.close)),
|
||||
("volume", Some(b.volume)),
|
||||
("amount", Some(b.amount)),
|
||||
("session_vwap", (volume > 0.).then_some(amount / volume)),
|
||||
("opening_high", (t >= opening_end).then_some(opening_high)),
|
||||
("opening_low", (t >= opening_end).then_some(opening_low)),
|
||||
("morning_high", (t >= time(690)).then_some(morning_high)),
|
||||
("morning_low", (t >= time(690)).then_some(morning_low)),
|
||||
("afternoon_return", morning_close.map(|v| b.close / v - 1.)),
|
||||
("late_return", late_close.map(|v| b.close / v - 1.)),
|
||||
] {
|
||||
fields.get_mut(name).unwrap().push(value);
|
||||
}
|
||||
timestamps.push(zone.from_local_datetime(&b.timestamp).single().unwrap());
|
||||
available_at.push(zone.from_local_datetime(&b.available_at).single().unwrap());
|
||||
}
|
||||
let frame = Frame {
|
||||
symbol: symbol.into(),
|
||||
frequency: "1m".into(),
|
||||
decision_at: zone.from_local_datetime(&decision).single().unwrap(),
|
||||
timestamps,
|
||||
available_at,
|
||||
fields,
|
||||
};
|
||||
let event = spec
|
||||
.session_event
|
||||
.as_deref()
|
||||
.ok_or("session_event_id_required")?;
|
||||
let values = crate::factor_events::evaluate(&expression(event, &spec.parameters)?, &frame)?;
|
||||
let latest = values.values.last().copied().flatten();
|
||||
result.score = latest;
|
||||
result.matched = latest == Some(1.);
|
||||
result.values = json!({"session_event":event,"session_contract":CONTRACT,"expression":values,"signal_bar_end":last,"decision_at":decision,"bars":visible.len(),"bar_times":frame.timestamps.iter().map(|t|t.format("%Y-%m-%dT%H:%M:%S").to_string()).collect::<Vec<_>>(),"close":visible.last().unwrap().close,"session_return":visible.last().unwrap().close/visible.first().unwrap().open-1.,"price_policy":"same_session_raw_ohlcv"});
|
||||
if latest.is_none() {
|
||||
result.exclusion = Some(json!({"reason":"session_warmup_or_undefined"}));
|
||||
} else {
|
||||
result.checks.push(json!({"label":"分钟事件","actual":latest,"operator":"==","threshold":1,"passed":result.matched}));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn spec(event: &str) -> PatternSpec {
|
||||
serde_json::from_value::<PatternSpec>(
|
||||
json!({"template":"session_event","session_event":event,"parameters":{}}),
|
||||
)
|
||||
.unwrap()
|
||||
.validate()
|
||||
.unwrap()
|
||||
}
|
||||
fn bars() -> Vec<MinuteBar> {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2026, 9, 8).unwrap();
|
||||
(570..=690)
|
||||
.chain(781..=900)
|
||||
.enumerate()
|
||||
.map(|(i, m)| {
|
||||
let timestamp = date.and_time(time(m));
|
||||
let price = 100. + (i % 17) as f64 / 10.;
|
||||
let volume = if i % 39 == 0 { 1000. } else { 100. };
|
||||
MinuteBar {
|
||||
symbol: "300395.SZ".into(),
|
||||
timestamp,
|
||||
available_at: timestamp,
|
||||
open: price,
|
||||
high: price + 0.1,
|
||||
low: price - 0.1,
|
||||
close: price,
|
||||
volume,
|
||||
amount: volume * price,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[test]
|
||||
fn all_thirteen_events_return_native_boolean_series() {
|
||||
let bars = bars();
|
||||
let decision = "2026-09-08T15:00:01".parse().unwrap();
|
||||
for event in EVENTS {
|
||||
let value = evaluate(&spec(event), "300395.SZ", &bars, decision).unwrap();
|
||||
assert!(value.score.is_some(), "{event}");
|
||||
assert_eq!(value.values["expression"]["value_type"], "boolean");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn decision_uses_the_previous_completed_label_and_future_prices_do_not_rewrite() {
|
||||
let mut bars = bars();
|
||||
let decision = "2026-09-08T10:02:00".parse().unwrap();
|
||||
for event in EVENTS {
|
||||
let before = evaluate(&spec(event), "300395.SZ", &bars, decision).unwrap();
|
||||
for bar in &mut bars {
|
||||
if bar.timestamp >= decision {
|
||||
bar.open = 1000.;
|
||||
bar.close = 1000.;
|
||||
bar.high = 1001.;
|
||||
bar.low = 999.;
|
||||
}
|
||||
}
|
||||
let after = evaluate(&spec(event), "300395.SZ", &bars, decision).unwrap();
|
||||
assert_eq!(before.values, after.values);
|
||||
assert_eq!(after.values["signal_bar_end"], "2026-09-08T10:01:00");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn gaps_and_stale_last_bars_do_not_become_false_or_repeated_signals() {
|
||||
let mut values = bars();
|
||||
let decision = "2026-09-08T10:02:00".parse().unwrap();
|
||||
values.retain(|r| r.timestamp.time() != time(600));
|
||||
assert!(evaluate(&spec(EVENTS[0]), "300395.SZ", &values, decision)
|
||||
.unwrap_err()
|
||||
.contains("session_bar_gap"));
|
||||
let stale = bars()
|
||||
.into_iter()
|
||||
.filter(|r| r.timestamp.time() < time(601))
|
||||
.collect::<Vec<_>>();
|
||||
assert!(evaluate(&spec(EVENTS[0]), "300395.SZ", &stale, decision)
|
||||
.unwrap_err()
|
||||
.contains("latest_bar_missing"));
|
||||
}
|
||||
#[test]
|
||||
fn opening_range_is_unavailable_before_the_range_has_completed() {
|
||||
let value = evaluate(
|
||||
&spec("OPENING_RANGE_BREAKOUT_UP"),
|
||||
"300395.SZ",
|
||||
&bars(),
|
||||
"2026-09-08T09:59:01".parse().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(value.score, None);
|
||||
assert!(!value.matched);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
//! Immutable, account-independent trading signals. Quantity and execution
|
||||
//! prices are intentionally absent; the existing broker owns those decisions.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::strategy::{OrderIntent, StrategyContext};
|
||||
use crate::portfolio::PortfolioState;
|
||||
|
||||
pub const SIGNAL_BOOK_SCHEMA: &str = "fidc.signal-book/v1";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SignalProvenance {
|
||||
Observed,
|
||||
Reconstructed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SignalFrequency {
|
||||
Daily,
|
||||
Minute,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum SignalAction {
|
||||
TargetWeight { symbol: String, weight: f64 },
|
||||
BuyCondition { symbol: String, allowed: bool },
|
||||
Exit { symbol: String },
|
||||
Reduce { symbol: String, remaining_ratio: f64 },
|
||||
}
|
||||
|
||||
impl SignalAction {
|
||||
fn symbol(&self) -> &str {
|
||||
match self {
|
||||
Self::TargetWeight { symbol, .. }
|
||||
| Self::BuyCondition { symbol, .. }
|
||||
| Self::Exit { symbol }
|
||||
| Self::Reduce { symbol, .. } => symbol,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct SignalSnapshot {
|
||||
pub decision_at: DateTime<Utc>,
|
||||
pub input_as_of: DateTime<Utc>,
|
||||
pub input_available_at: DateTime<Utc>,
|
||||
pub generated_at: DateTime<Utc>,
|
||||
pub published_at: DateTime<Utc>,
|
||||
pub input_sha256: String,
|
||||
pub complete_targets: bool,
|
||||
pub actions: Vec<SignalAction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct SignalBook {
|
||||
pub schema: String,
|
||||
pub version_sha256: String,
|
||||
pub generator_sha256: String,
|
||||
pub knowledge_cutoff: DateTime<Utc>,
|
||||
pub provenance: SignalProvenance,
|
||||
pub frequency: SignalFrequency,
|
||||
pub expected_decisions: Vec<DateTime<Utc>>,
|
||||
pub snapshots: Vec<SignalSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ValidatedSignalBook {
|
||||
book: SignalBook,
|
||||
index: BTreeMap<NaiveDateTime, usize>,
|
||||
}
|
||||
|
||||
fn valid_sha(value: &str) -> bool {
|
||||
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
}
|
||||
|
||||
fn shanghai(value: DateTime<Utc>) -> NaiveDateTime {
|
||||
value.with_timezone(&FixedOffset::east_opt(8 * 3600).expect("Shanghai offset")).naive_local()
|
||||
}
|
||||
|
||||
impl SignalBook {
|
||||
pub fn content_sha256(&self) -> Result<String, String> {
|
||||
let mut value=serde_json::to_value(self).map_err(|error|error.to_string())?;
|
||||
value.as_object_mut().ok_or("signal_book_object_required")?.remove("versionSha256");
|
||||
let raw=serde_json::to_vec(&value).map_err(|error|error.to_string())?;
|
||||
Ok(format!("{:x}",Sha256::digest(raw)))
|
||||
}
|
||||
|
||||
pub fn validate(self) -> Result<ValidatedSignalBook, String> {
|
||||
if self.schema != SIGNAL_BOOK_SCHEMA || !valid_sha(&self.version_sha256)
|
||||
|| !valid_sha(&self.generator_sha256)
|
||||
{
|
||||
return Err("signal_book_identity_invalid".into());
|
||||
}
|
||||
if self.expected_decisions.is_empty() || self.expected_decisions.len() > 100_000
|
||||
|| self.expected_decisions.len() != self.snapshots.len()
|
||||
{
|
||||
return Err("signal_book_decision_coverage_incomplete".into());
|
||||
}
|
||||
let mut index = BTreeMap::new();
|
||||
let mut previous = None;
|
||||
let mut total_actions = 0usize;
|
||||
for (number, (expected, snapshot)) in self.expected_decisions.iter().zip(&self.snapshots).enumerate() {
|
||||
if snapshot.decision_at != *expected || previous.is_some_and(|value| value >= *expected) {
|
||||
return Err("signal_book_decisions_duplicate_or_unordered".into());
|
||||
}
|
||||
previous = Some(*expected);
|
||||
if self.knowledge_cutoff >= *expected || snapshot.input_as_of > *expected
|
||||
|| snapshot.input_available_at > *expected || snapshot.input_as_of > snapshot.input_available_at
|
||||
|| snapshot.published_at < snapshot.generated_at || !valid_sha(&snapshot.input_sha256)
|
||||
|| snapshot.generated_at < snapshot.input_available_at
|
||||
|| snapshot.generated_at < self.knowledge_cutoff
|
||||
{
|
||||
return Err("signal_book_future_or_invalid_input".into());
|
||||
}
|
||||
if self.provenance == SignalProvenance::Observed && snapshot.published_at > *expected {
|
||||
return Err("observed_signal_not_available_at_decision".into());
|
||||
}
|
||||
total_actions = total_actions.checked_add(snapshot.actions.len()).ok_or("signal_book_action_limit")?;
|
||||
if total_actions > 2_000_000 { return Err("signal_book_action_limit".into()); }
|
||||
let mut action_keys = BTreeSet::new();
|
||||
let mut target_symbols = BTreeSet::new();
|
||||
let mut reductions = BTreeSet::new();
|
||||
let mut total_weight = 0.0;
|
||||
for action in &snapshot.actions {
|
||||
let symbol = action.symbol();
|
||||
if symbol.is_empty() || symbol.trim() != symbol { return Err("signal_symbol_invalid".into()); }
|
||||
let kind = match action {
|
||||
SignalAction::TargetWeight { weight, .. } => {
|
||||
if !weight.is_finite() || !(0.0..=1.0).contains(weight) { return Err("signal_target_weight_invalid".into()); }
|
||||
target_symbols.insert(symbol);
|
||||
total_weight += weight;
|
||||
"target"
|
||||
}
|
||||
SignalAction::BuyCondition { .. } => "buy_condition",
|
||||
SignalAction::Exit { .. } => { reductions.insert(symbol); "exit" }
|
||||
SignalAction::Reduce { remaining_ratio, .. } => {
|
||||
if !remaining_ratio.is_finite() || !(0.0..1.0).contains(remaining_ratio) { return Err("signal_reduction_invalid".into()); }
|
||||
reductions.insert(symbol);
|
||||
"reduce"
|
||||
}
|
||||
};
|
||||
if !action_keys.insert((symbol, kind)) { return Err("signal_action_duplicate".into()); }
|
||||
}
|
||||
if total_weight > 1.0 + 1e-12 { return Err("signal_target_exposure_exceeds_one".into()); }
|
||||
if snapshot.complete_targets && !reductions.is_empty() {
|
||||
return Err("complete_target_snapshot_cannot_mix_relative_exits".into());
|
||||
}
|
||||
if !target_symbols.is_disjoint(&reductions) { return Err("signal_target_exit_conflict".into()); }
|
||||
for symbol in &reductions {
|
||||
if action_keys.contains(&(*symbol, "exit")) && action_keys.contains(&(*symbol, "reduce")) {
|
||||
return Err("signal_exit_reduction_conflict".into());
|
||||
}
|
||||
}
|
||||
index.insert(shanghai(*expected), number);
|
||||
}
|
||||
if self.content_sha256()? != self.version_sha256 {
|
||||
return Err("signal_book_content_hash_mismatch".into());
|
||||
}
|
||||
Ok(ValidatedSignalBook { book: self, index })
|
||||
}
|
||||
}
|
||||
|
||||
impl ValidatedSignalBook {
|
||||
pub fn require_observed(&self) -> Result<(), String> {
|
||||
if self.book.provenance != SignalProvenance::Observed {
|
||||
return Err("reconstructed_signal_forbidden_in_online_execution".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn version_sha256(&self) -> &str { &self.book.version_sha256 }
|
||||
|
||||
pub fn decision_dates(&self) -> BTreeSet<NaiveDate> {
|
||||
self.index.keys().map(|value| value.date()).collect()
|
||||
}
|
||||
|
||||
pub fn symbols(&self) -> BTreeSet<String> {
|
||||
self.book.snapshots.iter().flat_map(|snapshot| &snapshot.actions)
|
||||
.map(|action| action.symbol().to_owned()).collect()
|
||||
}
|
||||
|
||||
pub fn snapshot_for(&self, ctx: &StrategyContext<'_>) -> Result<&SignalSnapshot, String> {
|
||||
let snapshot = self.snapshot_at(ctx.execution_date, ctx.current_time(), ctx.is_lagged_execution())?;
|
||||
if ctx.is_lagged_execution() && shanghai(snapshot.input_as_of).date() > ctx.decision_date {
|
||||
return Err("next_open_signal_contains_execution_session_inputs".into());
|
||||
}
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
pub fn is_due_on(&self, execution_date: NaiveDate) -> bool {
|
||||
self.index.range(execution_date.and_hms_opt(0,0,0).expect("session start")..)
|
||||
.next().is_some_and(|(at,_)|at.date()==execution_date)
|
||||
}
|
||||
|
||||
fn snapshot_at(&self, execution_date: NaiveDate, current_time: Option<NaiveTime>, lagged: bool) -> Result<&SignalSnapshot, String> {
|
||||
let at = if self.book.frequency == SignalFrequency::Daily && lagged {
|
||||
execution_date.and_hms_opt(9, 30, 0).expect("next open")
|
||||
} else {
|
||||
execution_date.and_time(current_time.unwrap_or(NaiveTime::from_hms_opt(15, 0, 0).expect("daily close")))
|
||||
};
|
||||
self.index.get(&at).map(|index| &self.book.snapshots[*index])
|
||||
.ok_or_else(|| format!("signal_snapshot_missing_at_decision: {at}"))
|
||||
}
|
||||
|
||||
pub fn intents(&self, ctx: &StrategyContext<'_>) -> Result<Vec<OrderIntent>, String> {
|
||||
let snapshot = self.snapshot_for(ctx)?;
|
||||
self.snapshot_intents(snapshot, ctx.portfolio)
|
||||
}
|
||||
|
||||
fn snapshot_intents(&self, snapshot: &SignalSnapshot, portfolio: &PortfolioState) -> Result<Vec<OrderIntent>, String> {
|
||||
let reason = format!("信号执行 version={} decision={}", self.book.version_sha256, snapshot.decision_at);
|
||||
let mut intents = Vec::new();
|
||||
let mut weights = BTreeMap::new();
|
||||
for action in &snapshot.actions {
|
||||
match action {
|
||||
SignalAction::TargetWeight { symbol, weight } if snapshot.complete_targets => {
|
||||
weights.insert(symbol.clone(), *weight);
|
||||
}
|
||||
SignalAction::TargetWeight { symbol, weight } => intents.push(OrderIntent::TargetPercent {
|
||||
symbol: symbol.clone(), target_percent: *weight, reason: reason.clone(),
|
||||
}),
|
||||
SignalAction::Exit { symbol } => intents.push(OrderIntent::TargetPercent {
|
||||
symbol: symbol.clone(), target_percent: 0.0, reason: reason.clone(),
|
||||
}),
|
||||
SignalAction::Reduce { symbol, remaining_ratio } => {
|
||||
if let Some(position) = portfolio.position(symbol).filter(|position| position.quantity > 0) {
|
||||
let quantity = (f64::from(position.quantity) * remaining_ratio).floor() as u32;
|
||||
let target_quantity = i32::try_from(quantity).map_err(|_| "signal_reduction_quantity_overflow")?;
|
||||
intents.push(OrderIntent::TargetShares { symbol: symbol.clone(), target_quantity, reason: reason.clone() });
|
||||
}
|
||||
}
|
||||
SignalAction::BuyCondition { .. } => {}
|
||||
}
|
||||
}
|
||||
if snapshot.complete_targets {
|
||||
if weights.is_empty() {
|
||||
for position in portfolio.positions().values().filter(|position| position.quantity > 0) {
|
||||
intents.push(OrderIntent::TargetPercent { symbol: position.symbol.clone(), target_percent: 0.0, reason: reason.clone() });
|
||||
}
|
||||
} else {
|
||||
intents.push(OrderIntent::TargetPortfolioSmart { target_weights: weights,
|
||||
order_prices: None, valuation_prices: None, reason });
|
||||
}
|
||||
}
|
||||
Ok(intents)
|
||||
}
|
||||
|
||||
pub fn buy_denials(&self, ctx: &StrategyContext<'_>) -> Result<BTreeMap<String, String>, String> {
|
||||
Ok(self.snapshot_for(ctx)?.actions.iter().filter_map(|action| match action {
|
||||
SignalAction::BuyCondition { symbol, allowed: false } => Some((symbol.clone(), "信号买入条件未满足".into())),
|
||||
_ => None,
|
||||
}).collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Duration;
|
||||
use serde_json::json;
|
||||
|
||||
fn book() -> SignalBook {
|
||||
let decision: DateTime<Utc> = "2025-01-07T09:30:00+08:00".parse().unwrap();
|
||||
let source: DateTime<Utc> = "2025-01-06T15:00:00+08:00".parse().unwrap();
|
||||
seal(SignalBook {
|
||||
schema: SIGNAL_BOOK_SCHEMA.into(), version_sha256: "a".repeat(64), generator_sha256: "b".repeat(64),
|
||||
knowledge_cutoff: "2024-12-31T15:00:00+08:00".parse().unwrap(),
|
||||
provenance: SignalProvenance::Reconstructed, frequency: SignalFrequency::Daily,
|
||||
expected_decisions: vec![decision], snapshots: vec![SignalSnapshot {
|
||||
decision_at: decision, input_as_of: source, input_available_at: source,
|
||||
generated_at: decision + Duration::days(10), published_at: decision + Duration::days(10),
|
||||
input_sha256: "c".repeat(64), complete_targets: true,
|
||||
actions: vec![SignalAction::TargetWeight { symbol: "000001.SZ".into(), weight: 0.5 }],
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
fn seal(mut book:SignalBook)->SignalBook {
|
||||
book.version_sha256=book.content_sha256().unwrap();
|
||||
book
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_reconstruction_is_not_online_publication() {
|
||||
let validated = book().validate().unwrap();
|
||||
assert!(validated.require_observed().unwrap_err().contains("reconstructed"));
|
||||
let mut observed = book();
|
||||
observed.provenance = SignalProvenance::Observed;
|
||||
assert!(observed.clone().validate().unwrap_err().contains("not_available"));
|
||||
observed.snapshots[0].generated_at = observed.snapshots[0].decision_at;
|
||||
observed.snapshots[0].published_at = observed.snapshots[0].decision_at;
|
||||
seal(observed).validate().unwrap().require_observed().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_future_inputs_and_model_knowledge() {
|
||||
for field in 0..3 {
|
||||
let mut value = book();
|
||||
let future = value.snapshots[0].decision_at + Duration::seconds(1);
|
||||
match field {
|
||||
0 => value.snapshots[0].input_as_of = future,
|
||||
1 => value.snapshots[0].input_available_at = future,
|
||||
_ => value.knowledge_cutoff = future,
|
||||
}
|
||||
assert!(value.validate().unwrap_err().contains("future"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_quantities_prices_and_unknown_signal_fields() {
|
||||
for name in ["quantity", "execution_price", "account_id", "cash"] {
|
||||
let mut action = json!({"kind":"target_weight","symbol":"000001.SZ","weight":0.5});
|
||||
action[name] = json!(100);
|
||||
assert!(serde_json::from_value::<SignalAction>(action).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_and_duplicate_actions_fail_closed() {
|
||||
let mut value = book();
|
||||
value.expected_decisions.push(value.expected_decisions[0] + Duration::days(1));
|
||||
assert!(value.validate().unwrap_err().contains("coverage"));
|
||||
let mut value = book();
|
||||
value.snapshots.push(value.snapshots[0].clone());
|
||||
value.expected_decisions.push(value.expected_decisions[0]);
|
||||
assert!(value.validate().unwrap_err().contains("duplicate"));
|
||||
let mut value = book();
|
||||
let repeated = value.snapshots[0].actions[0].clone();
|
||||
value.snapshots[0].actions.push(repeated);
|
||||
assert!(value.validate().unwrap_err().contains("duplicate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_overallocation_nonfinite_and_ambiguous_actions() {
|
||||
for weight in [f64::NAN, f64::INFINITY, -0.1, 1.1] {
|
||||
let mut value = book();
|
||||
value.snapshots[0].actions[0] = SignalAction::TargetWeight { symbol: "000001.SZ".into(), weight };
|
||||
assert!(value.validate().is_err());
|
||||
}
|
||||
let mut value = book();
|
||||
value.snapshots[0].actions.push(SignalAction::TargetWeight { symbol:"000002.SZ".into(),weight:0.6 });
|
||||
assert!(value.validate().unwrap_err().contains("exposure"));
|
||||
let mut value = book();
|
||||
value.snapshots[0].actions.push(SignalAction::Exit {symbol:"000001.SZ".into()});
|
||||
assert!(value.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_open_uses_decision_session_and_never_nearest_signal() {
|
||||
let value = book().validate().unwrap();
|
||||
let day = NaiveDate::from_ymd_opt(2025,1,7).unwrap();
|
||||
assert!(value.snapshot_at(day, NaiveTime::from_hms_opt(9,30,0), true).is_ok());
|
||||
assert!(value.snapshot_at(day, NaiveTime::from_hms_opt(14,59,0), false).is_err());
|
||||
assert!(value.snapshot_at(day + Duration::days(1), None, true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reduction_is_resolved_from_each_accounts_actual_position() {
|
||||
let mut raw = book();
|
||||
raw.snapshots[0].complete_targets = false;
|
||||
raw.snapshots[0].actions = vec![SignalAction::Reduce {symbol:"000001.SZ".into(),remaining_ratio:0.5}];
|
||||
let value = seal(raw).validate().unwrap();
|
||||
let day = NaiveDate::from_ymd_opt(2025,1,3).unwrap();
|
||||
for (held, expected) in [(1000,500),(3000,1500)] {
|
||||
let mut portfolio = PortfolioState::new(100_000.0);
|
||||
portfolio.position_mut("000001.SZ").buy(day,held,10.0);
|
||||
let result = value.snapshot_intents(&value.book.snapshots[0],&portfolio).unwrap();
|
||||
assert!(matches!(result[0],OrderIntent::TargetShares {target_quantity,..} if target_quantity==expected));
|
||||
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity,held);
|
||||
}
|
||||
assert!(value.snapshot_intents(&value.book.snapshots[0],&PortfolioState::new(10_000.0)).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_complete_snapshot_clears_only_that_accounts_holdings() {
|
||||
let mut raw = book();
|
||||
raw.snapshots[0].actions.clear();
|
||||
let value = seal(raw).validate().unwrap();
|
||||
let day = NaiveDate::from_ymd_opt(2025,1,3).unwrap();
|
||||
let mut portfolio = PortfolioState::new(100_000.0);
|
||||
portfolio.position_mut("000002.SZ").buy(day,200,10.0);
|
||||
let result = value.snapshot_intents(&value.book.snapshots[0],&portfolio).unwrap();
|
||||
assert!(matches!(&result[0],OrderIntent::TargetPercent {symbol,target_percent,..} if symbol=="000002.SZ" && *target_percent==0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_spec_consumes_book_without_running_another_selection() {
|
||||
let spec = json!({"signalBook":book(),"runtimeExpressions":{"trading":{"actions":[{"kind":"consume_signal"}]}}});
|
||||
let config = crate::platform_strategy_spec::platform_expr_config_from_value("signal-fixture","000001.SZ",&spec).unwrap();
|
||||
assert!(!config.rotation_enabled && config.signal_book.is_some());
|
||||
assert!(matches!(config.explicit_actions.as_slice(),[crate::PlatformTradeAction::ConsumeSignal]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_valid_contents_must_not_reuse_a_version_hash() {
|
||||
let mut raw=book();
|
||||
raw.snapshots[0].actions=vec![SignalAction::TargetWeight{symbol:"000001.SZ".into(),weight:0.4}];
|
||||
assert_eq!(raw.clone().validate().unwrap_err(),"signal_book_content_hash_mismatch");
|
||||
seal(raw).validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_daily_inputs_may_be_published_after_market_close() {
|
||||
let mut raw=book();
|
||||
raw.expected_decisions=vec!["2026-07-07T09:30:00+08:00".parse().unwrap()];
|
||||
raw.snapshots[0].decision_at=raw.expected_decisions[0];
|
||||
raw.snapshots[0].input_as_of="2026-07-06T15:30:00+08:00".parse().unwrap();
|
||||
raw.snapshots[0].input_available_at="2026-07-06T16:00:00+08:00".parse().unwrap();
|
||||
raw.snapshots[0].generated_at=raw.snapshots[0].input_available_at;
|
||||
raw.snapshots[0].published_at=raw.snapshots[0].generated_at;
|
||||
raw.provenance=SignalProvenance::Observed;
|
||||
seal(raw).validate().unwrap().require_observed().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use chrono::{Duration, NaiveDate, NaiveTime};
|
||||
use fidc_core::{
|
||||
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
|
||||
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
||||
IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
|
||||
Instrument, IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
|
||||
StrategyDecision,
|
||||
};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
@@ -16,6 +16,18 @@ fn t(hour: u32, minute: u32, second: u32) -> NaiveTime {
|
||||
NaiveTime::from_hms_opt(hour, minute, second).expect("valid time")
|
||||
}
|
||||
|
||||
fn fixture_instruments() -> Vec<Instrument> {
|
||||
vec![Instrument {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
name: "quote-plan-fixture".to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(d(2020, 1, 1)),
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}]
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DecisionQuoteReader {
|
||||
day_count: usize,
|
||||
@@ -90,7 +102,7 @@ impl Strategy for NoLoaderDecisionQuoteStrategy {
|
||||
|
||||
fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
|
||||
DataSet::from_components(
|
||||
Vec::new(),
|
||||
fixture_instruments(),
|
||||
vec说明了该分派边界。
|
||||
|
||||
下单数量、目标仓位、投入比例和筛选边界必须返回有限数值,否则记录
|
||||
`missing_numeric_result`,包含表达式、证券、决策日和执行日。只有排名评估保留
|
||||
独立的缺失值诊断路径;没有把数据源的缺行改写为价格或交易事实。
|
||||
|
||||
## 代码与测试
|
||||
|
||||
- `fda2e70`:VM 三值逻辑及动态数值保护。
|
||||
- `ea58ab2`:显式关闭 Rhai 快运算符,补齐缺失 map 保护。
|
||||
- `e3f1028`:执行标量必须有限,排名与执行参数分离。
|
||||
- 177 引擎:585 项通过、8 项跳过。
|
||||
- Runner:360 项通过、3 项跳过。
|
||||
- 交易工作区链接 e3f1028:510 项通过、8 项跳过。
|
||||
|
||||
首次回归曾发现 Rhai 快路径仍绕过保护,修复后重新完整测试,未将失败候选部署。
|
||||
|
||||
## 真实回放
|
||||
|
||||
使用已保存的原始 strategy spec、初始资金、日期、基准、频率及全部执行配置,
|
||||
通过独立 runner 真正重新执行。固定为服务实际使用的16个逻辑CPU、Rayon8线程、Tokio16线程。
|
||||
|
||||
- 五年日线:2021-08-23 至 2026-08-28,1,000万元,25,408笔成交。
|
||||
- 分钟样本:2025-01-02 至 2025-11-17,100万元,156笔成交。
|
||||
- 10次回放的 canonical 与 result-store 均等于各自同 frozen bundle 基准。
|
||||
- 包含 e3f1028 的最终回放为 `five-year-strict-1`、`minute-strict-1`。
|
||||
|
||||
完整证据:`/Users/boris/WorkSpace/fidc-backtest-service/docs/evidence/numeric-condition-replay-20260909.json`。
|
||||
日线源行6,918,227;分钟样本仅636行,不能用其亚秒耗时宣传全部分钟策略的性能。
|
||||
|
||||
## 边界
|
||||
|
||||
该候选尚未部署到常驻回测或交易服务。此验证证明两种已有策略在有效冻结数据下结果不变,
|
||||
不证明所有策略、所有原始财务公告/vintage、全部缺失数据原因或真实券商交易均已验收。
|
||||
Rhai 未提供与数值 VM 完全相同的 nullable 表达式能力,目前选择明确拒绝,不能称为所有
|
||||
动态语言表达式都已支持三值逻辑。完整 typed Base Panel 与对象分配优化仍待完成。
|
||||
@@ -0,0 +1,15 @@
|
||||
# 生命周期、价格缺失与历史状态
|
||||
|
||||
证券有效区间为 `[listed_at, delisted_at)`。无明确摘牌日期的最新 terminal 标签不能反向污染历史;已知未来摘牌日不阻断此前的正常交易。退市整理期不是已摘牌。
|
||||
|
||||
执行价加载前分别核验证券身份、正式上市/摘牌边界。合法上市前、摘牌后不查询和补价,记录结构化原因;同一日已有正执行价与生命周期边界冲突时报错。未知身份/代码映射、上市后的分钟缺口、候选事实缺失继续失败,不因 missing candidate 而跳过校验。持仓仅在当日正式暂停交易事实成立时允许按既定估值合同沿用历史价格;普通行情缺口不再无条件沿用旧价。
|
||||
|
||||
整个明确证券范围尚未上市时保留官方日历内现金净值点,不缩短回测范围,不伪造成交或 OHLCV。基准只在首个基线点归一,后续无交易日不反复重置。
|
||||
|
||||
513 项核心测试通过,6 项原有测试忽略。新增验证包含沪深北股票和 ETF 上市前、实际摘牌日、未知证券身份、候选缺失、正式停牌和普通价格缺口、全池上市前现金期间。对单个正式分区的数据缺口仍需数据源修复,不从这些测试外推全市场完整性。
|
||||
|
||||
## 真实边界回放补充
|
||||
|
||||
177 回测 `btr_1789041425783_797911_1`:920038.BJ,2026-08-04 至 08-07。真实上市日08-05,原结果只保留08-05至08-07三个净值点。原因是准备面同时加载基准000300.SH,基准不是交易候选但参与了“全部证券生命周期外”的判定。现在只排除已声明且没有交易候选记录的基准,不按代码或名称猜测指数,也不把真实候选排除;补充真实准备结构的回归后,4日现金区间完整保留。
|
||||
|
||||
该草稿沿用源池 `rejectBjseSelection=false`、`rejectBjseBuy=true`,所以选中北交所但不下单符合其买入政策;原规划阶段没有记录拒绝原因则是审计缺项。新增 `scope=buy, stage=buy_planning` 审计,不伪造订单ID,不把买入否决改写成选股排除。测试验证禁止时无订单且有bjse原因,放开买入政策时正常生成意图。最新核心514项通过、6项原有忽略。
|
||||
Reference in New Issue
Block a user