//! Causal, typed indicator/event expressions shared by research and trading. use chrono::{DateTime, FixedOffset}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::collections::BTreeMap; use ta_lib::{ Core, abstract_api::{self, InputType, OptInputType, OutputType}, }; pub const CONTRACT: &str = "fidc_factor_event_expression_v1"; pub const TA_REV: &str = "dd5a90259a3f9e04e2da9f38bf0719a841b40108"; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum Expr { Number { value: f64, }, Field { name: String, }, Indicator { name: String, #[serde(default)] inputs: Vec, #[serde(default)] parameters: BTreeMap, #[serde(default)] output: usize, }, Operator { name: String, args: Vec, #[serde(default)] window: Option, }, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Frame { pub symbol: String, pub frequency: String, pub decision_at: DateTime, pub timestamps: Vec>, pub available_at: Vec>, pub fields: BTreeMap>>, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum ValueType { Number, Boolean, } #[derive(Debug, Clone, Serialize)] pub struct Series { pub value_type: ValueType, pub values: Vec>, } const OPERATORS: &[&str] = &[ "GT", "GTE", "LT", "LTE", "EQ", "NEQ", "BETWEEN", "OUTSIDE", "CROSS_ABOVE", "CROSS_BELOW", "BREAK_ABOVE", "BREAK_BELOW", "BREAK_HIGH", "BREAK_LOW", "CHANGE", "DIFF", "DELTA", "PCT_CHANGE", "LOG_RETURN", "RISING", "FALLING", "NON_DECREASING", "NON_INCREASING", "TURN_UP", "TURN_DOWN", "BOTTOM_REVERSAL", "TOP_REVERSAL", "SLOPE", "SLOPE_CHANGE", "ACCELERATION", "HHV", "LLV", "ARGMAX", "ARGMIN", "DISTANCE_TO_HIGH", "DISTANCE_TO_LOW", "NEW_HIGH", "NEW_LOW", "NEAR_HIGH", "NEAR_LOW", "BULLISH_DIVERGENCE", "BEARISH_DIVERGENCE", "ZSCORE", "MINMAX", "STANDARDIZE", "NORMALIZE", "COUNT", "COUNT_TRUE", "CONSECUTIVE", "BARS_SINCE", "DURATION", "DAYS_SINCE", "TIME_SINCE", "REF", "LAG", "PREV", "SHIFT", "ROLLING_MEAN", "ROLLING_SUM", "ROLLING_STD", "ROLLING_MAX", "ROLLING_MIN", "ROLLING_MEDIAN", "ROLLING_CORR", "ROLLING_COV", "AND", "OR", "NOT", "XOR", "ADD", "SUB", "MUL", "DIV", "ABS", "MAX", "MIN", "LOG", "SQRT", "POWER", "CUMMAX", "CUMMIN", "SIGN", "IF", ]; pub fn catalog() -> Value { 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":format!("{:?}",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,"library":{"name":"TA-Lib native Rust","revision":TA_REV,"license":"BSD-3-Clause"}, "indicators":indicators,"operators":OPERATORS,"cross_section_operators":crate::factor_cross_section::OPERATORS,"read_only":true,"live_routing":false, "policies":{"null":"unknown_not_false","warmup":"null_until_full_history","recursive_seed":"frozen_input_start", "breakout":"previous_window_excludes_current","boolean":"three_valued_logic","daily_execution":"next_completed_session", "minute_execution":"strictly_after_completed_bar","cross_section":"requires_separate_complete_universe_contract"}}) } impl Frame { pub fn validate(&self) -> Result<(), String> { let n = self.timestamps.len(); if self.symbol.is_empty() || n == 0 || n > 200_000 || self.available_at.len() != n || self.fields.len() > 100 || n.saturating_mul(self.fields.len()) > 1_000_000 { return Err("factor_frame_invalid: identity/shape/limit".into()); } if !["1d", "1w", "1m", "5m", "15m", "30m", "60m"].contains(&self.frequency.as_str()) { return Err("factor_frame_invalid: unsupported_frequency".into()); } for i in 0..n { if (i > 0 && self.timestamps[i] <= self.timestamps[i - 1]) || self.available_at[i] < self.timestamps[i] || self.available_at[i] > self.decision_at { return Err(format!( "factor_input_not_visible: {} index={i}", self.symbol )); } } for (field, values) in &self.fields { if values.len() != n || values.iter().flatten().any(|v| !v.is_finite()) { return Err(format!("factor_field_invalid: {} {field}", self.symbol)); } } Ok(()) } } pub fn evaluate(expr: &Expr, frame: &Frame) -> Result { frame.validate()?; fn cost(expr: &Expr, depth: usize, nodes: &mut usize) -> Result { *nodes += 1; if depth > 24 || *nodes > 256 { return Err("factor_expression_size_exceeded".into()); } let (children, own) = match expr { Expr::Indicator { inputs, parameters, .. } => ( inputs.as_slice(), parameters .values() .filter_map(Value::as_u64) .max() .unwrap_or(30) .min(1_000_000) as usize, ), Expr::Operator { args, window, .. } => (args.as_slice(), window.unwrap_or(1)), _ => (&[][..], 1), }; children.iter().try_fold(own, |total, child| { Ok(total.saturating_add(cost(child, depth + 1, nodes)?)) }) } if frame .timestamps .len() .saturating_mul(cost(expr, 0, &mut 0)?) > 20_000_000 { return Err("factor_expression_compute_budget_exceeded".into()); } evaluate_inner(expr, frame, 0) } fn evaluate_inner(expr: &Expr, frame: &Frame, depth: usize) -> Result { if depth > 24 { return Err("factor_expression_too_deep".into()); } match expr { Expr::Number { value } if value.is_finite() => Ok(Series { value_type: ValueType::Number, values: vec![Some(*value); frame.timestamps.len()], }), Expr::Number { .. } => Err("factor_constant_nonfinite".into()), Expr::Field { name } => Ok(Series { value_type: ValueType::Number, values: frame .fields .get(name) .ok_or_else(|| format!("factor_source_field_missing: {} {name}", frame.symbol))? .clone(), }), Expr::Indicator { name, inputs, parameters, output, } => indicator(name, inputs, parameters, *output, frame, depth), Expr::Operator { name, args, window } => { if args.len() > 16 { return Err("factor_operator_arity_exceeded".into()); } let args = args .iter() .map(|a| evaluate_inner(a, frame, depth + 1)) .collect::, _>>()?; operator(name, &args, *window, frame) } } } fn indicator( name: &str, inputs: &[Expr], parameters: &BTreeMap, output: usize, frame: &Frame, depth: usize, ) -> Result { let id = abstract_api::get_func_handle(name).ok_or_else(|| format!("indicator_unknown: {name}"))?; let info = id.info(); if output >= info.outputs.len() { return Err("indicator_output_invalid".into()); } let real_count = info .inputs .iter() .filter(|i| i.kind == InputType::Real) .count(); if inputs.len() != real_count || info.inputs.iter().any(|i| i.kind == InputType::Integer) { return Err(format!( "indicator_inputs_invalid: {name} expects {real_count} real series" )); } let mut data = inputs .iter() .map(|a| evaluate_inner(a, frame, depth + 1)) .collect::, _>>()?; if data.iter().any(|s| s.value_type != ValueType::Number) { return Err("indicator_requires_numeric_input".into()); } let price_names = ["open", "high", "low", "close", "volume", "open_interest"]; let flags = info .inputs .iter() .filter(|i| i.kind == InputType::Price) .fold(0, |v, i| v | i.flags.0); let mut price_indices = [None; 6]; for (i, field) in price_names.iter().enumerate() { if flags & (1 << i) != 0 { price_indices[i] = Some(data.len()); data.push(evaluate_inner( &Expr::Field { name: (*field).into(), }, frame, depth + 1, )?); } } let core = Core::new(); let mut validation = id.new_call(&core); for (key, v) in parameters { let slot = info .opt_inputs .iter() .position(|p| p.param_name == key) .ok_or_else(|| format!("indicator_parameter_unknown: {name}.{key}"))?; match info.opt_inputs[slot].kind { OptInputType::IntegerRange { .. } | OptInputType::IntegerList { .. } => { let v = v .as_i64() .and_then(|v| i32::try_from(v).ok()) .ok_or("indicator_parameter_requires_integer")?; validation.set_opt(slot, v).map_err(|e| format!("{e:?}"))?; } _ => { validation .set_opt( slot, v.as_f64() .filter(|v| v.is_finite()) .ok_or("indicator_parameter_requires_finite_number")?, ) .map_err(|e| format!("{e:?}"))?; } } } let lookback = validation .lookback() .map_err(|e| format!("indicator_parameter_invalid: {name} {e:?}"))?; let n = frame.timestamps.len(); let mut result = vec![None; n]; let mut start = 0; // Never bridge missing source observations. Recursive indicators rewarm after a gap. while start < n { if data.iter().any(|s| s.values[start].is_none()) { start += 1; continue; } let mut end = start + 1; while end < n && data.iter().all(|s| s.values[end].is_some()) { end += 1; } if end - start <= lookback { start = end; continue; } let arrays = data .iter() .map(|s| { s.values[start..end] .iter() .map(|v| v.unwrap()) .collect::>() }) .collect::>(); let mut float_out = (0..info.outputs.len()) .map(|_| vec![0.0; end - start]) .collect::>(); let mut int_out = (0..info.outputs.len()) .map(|_| vec![0i32; end - start]) .collect::>(); let mut call = id.new_call(&core); for (key, v) in parameters { let slot = info .opt_inputs .iter() .position(|p| p.param_name == key) .unwrap(); match info.opt_inputs[slot].kind { OptInputType::IntegerRange { .. } | OptInputType::IntegerList { .. } => { call.set_opt(slot, v.as_i64().unwrap() as i32) .map_err(|e| format!("{e:?}"))?; } _ => { call.set_opt(slot, v.as_f64().unwrap()) .map_err(|e| format!("{e:?}"))?; } } } let mut real_slot = 0; for (slot, i) in info.inputs.iter().enumerate() { if i.kind == InputType::Real { call.set_input(slot, &arrays[real_slot]) .map_err(|e| format!("{e:?}"))?; real_slot += 1; } else { let p = price_indices.map(|i| i.map(|i| arrays[i].as_slice())); call.set_price_input(slot, p[0], p[1], p[2], p[3], p[4], p[5]) .map_err(|e| format!("{e:?}"))?; } } for (slot, (floats, ints)) in float_out.iter_mut().zip(int_out.iter_mut()).enumerate() { if info.outputs[slot].kind == OutputType::Real { call.set_output(slot, floats) .map_err(|e| format!("{e:?}"))?; } else { call.set_int_output(slot, ints) .map_err(|e| format!("{e:?}"))?; } } let range = call .call(0, end - start - 1) .map_err(|e| format!("indicator_failed: {name} {e:?}"))?; drop(call); for j in 0..range.count { let value = if info.outputs[output].kind == OutputType::Real { float_out[output][j] } else { int_out[output][j] as f64 }; if !value.is_finite() { return Err(format!( "indicator_nonfinite: {name} index={}", start + range.beg_idx + j )); } result[start + range.beg_idx + j] = Some(value); } start = end; } Ok(Series { value_type: ValueType::Number, values: result, }) } fn average(v: &[f64]) -> f64 { v[0] + v .iter() .skip(1) .map(|x| (x - v[0]) / v.len() as f64) .sum::() } fn slope(v: &[f64]) -> f64 { let x = (v.len() - 1) as f64 / 2.0; let y = average(v); let num = v .iter() .enumerate() .map(|(i, v)| (i as f64 - x) * (v - y)) .sum::(); let den = (0..v.len()).map(|i| (i as f64 - x).powi(2)).sum::(); num / den } fn boolean(v: bool) -> Option { Some(if v { 1.0 } else { 0.0 }) } fn operator( name: &str, args: &[Series], window: Option, frame: &Frame, ) -> Result { if !OPERATORS.contains(&name) { return Err(format!("operator_not_registered: {name}")); } let bool_input = matches!( name, "AND" | "OR" | "NOT" | "XOR" | "COUNT" | "COUNT_TRUE" | "CONSECUTIVE" | "BARS_SINCE" | "DURATION" | "DAYS_SINCE" | "TIME_SINCE" ); let lag = matches!(name, "REF" | "LAG" | "PREV" | "SHIFT"); if args.is_empty() || (name == "IF" && (args.len() != 3 || args[0].value_type != ValueType::Boolean || args[1].value_type != args[2].value_type)) || (!lag && name != "IF" && args .iter() .any(|a| (a.value_type == ValueType::Boolean) != bool_input)) { return Err(format!("operator_input_type_invalid: {name}")); } let arity = match name { "BETWEEN" | "OUTSIDE" | "IF" => 3, "GT" | "GTE" | "LT" | "LTE" | "EQ" | "NEQ" | "CROSS_ABOVE" | "CROSS_BELOW" | "BREAK_ABOVE" | "BREAK_BELOW" | "ADD" | "SUB" | "MUL" | "DIV" | "MAX" | "MIN" | "POWER" | "XOR" | "ROLLING_CORR" | "ROLLING_COV" | "NEAR_HIGH" | "NEAR_LOW" | "BULLISH_DIVERGENCE" | "BEARISH_DIVERGENCE" => 2, "AND" | "OR" => args.len(), _ => 1, }; if args.len() != arity { return Err(format!("operator_arity_invalid: {name}")); } let windowed = matches!( name, "BREAK_HIGH" | "BREAK_LOW" | "RISING" | "FALLING" | "NON_DECREASING" | "NON_INCREASING" | "SLOPE" | "SLOPE_CHANGE" | "HHV" | "LLV" | "ARGMAX" | "ARGMIN" | "DISTANCE_TO_HIGH" | "DISTANCE_TO_LOW" | "NEW_HIGH" | "NEW_LOW" | "NEAR_HIGH" | "NEAR_LOW" | "BULLISH_DIVERGENCE" | "BEARISH_DIVERGENCE" | "ZSCORE" | "STANDARDIZE" | "MINMAX" | "NORMALIZE" | "COUNT" | "COUNT_TRUE" ) || name.starts_with("ROLLING_"); let n = window.unwrap_or(1); if n == 0 || n > 10_000 || (windowed && window.is_none()) || (matches!( name, "SLOPE" | "SLOPE_CHANGE" | "ZSCORE" | "STANDARDIZE" | "ROLLING_STD" | "ROLLING_CORR" | "ROLLING_COV" ) && n < 2) { return Err(format!("operator_window_invalid: {name}")); } let returns_bool = matches!( name, "GT" | "GTE" | "LT" | "LTE" | "EQ" | "NEQ" | "BETWEEN" | "OUTSIDE" | "CROSS_ABOVE" | "CROSS_BELOW" | "BREAK_ABOVE" | "BREAK_BELOW" | "BREAK_HIGH" | "BREAK_LOW" | "RISING" | "FALLING" | "NON_DECREASING" | "NON_INCREASING" | "TURN_UP" | "TURN_DOWN" | "BOTTOM_REVERSAL" | "TOP_REVERSAL" | "NEW_HIGH" | "NEW_LOW" | "NEAR_HIGH" | "NEAR_LOW" | "BULLISH_DIVERGENCE" | "BEARISH_DIVERGENCE" | "AND" | "OR" | "NOT" | "XOR" ); let len = frame.timestamps.len(); let mut out = vec![None; len]; let mut last_true = None; let mut consecutive = Some(0usize); let mut extreme: Option = None; let mut cumulative_complete = true; for i in 0..len { let a = args[0].values[i]; let b = args.get(1).and_then(|a| a.values[i]); let at = |j: usize| args[0].values.get(j).copied().flatten(); let history = |end: usize, count: usize| -> Option> { if end < count { None } else { args[0].values[end - count..end].iter().copied().collect() } }; out[i] = match name { "IF" => a.and_then(|a| { if a == 1.0 { args[1].values[i] } else { args[2].values[i] } }), "SIGN" => a.map(|v| { if v == 0.0 { 0.0 } else if v > 0.0 { 1.0 } else { -1.0 } }), "CUMMAX" | "CUMMIN" => { cumulative_complete &= a.is_some(); extreme = a.filter(|_| cumulative_complete).map(|v| { extreme.map_or(v, |p| if name == "CUMMAX" { p.max(v) } else { p.min(v) }) }); extreme } "AND" => { if args.iter().any(|a| a.values[i] == Some(0.0)) { Some(0.0) } else if args.iter().any(|a| a.values[i].is_none()) { None } else { Some(1.0) } } "OR" => { if args.iter().any(|a| a.values[i] == Some(1.0)) { Some(1.0) } else if args.iter().any(|a| a.values[i].is_none()) { None } else { Some(0.0) } } "NOT" => a.map(|v| 1.0 - v), "XOR" => a.zip(b).and_then(|(a, b)| boolean(a != b)), "GT" | "GTE" | "LT" | "LTE" | "EQ" | "NEQ" => a.zip(b).and_then(|(a, b)| { boolean(match name { "GT" => a > b, "GTE" => a >= b, "LT" => a < b, "LTE" => a <= b, "EQ" => a == b, _ => a != b, }) }), "BETWEEN" | "OUTSIDE" => a.zip(b).zip(args[2].values[i]).and_then(|((a, b), c)| { if b > c { None } else { boolean((a >= b && a <= c) == (name == "BETWEEN")) } }), "CROSS_ABOVE" | "CROSS_BELOW" | "BREAK_ABOVE" | "BREAK_BELOW" => { if i == 0 { None } else { a.zip(b).zip(at(i - 1).zip(args[1].values[i - 1])).and_then( |((a, b), (p, q))| { boolean(if name.ends_with("ABOVE") { p <= q && a > b } else { p >= q && a < b }) }, ) } } "REF" | "LAG" | "PREV" | "SHIFT" => i.checked_sub(n).and_then(at), "CHANGE" | "DIFF" | "DELTA" | "PCT_CHANGE" | "LOG_RETURN" => a .zip(i.checked_sub(n).and_then(at)) .and_then(|(a, p)| match name { "PCT_CHANGE" => { if p == 0.0 { None } else { Some(a / p - 1.0) } } "LOG_RETURN" => { if a <= 0.0 || p <= 0.0 { None } else { Some((a / p).ln()) } } _ => Some(a - p), }), "ACCELERATION" => a .zip(i.checked_sub(n).and_then(at)) .zip(i.checked_sub(n * 2).and_then(at)) .map(|((a, p), q)| a - 2.0 * p + q), "BULLISH_DIVERGENCE" | "BEARISH_DIVERGENCE" => { if i < n || n < 4 { None } else { let price: Option> = args[0].values[i - n..=i].iter().copied().collect(); let indicator: Option> = args[1].values[i - n..=i].iter().copied().collect(); price.zip(indicator).and_then(|(price, indicator)| { let low = name == "BULLISH_DIVERGENCE"; let pivots = (1..n) .filter(|&j| { if low { price[j] < price[j - 1] && price[j] < price[j + 1] } else { price[j] > price[j - 1] && price[j] > price[j + 1] } }) .collect::>(); if pivots.last() != Some(&(n - 1)) || pivots.len() < 2 { return boolean(false); } let a = pivots[pivots.len() - 2]; let b = n - 1; boolean(if low { price[b] < price[a] && indicator[b] > indicator[a] } else { price[b] > price[a] && indicator[b] < indicator[a] }) }) } } "TURN_UP" | "TURN_DOWN" | "BOTTOM_REVERSAL" | "TOP_REVERSAL" => { if i < 2 { None } else { a.zip(at(i - 1)).zip(at(i - 2)).and_then(|((a, p), q)| { if name == "ACCELERATION" { Some(a - 2.0 * p + q) } else { boolean(if matches!(name, "TURN_UP" | "BOTTOM_REVERSAL") { p < q && a > p } else { p > q && a < p }) } }) } } "ABS" => a.map(f64::abs), "LOG" => a.filter(|v| *v > 0.0).map(f64::ln), "SQRT" => a.filter(|v| *v >= 0.0).map(f64::sqrt), "ADD" => a.zip(b).map(|(a, b)| a + b), "SUB" => a.zip(b).map(|(a, b)| a - b), "MUL" => a.zip(b).map(|(a, b)| a * b), "DIV" => a.zip(b).filter(|(_, b)| *b != 0.0).map(|(a, b)| a / b), "MAX" => a.zip(b).map(|(a, b)| a.max(b)), "MIN" => a.zip(b).map(|(a, b)| a.min(b)), "POWER" => a.zip(b).map(|(a, b)| a.powf(b)), "BARS_SINCE" | "DAYS_SINCE" | "TIME_SINCE" => { if a == Some(1.0) { last_true = Some(i); } if a.is_none() { last_true = None; } last_true.map(|t| { if name == "BARS_SINCE" { (i - t) as f64 } else { let secs = (frame.timestamps[i] - frame.timestamps[t]).num_seconds() as f64; if name == "DAYS_SINCE" { secs / 86400.0 } else { secs } } }) } "CONSECUTIVE" | "DURATION" => { consecutive = match a { Some(1.0) => consecutive.map(|v| v + 1), Some(_) => Some(0), None => None, }; consecutive.map(|v| v as f64) } "BREAK_HIGH" | "NEW_HIGH" | "BREAK_LOW" | "NEW_LOW" => { a.zip(history(i, n)).and_then(|(a, v)| { boolean(if matches!(name, "BREAK_HIGH" | "NEW_HIGH") { a > v.into_iter().fold(f64::NEG_INFINITY, f64::max) } else { a < v.into_iter().fold(f64::INFINITY, f64::min) }) }) } "RISING" | "FALLING" | "NON_DECREASING" | "NON_INCREASING" => history(i + 1, n + 1) .and_then(|v| { boolean(v.windows(2).all(|p| match name { "RISING" => p[1] > p[0], "FALLING" => p[1] < p[0], "NON_DECREASING" => p[1] >= p[0], _ => p[1] <= p[0], })) }), "SLOPE_CHANGE" => history(i + 1, n) .zip(history(i, n)) .map(|(a, b)| slope(&a) - slope(&b)), _ => history(i + 1, n).and_then(|mut v| { let mean = average(&v); let lo = v.iter().copied().fold(f64::INFINITY, f64::min); let hi = v.iter().copied().fold(f64::NEG_INFINITY, f64::max); let variance = v.iter().map(|v| (v - mean).powi(2)).sum::() / n as f64; match name { "HHV" | "ROLLING_MAX" => Some(hi), "LLV" | "ROLLING_MIN" => Some(lo), "ARGMAX" => v.iter().rposition(|x| *x == hi).map(|p| (n - 1 - p) as f64), "ARGMIN" => v.iter().rposition(|x| *x == lo).map(|p| (n - 1 - p) as f64), "DISTANCE_TO_HIGH" => { if hi == 0.0 { None } else { Some(v[n - 1] / hi - 1.0) } } "DISTANCE_TO_LOW" => { if lo == 0.0 { None } else { Some(v[n - 1] / lo - 1.0) } } "NEAR_HIGH" | "NEAR_LOW" => b.filter(|b| *b >= 0.0).and_then(|b| { let base = if name == "NEAR_HIGH" { hi } else { lo }; if base == 0.0 { None } else { boolean((v[n - 1] / base - 1.0).abs() <= b) } }), "ZSCORE" | "STANDARDIZE" => { if variance == 0.0 { None } else { Some((v[n - 1] - mean) / variance.sqrt()) } } "MINMAX" | "NORMALIZE" => { if hi == lo { None } else { Some((v[n - 1] - lo) / (hi - lo)) } } "ROLLING_MEAN" => Some(mean), "ROLLING_SUM" | "COUNT" | "COUNT_TRUE" => Some(v.iter().sum()), "ROLLING_STD" => Some(variance.sqrt()), "ROLLING_MEDIAN" => { v.sort_by(f64::total_cmp); Some(if n % 2 == 1 { v[n / 2] } else { (v[n / 2 - 1] + v[n / 2]) / 2.0 }) } "SLOPE" => Some(slope(&v)), "ROLLING_CORR" | "ROLLING_COV" => { let b: Option> = args[1].values[i + 1 - n..=i].iter().copied().collect(); b.and_then(|b| { let bm = average(&b); let cov = v .iter() .zip(&b) .map(|(a, b)| (a - mean) * (b - bm)) .sum::() / n as f64; if name == "ROLLING_COV" { Some(cov) } else { let bv = b.iter().map(|b| (b - bm).powi(2)).sum::() / n as f64; let d = (variance * bv).sqrt(); if d == 0.0 { None } else { Some(cov / d) } } }) } _ => None, } }), } .filter(|v| v.is_finite()); } Ok(Series { value_type: if name == "IF" { args[1].value_type } else if lag { args[0].value_type } else if returns_bool { ValueType::Boolean } else { ValueType::Number }, values: out, }) } #[cfg(test)] mod tests { use super::*; 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); } }