Files
fidc-backtest-engine/crates/fidc-core/src/factor_events.rs
T

1048 lines
36 KiB
Rust

//! 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<Expr>,
#[serde(default)]
parameters: BTreeMap<String, Value>,
#[serde(default)]
output: usize,
},
Operator {
name: String,
args: Vec<Expr>,
#[serde(default)]
window: Option<usize>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Frame {
pub symbol: String,
pub frequency: String,
pub decision_at: DateTime<FixedOffset>,
pub timestamps: Vec<DateTime<FixedOffset>>,
pub available_at: Vec<DateTime<FixedOffset>>,
pub fields: BTreeMap<String, Vec<Option<f64>>>,
}
#[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<Option<f64>>,
}
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<Value> = abstract_api::funcs().map(|f| json!({
"name":f.name, "group":format!("{:?}",f.group), "description":f.hint,
"inputs":f.inputs.iter().map(|p|json!({"name":p.param_name,"kind":format!("{:?}",p.kind),"flags":p.flags.0})).collect::<Vec<_>>(),
"parameters":f.opt_inputs.iter().map(|p|json!({"name":p.param_name,"label":p.display_name,"description":p.hint,"domain":format!("{:?}",p.kind)})).collect::<Vec<_>>(),
"outputs":f.outputs.iter().enumerate().map(|(i,p)|json!({"index":i,"name":p.param_name,"kind":format!("{:?}",p.kind)})).collect::<Vec<_>>(),
"unstable_period":format!("{:?}",f.unst_id), "production_eligible":false,
})).collect();
json!({"contract":CONTRACT,"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<Series, String> {
frame.validate()?;
fn cost(expr: &Expr, depth: usize, nodes: &mut usize) -> Result<usize, String> {
*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<Series, String> {
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::<Result<Vec<_>, _>>()?;
operator(name, &args, *window, frame)
}
}
}
fn indicator(
name: &str,
inputs: &[Expr],
parameters: &BTreeMap<String, Value>,
output: usize,
frame: &Frame,
depth: usize,
) -> Result<Series, String> {
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::<Result<Vec<_>, _>>()?;
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::<Vec<_>>()
})
.collect::<Vec<_>>();
let mut float_out = (0..info.outputs.len())
.map(|_| vec![0.0; end - start])
.collect::<Vec<_>>();
let mut int_out = (0..info.outputs.len())
.map(|_| vec![0i32; end - start])
.collect::<Vec<_>>();
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::<f64>()
}
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::<f64>();
let den = (0..v.len()).map(|i| (i as f64 - x).powi(2)).sum::<f64>();
num / den
}
fn boolean(v: bool) -> Option<f64> {
Some(if v { 1.0 } else { 0.0 })
}
fn operator(
name: &str,
args: &[Series],
window: Option<usize>,
frame: &Frame,
) -> Result<Series, String> {
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<f64> = 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<Vec<f64>> {
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<Vec<f64>> =
args[0].values[i - n..=i].iter().copied().collect();
let indicator: Option<Vec<f64>> =
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::<Vec<_>>();
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::<f64>() / 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<Vec<f64>> =
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::<f64>()
/ n as f64;
if name == "ROLLING_COV" {
Some(cov)
} else {
let bv = b.iter().map(|b| (b - bm).powi(2)).sum::<f64>() / 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<Option<f64>>) -> Frame {
let start = DateTime::parse_from_rfc3339("2026-09-01T15:30:00+08:00").unwrap();
let times = (0..values.len())
.map(|i| start + chrono::Duration::days(i as i64))
.collect::<Vec<_>>();
Frame {
symbol: "TEST".into(),
frequency: "1d".into(),
decision_at: *times.last().unwrap(),
available_at: times.clone(),
timestamps: times,
fields: BTreeMap::from([("close".into(), values)]),
}
}
fn expr(v: Value) -> Expr {
serde_json::from_value(v).unwrap()
}
#[test]
fn ta_sma_real_values_and_parameter_validation() {
let frame = frame(vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0)]);
let e = expr(
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":3}}),
);
assert_eq!(
evaluate(&e, &frame).unwrap().values,
vec![None, None, Some(2.0), Some(3.0)]
);
let bad = expr(
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"period":3}}),
);
assert!(
evaluate(&bad, &frame)
.unwrap_err()
.contains("parameter_unknown")
);
}
#[test]
fn cross_is_event_not_state_and_never_uses_future() {
let f = frame(vec![
Some(9.0),
Some(10.0),
Some(11.0),
Some(12.0),
Some(8.0),
]);
let e = expr(
json!({"kind":"operator","name":"CROSS_ABOVE","args":[{"kind":"field","name":"close"},{"kind":"number","value":10.0}]}),
);
assert_eq!(
evaluate(&e, &f).unwrap().values,
vec![None, Some(0.0), Some(1.0), Some(0.0), Some(0.0)]
);
let mut invalid = f.clone();
invalid.available_at[4] = invalid.decision_at + chrono::Duration::seconds(1);
assert!(evaluate(&e, &invalid).is_err());
}
#[test]
fn missing_is_not_zero_and_breakout_excludes_current() {
let f = frame(vec![Some(1.0), Some(2.0), Some(3.0), None, Some(5.0)]);
let e = expr(
json!({"kind":"operator","name":"BREAK_HIGH","window":2,"args":[{"kind":"field","name":"close"}]}),
);
assert_eq!(
evaluate(&e, &f).unwrap().values,
vec![None, None, Some(1.0), None, None]
);
let zero = expr(
json!({"kind":"operator","name":"DIV","args":[{"kind":"field","name":"close"},{"kind":"number","value":0}]}),
);
assert!(
evaluate(&zero, &f)
.unwrap()
.values
.iter()
.all(Option::is_none)
);
}
#[test]
fn ta_rewarms_after_gap_and_const_zscore_is_unknown() {
let f = frame(vec![Some(1.0), Some(1.0), None, Some(2.0), Some(2.0)]);
let e = expr(
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":2}}),
);
assert_eq!(
evaluate(&e, &f).unwrap().values,
vec![None, Some(1.0), None, None, Some(2.0)]
);
let e = expr(
json!({"kind":"operator","name":"ZSCORE","window":2,"args":[{"kind":"field","name":"close"}]}),
);
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
}
#[test]
fn no_event_has_no_bars_since_and_type_errors_reject() {
let f = frame(vec![Some(1.0), Some(1.0), Some(1.0)]);
let state = json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":5}]});
let e = expr(json!({"kind":"operator","name":"BARS_SINCE","args":[state]}));
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
assert!(
evaluate(
&expr(
json!({"kind":"operator","name":"NOT","args":[{"kind":"field","name":"close"}]})
),
&f
)
.is_err()
);
}
#[test]
fn literal_unknown_fields_reject_and_catalog_is_not_trading_permission() {
assert!(
serde_json::from_value::<Expr>(json!({"kind":"number","value":1,"account_id":2}))
.is_err()
);
let c = catalog();
assert!(c["indicators"].as_array().unwrap().len() > 190);
assert_eq!(c["live_routing"], false);
}
}