From fa0b316a8bcd7d8934f317bd63d2dc9ac960001e Mon Sep 17 00:00:00 2001 From: boris Date: Fri, 11 Sep 2026 13:26:39 +0800 Subject: [PATCH] refactor: separate expression metadata and tests from numerical identity --- crates/fidc-core/src/factor_event_catalog.rs | 52 +++++ crates/fidc-core/src/factor_events.rs | 215 +------------------ crates/fidc-core/src/factor_events_tests.rs | 161 ++++++++++++++ crates/fidc-core/src/lib.rs | 1 + 4 files changed, 219 insertions(+), 210 deletions(-) create mode 100644 crates/fidc-core/src/factor_event_catalog.rs create mode 100644 crates/fidc-core/src/factor_events_tests.rs diff --git a/crates/fidc-core/src/factor_event_catalog.rs b/crates/fidc-core/src/factor_event_catalog.rs new file mode 100644 index 0000000..2a6b742 --- /dev/null +++ b/crates/fidc-core/src/factor_event_catalog.rs @@ -0,0 +1,52 @@ +//! Indicator metadata is versioned independently from the numerical kernel. +use crate::factor_events::{CONTRACT, OPERATORS, TA_REV}; +use serde_json::{Value, json}; +use ta_lib::abstract_api::{self, OptInputType}; + +pub fn catalog() -> Value { + use sha2::{Digest, Sha256}; + let mut implementation = Sha256::new(); + for file in [include_bytes!("factor_events.rs").as_slice(), include_bytes!("factor_cross_section.rs").as_slice(), + include_bytes!("daily_patterns.rs").as_slice(),include_bytes!("market_event_context.rs").as_slice(), + include_bytes!("session_events.rs").as_slice(),include_bytes!("pattern_context.rs").as_slice(),TA_REV.as_bytes()] {implementation.update(file);} + let implementation_sha256=format!("{:x}",implementation.finalize()); + let indicators: Vec = abstract_api::funcs().map(|f| json!({ + "name":f.name, "group":format!("{:?}",f.group), "description":f.hint, + "inputs":f.inputs.iter().map(|p|json!({"name":p.param_name,"kind":format!("{:?}",p.kind),"flags":p.flags.0})).collect::>(), + "parameters":f.opt_inputs.iter().map(|p|json!({"name":p.param_name,"label":p.display_name,"description":p.hint,"domain":parameter_domain(p.kind)})).collect::>(), + "outputs":f.outputs.iter().enumerate().map(|(i,p)|json!({"index":i,"name":p.param_name,"kind":format!("{:?}",p.kind)})).collect::>(), + "unstable_period":format!("{:?}",f.unst_id), "production_eligible":false, + })).collect(); + json!({"contract":CONTRACT,"parameter_domain_contract":"fidc.indicator-parameter-domain/v1","expression_kernel_sha256":implementation_sha256,"library":{"name":"TA-Lib native Rust","revision":TA_REV,"license":"BSD-3-Clause"}, + "execution_context_contract":crate::pattern_context::CONTRACT, + "execution_context_fields":crate::pattern_context::CONTEXT_FIELDS, + "market_event_context_contract":crate::market_event_context::CONTRACT, + "market_event_kernel_sha256":crate::market_event_context::implementation_sha256(), + "market_event_common_fields":crate::market_event_context::COMMON_FIELDS, + "market_event_industry_fields":crate::market_event_context::INDUSTRY_FIELDS, + "session_events":crate::session_events::EVENTS,"session_event_contract":crate::session_events::CONTRACT, + "indicators":indicators,"operators":OPERATORS,"cross_section_operators":crate::factor_cross_section::OPERATORS,"read_only":true,"live_routing":false, + "policies":{"null":"unknown_not_false","warmup":"null_until_full_history","recursive_seed":"frozen_input_start", + "breakout":"previous_window_excludes_current","boolean":"three_valued_logic","daily_execution":"next_completed_session", + "minute_execution":"strictly_after_completed_bar","cross_section":"requires_separate_complete_universe_contract"}}) +} + +pub(crate) fn parameter_domain(kind: OptInputType) -> Value { + match kind { + OptInputType::IntegerRange { min, max, default, .. } => json!({ + "value_type":"integer", "minimum":min, "maximum":max, "default":default, + }), + OptInputType::RealRange { min, max, default, precision, .. } => json!({ + "value_type":"number", "minimum":min, "maximum":max, + "default":default, "display_precision":precision, + }), + OptInputType::IntegerList { values, default } => json!({ + "value_type":"integer", "default":default, + "choices":values.iter().map(|(value,label)|json!({"value":value,"label":label})).collect::>(), + }), + OptInputType::RealList { values, default } => json!({ + "value_type":"number", "default":default, + "choices":values.iter().map(|(value,label)|json!({"value":value,"label":label})).collect::>(), + }), + } +} diff --git a/crates/fidc-core/src/factor_events.rs b/crates/fidc-core/src/factor_events.rs index d841eb6..4c6571a 100644 --- a/crates/fidc-core/src/factor_events.rs +++ b/crates/fidc-core/src/factor_events.rs @@ -1,7 +1,7 @@ //! Causal, typed indicator/event expressions shared by research and trading. use chrono::{DateTime, FixedOffset}; use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; +use serde_json::Value; use std::collections::BTreeMap; use ta_lib::{ Core, @@ -82,7 +82,7 @@ pub struct Series { pub values: Vec>, } -const OPERATORS: &[&str] = &[ +pub(crate) const OPERATORS: &[&str] = &[ "GT", "GTE", "LT", @@ -168,53 +168,7 @@ const OPERATORS: &[&str] = &[ "IF", ]; -pub fn catalog() -> Value { - use sha2::{Digest, Sha256}; - let mut implementation = Sha256::new(); - for file in [include_bytes!("factor_events.rs").as_slice(), include_bytes!("factor_cross_section.rs").as_slice(), - include_bytes!("daily_patterns.rs").as_slice(),include_bytes!("market_event_context.rs").as_slice(), - include_bytes!("session_events.rs").as_slice(),include_bytes!("pattern_context.rs").as_slice(),TA_REV.as_bytes()] {implementation.update(file);} - let implementation_sha256=format!("{:x}",implementation.finalize()); - let indicators: Vec = abstract_api::funcs().map(|f| json!({ - "name":f.name, "group":format!("{:?}",f.group), "description":f.hint, - "inputs":f.inputs.iter().map(|p|json!({"name":p.param_name,"kind":format!("{:?}",p.kind),"flags":p.flags.0})).collect::>(), - "parameters":f.opt_inputs.iter().map(|p|json!({"name":p.param_name,"label":p.display_name,"description":p.hint,"domain":parameter_domain(p.kind)})).collect::>(), - "outputs":f.outputs.iter().enumerate().map(|(i,p)|json!({"index":i,"name":p.param_name,"kind":format!("{:?}",p.kind)})).collect::>(), - "unstable_period":format!("{:?}",f.unst_id), "production_eligible":false, - })).collect(); - json!({"contract":CONTRACT,"parameter_domain_contract":"fidc.indicator-parameter-domain/v1","expression_kernel_sha256":implementation_sha256,"library":{"name":"TA-Lib native Rust","revision":TA_REV,"license":"BSD-3-Clause"}, - "execution_context_contract":crate::pattern_context::CONTRACT, - "execution_context_fields":crate::pattern_context::CONTEXT_FIELDS, - "market_event_context_contract":crate::market_event_context::CONTRACT, - "market_event_kernel_sha256":crate::market_event_context::implementation_sha256(), - "market_event_common_fields":crate::market_event_context::COMMON_FIELDS, - "market_event_industry_fields":crate::market_event_context::INDUSTRY_FIELDS, - "session_events":crate::session_events::EVENTS,"session_event_contract":crate::session_events::CONTRACT, - "indicators":indicators,"operators":OPERATORS,"cross_section_operators":crate::factor_cross_section::OPERATORS,"read_only":true,"live_routing":false, - "policies":{"null":"unknown_not_false","warmup":"null_until_full_history","recursive_seed":"frozen_input_start", - "breakout":"previous_window_excludes_current","boolean":"three_valued_logic","daily_execution":"next_completed_session", - "minute_execution":"strictly_after_completed_bar","cross_section":"requires_separate_complete_universe_contract"}}) -} - -fn parameter_domain(kind: OptInputType) -> Value { - match kind { - OptInputType::IntegerRange { min, max, default, .. } => json!({ - "value_type":"integer", "minimum":min, "maximum":max, "default":default, - }), - OptInputType::RealRange { min, max, default, precision, .. } => json!({ - "value_type":"number", "minimum":min, "maximum":max, - "default":default, "display_precision":precision, - }), - OptInputType::IntegerList { values, default } => json!({ - "value_type":"integer", "default":default, - "choices":values.iter().map(|(value,label)|json!({"value":value,"label":label})).collect::>(), - }), - OptInputType::RealList { values, default } => json!({ - "value_type":"number", "default":default, - "choices":values.iter().map(|(value,label)|json!({"value":value,"label":label})).collect::>(), - }), - } -} +pub use crate::factor_event_catalog::catalog; impl Frame { pub fn validate(&self) -> Result<(), String> { @@ -978,164 +932,5 @@ fn operator( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn every_parameter_domain_is_structured_and_matches_native_defaults() { - for function in abstract_api::funcs() { - let handle = abstract_api::get_func_handle(function.name).unwrap(); - let core = Core::new(); - let mut call = handle.new_call(&core); - for (index, parameter) in function.opt_inputs.iter().enumerate() { - let domain = parameter_domain(parameter.kind); - let default = domain["default"].as_f64().unwrap(); - assert!(default.is_finite(), "{} {}", function.name, parameter.param_name); - if let Some(choices) = domain.get("choices") { - assert!(choices.as_array().unwrap().iter().any(|v| v["value"].as_f64() == Some(default))); - } else { - assert!(default >= domain["minimum"].as_f64().unwrap()); - assert!(default <= domain["maximum"].as_f64().unwrap()); - } - if domain["value_type"] == "integer" { - assert_eq!(default.fract(), 0.0); - call.set_opt(index, default as i32).unwrap(); - } else { - call.set_opt(index, default).unwrap(); - } - } - assert!(call.lookback().is_ok(), "{}", function.name); - } - } - - #[test] - fn parameter_domains_keep_enumeration_labels_without_debug_string_parsing() { - let catalog = catalog(); - assert_eq!(catalog["parameter_domain_contract"], "fidc.indicator-parameter-domain/v1"); - let indicators = catalog["indicators"].as_array().unwrap(); - let rsi = indicators.iter().find(|v| v["name"] == "RSI").unwrap(); - assert_eq!(rsi["parameters"][0]["domain"]["minimum"], 2); - let stoch = indicators.iter().find(|v| v["name"] == "STOCH").unwrap(); - let ma_type = stoch["parameters"].as_array().unwrap().iter().find(|p| p["name"] == "optInSlowK_MAType").unwrap(); - assert!(ma_type["domain"]["choices"].as_array().unwrap().iter().any(|v| v["label"] == "EMA" && v["value"] == 1)); - } - - fn frame(values: Vec>) -> Frame { - let start = DateTime::parse_from_rfc3339("2026-09-01T15:30:00+08:00").unwrap(); - let times = (0..values.len()) - .map(|i| start + chrono::Duration::days(i as i64)) - .collect::>(); - Frame { - symbol: "TEST".into(), - frequency: "1d".into(), - decision_at: *times.last().unwrap(), - available_at: times.clone(), - timestamps: times, - fields: BTreeMap::from([("close".into(), values)]), - } - } - fn expr(v: Value) -> Expr { - serde_json::from_value(v).unwrap() - } - #[test] - fn ta_sma_real_values_and_parameter_validation() { - let frame = frame(vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0)]); - let e = expr( - json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":3}}), - ); - assert_eq!( - evaluate(&e, &frame).unwrap().values, - vec![None, None, Some(2.0), Some(3.0)] - ); - let bad = expr( - json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"period":3}}), - ); - assert!( - evaluate(&bad, &frame) - .unwrap_err() - .contains("parameter_unknown") - ); - } - #[test] - fn cross_is_event_not_state_and_never_uses_future() { - let f = frame(vec![ - Some(9.0), - Some(10.0), - Some(11.0), - Some(12.0), - Some(8.0), - ]); - let e = expr( - json!({"kind":"operator","name":"CROSS_ABOVE","args":[{"kind":"field","name":"close"},{"kind":"number","value":10.0}]}), - ); - assert_eq!( - evaluate(&e, &f).unwrap().values, - vec![None, Some(0.0), Some(1.0), Some(0.0), Some(0.0)] - ); - let mut invalid = f.clone(); - invalid.available_at[4] = invalid.decision_at + chrono::Duration::seconds(1); - assert!(evaluate(&e, &invalid).is_err()); - } - #[test] - fn missing_is_not_zero_and_breakout_excludes_current() { - let f = frame(vec![Some(1.0), Some(2.0), Some(3.0), None, Some(5.0)]); - let e = expr( - json!({"kind":"operator","name":"BREAK_HIGH","window":2,"args":[{"kind":"field","name":"close"}]}), - ); - assert_eq!( - evaluate(&e, &f).unwrap().values, - vec![None, None, Some(1.0), None, None] - ); - let zero = expr( - json!({"kind":"operator","name":"DIV","args":[{"kind":"field","name":"close"},{"kind":"number","value":0}]}), - ); - assert!( - evaluate(&zero, &f) - .unwrap() - .values - .iter() - .all(Option::is_none) - ); - } - #[test] - fn ta_rewarms_after_gap_and_const_zscore_is_unknown() { - let f = frame(vec![Some(1.0), Some(1.0), None, Some(2.0), Some(2.0)]); - let e = expr( - json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":2}}), - ); - assert_eq!( - evaluate(&e, &f).unwrap().values, - vec![None, Some(1.0), None, None, Some(2.0)] - ); - let e = expr( - json!({"kind":"operator","name":"ZSCORE","window":2,"args":[{"kind":"field","name":"close"}]}), - ); - assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none)); - } - #[test] - fn no_event_has_no_bars_since_and_type_errors_reject() { - let f = frame(vec![Some(1.0), Some(1.0), Some(1.0)]); - let state = json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":5}]}); - let e = expr(json!({"kind":"operator","name":"BARS_SINCE","args":[state]})); - assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none)); - assert!( - evaluate( - &expr( - json!({"kind":"operator","name":"NOT","args":[{"kind":"field","name":"close"}]}) - ), - &f - ) - .is_err() - ); - } - #[test] - fn literal_unknown_fields_reject_and_catalog_is_not_trading_permission() { - assert!( - serde_json::from_value::(json!({"kind":"number","value":1,"account_id":2})) - .is_err() - ); - let c = catalog(); - assert!(c["indicators"].as_array().unwrap().len() > 190); - assert_eq!(c["live_routing"], false); - } -} +#[path = "factor_events_tests.rs"] +mod tests; diff --git a/crates/fidc-core/src/factor_events_tests.rs b/crates/fidc-core/src/factor_events_tests.rs new file mode 100644 index 0000000..edce099 --- /dev/null +++ b/crates/fidc-core/src/factor_events_tests.rs @@ -0,0 +1,161 @@ +use super::*; +use serde_json::json; +use crate::factor_event_catalog::parameter_domain; + +#[test] +fn every_parameter_domain_is_structured_and_matches_native_defaults() { + for function in abstract_api::funcs() { + let handle = abstract_api::get_func_handle(function.name).unwrap(); + let core = Core::new(); + let mut call = handle.new_call(&core); + for (index, parameter) in function.opt_inputs.iter().enumerate() { + let domain = parameter_domain(parameter.kind); + let default = domain["default"].as_f64().unwrap(); + assert!(default.is_finite(), "{} {}", function.name, parameter.param_name); + if let Some(choices) = domain.get("choices") { + assert!(choices.as_array().unwrap().iter().any(|v| v["value"].as_f64() == Some(default))); + } else { + assert!(default >= domain["minimum"].as_f64().unwrap()); + assert!(default <= domain["maximum"].as_f64().unwrap()); + } + if domain["value_type"] == "integer" { + assert_eq!(default.fract(), 0.0); + call.set_opt(index, default as i32).unwrap(); + } else { + call.set_opt(index, default).unwrap(); + } + } + assert!(call.lookback().is_ok(), "{}", function.name); + } +} + +#[test] +fn parameter_domains_keep_enumeration_labels_without_debug_string_parsing() { + let catalog = catalog(); + assert_eq!(catalog["parameter_domain_contract"], "fidc.indicator-parameter-domain/v1"); + let indicators = catalog["indicators"].as_array().unwrap(); + let rsi = indicators.iter().find(|v| v["name"] == "RSI").unwrap(); + assert_eq!(rsi["parameters"][0]["domain"]["minimum"], 2); + let stoch = indicators.iter().find(|v| v["name"] == "STOCH").unwrap(); + let ma_type = stoch["parameters"].as_array().unwrap().iter().find(|p| p["name"] == "optInSlowK_MAType").unwrap(); + assert!(ma_type["domain"]["choices"].as_array().unwrap().iter().any(|v| v["label"] == "EMA" && v["value"] == 1)); +} + +fn frame(values: Vec>) -> Frame { + let start = DateTime::parse_from_rfc3339("2026-09-01T15:30:00+08:00").unwrap(); + let times = (0..values.len()) + .map(|i| start + chrono::Duration::days(i as i64)) + .collect::>(); + Frame { + symbol: "TEST".into(), + frequency: "1d".into(), + decision_at: *times.last().unwrap(), + available_at: times.clone(), + timestamps: times, + fields: BTreeMap::from([("close".into(), values)]), + } +} +fn expr(v: Value) -> Expr { + serde_json::from_value(v).unwrap() +} +#[test] +fn ta_sma_real_values_and_parameter_validation() { + let frame = frame(vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0)]); + let e = expr( + json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":3}}), + ); + assert_eq!( + evaluate(&e, &frame).unwrap().values, + vec![None, None, Some(2.0), Some(3.0)] + ); + let bad = expr( + json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"period":3}}), + ); + assert!( + evaluate(&bad, &frame) + .unwrap_err() + .contains("parameter_unknown") + ); +} +#[test] +fn cross_is_event_not_state_and_never_uses_future() { + let f = frame(vec![ + Some(9.0), + Some(10.0), + Some(11.0), + Some(12.0), + Some(8.0), + ]); + let e = expr( + json!({"kind":"operator","name":"CROSS_ABOVE","args":[{"kind":"field","name":"close"},{"kind":"number","value":10.0}]}), + ); + assert_eq!( + evaluate(&e, &f).unwrap().values, + vec![None, Some(0.0), Some(1.0), Some(0.0), Some(0.0)] + ); + let mut invalid = f.clone(); + invalid.available_at[4] = invalid.decision_at + chrono::Duration::seconds(1); + assert!(evaluate(&e, &invalid).is_err()); +} +#[test] +fn missing_is_not_zero_and_breakout_excludes_current() { + let f = frame(vec![Some(1.0), Some(2.0), Some(3.0), None, Some(5.0)]); + let e = expr( + json!({"kind":"operator","name":"BREAK_HIGH","window":2,"args":[{"kind":"field","name":"close"}]}), + ); + assert_eq!( + evaluate(&e, &f).unwrap().values, + vec![None, None, Some(1.0), None, None] + ); + let zero = expr( + json!({"kind":"operator","name":"DIV","args":[{"kind":"field","name":"close"},{"kind":"number","value":0}]}), + ); + assert!( + evaluate(&zero, &f) + .unwrap() + .values + .iter() + .all(Option::is_none) + ); +} +#[test] +fn ta_rewarms_after_gap_and_const_zscore_is_unknown() { + let f = frame(vec![Some(1.0), Some(1.0), None, Some(2.0), Some(2.0)]); + let e = expr( + json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":2}}), + ); + assert_eq!( + evaluate(&e, &f).unwrap().values, + vec![None, Some(1.0), None, None, Some(2.0)] + ); + let e = expr( + json!({"kind":"operator","name":"ZSCORE","window":2,"args":[{"kind":"field","name":"close"}]}), + ); + assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none)); +} +#[test] +fn no_event_has_no_bars_since_and_type_errors_reject() { + let f = frame(vec![Some(1.0), Some(1.0), Some(1.0)]); + let state = json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":5}]}); + let e = expr(json!({"kind":"operator","name":"BARS_SINCE","args":[state]})); + assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none)); + assert!( + evaluate( + &expr( + json!({"kind":"operator","name":"NOT","args":[{"kind":"field","name":"close"}]}) + ), + &f + ) + .is_err() + ); +} +#[test] +fn literal_unknown_fields_reject_and_catalog_is_not_trading_permission() { + assert!( + serde_json::from_value::(json!({"kind":"number","value":1,"account_id":2})) + .is_err() + ); + let c = catalog(); + assert!(c["indicators"].as_array().unwrap().len() > 190); + assert_eq!(c["live_routing"], false); +} diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 7f65361..8ff5e39 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -6,6 +6,7 @@ pub mod daily_patterns; pub mod pattern_context; pub mod session_events; pub mod factor_events; +mod factor_event_catalog; pub mod factor_cross_section; pub mod market_event_context; pub mod engine;