#[derive(Debug, Clone, Copy)]
pub enum Field {
Expr(&'static str),
ExprOpt(&'static str),
ExprList(&'static str),
Num(&'static str),
NumOpt(&'static str),
BoolOpt(&'static str),
Str(&'static str),
StrOpt(&'static str),
StrListOpt(&'static str),
}
pub fn field_name(f: &Field) -> &'static str {
match f {
Field::Expr(n)
| Field::ExprOpt(n)
| Field::ExprList(n)
| Field::Num(n)
| Field::NumOpt(n)
| Field::BoolOpt(n)
| Field::Str(n)
| Field::StrOpt(n)
| Field::StrListOpt(n) => n,
}
}
pub fn field_kind(f: &Field) -> &'static str {
match f {
Field::Expr(_) | Field::ExprOpt(_) => "expr",
Field::ExprList(_) => "list",
Field::Num(_) | Field::NumOpt(_) => "number",
Field::BoolOpt(_) => "bool",
Field::Str(_) | Field::StrOpt(_) => "string",
Field::StrListOpt(_) => "list-string",
}
}
pub fn field_required(f: &Field) -> bool {
matches!(
f,
Field::Expr(_) | Field::Num(_) | Field::Str(_) | Field::ExprList(_)
)
}
pub struct OpSig {
pub tag: &'static str,
pub fields: &'static [Field],
}
struct Row {
names: &'static [&'static str],
sig: OpSig,
desc: &'static str,
}
use Field::*;
static ROWS: &[Row] = &[
Row {
names: &["sma", "average"],
sig: OpSig {
tag: "Average",
fields: &[Expr("of"), Num("n")],
},
desc: "Simple moving average of `of` over `n` days.",
},
Row {
names: &["ema"],
sig: OpSig {
tag: "Ema",
fields: &[Expr("of"), Num("n")],
},
desc: "Exponential moving average of `of` over `n` days.",
},
Row {
names: &["std"],
sig: OpSig {
tag: "Std",
fields: &[Expr("of"), Num("n")],
},
desc: "Rolling standard deviation of `of` over `n` days.",
},
Row {
names: &["rsi"],
sig: OpSig {
tag: "Rsi",
fields: &[Expr("of"), Num("n")],
},
desc: "Relative Strength Index of `of` over `n` days.",
},
Row {
names: &["pct_change"],
sig: OpSig {
tag: "PctChange",
fields: &[Expr("of"), Num("n")],
},
desc: "Percentage change of `of` over `n` days.",
},
Row {
names: &["rise"],
sig: OpSig {
tag: "Rise",
fields: &[Expr("of"), Num("n")],
},
desc: "1 where `of` rose for `n` consecutive days, else 0.",
},
Row {
names: &["fall"],
sig: OpSig {
tag: "Fall",
fields: &[Expr("of"), Num("n")],
},
desc: "1 where `of` fell for `n` consecutive days, else 0.",
},
Row {
names: &["shift"],
sig: OpSig {
tag: "Shift",
fields: &[Expr("of"), Num("n")],
},
desc: "`of` lagged forward by `n` days.",
},
Row {
names: &["rolling_max"],
sig: OpSig {
tag: "RollingMax",
fields: &[Expr("of"), Num("n")],
},
desc: "Rolling maximum of `of` over `n` days.",
},
Row {
names: &["rolling_min"],
sig: OpSig {
tag: "RollingMin",
fields: &[Expr("of"), Num("n")],
},
desc: "Rolling minimum of `of` over `n` days.",
},
Row {
names: &["bollinger_mid"],
sig: OpSig {
tag: "BollingerMid",
fields: &[Expr("of"), Num("n")],
},
desc: "Bollinger mid band: the `n`-day simple moving average of `of`.",
},
Row {
names: &["bollinger_upper"],
sig: OpSig {
tag: "BollingerUpper",
fields: &[Expr("of"), Num("n"), NumOpt("k")],
},
desc: "Bollinger upper band: sma(of, n) + k * std(of, n) (k defaults to 2).",
},
Row {
names: &["bollinger_lower"],
sig: OpSig {
tag: "BollingerLower",
fields: &[Expr("of"), Num("n"), NumOpt("k")],
},
desc: "Bollinger lower band: sma(of, n) - k * std(of, n) (k defaults to 2).",
},
Row {
names: &["macd"],
sig: OpSig {
tag: "Macd",
fields: &[Expr("of"), NumOpt("fast"), NumOpt("slow")],
},
desc: "MACD line: ema(of, fast) - ema(of, slow) (fast/slow default 12/26).",
},
Row {
names: &["macd_signal"],
sig: OpSig {
tag: "MacdSignal",
fields: &[Expr("of"), NumOpt("fast"), NumOpt("slow"), NumOpt("signal")],
},
desc: "MACD signal line: `signal`-day EMA of the MACD line (defaults 12/26/9).",
},
Row {
names: &["macd_hist"],
sig: OpSig {
tag: "MacdHist",
fields: &[Expr("of"), NumOpt("fast"), NumOpt("slow"), NumOpt("signal")],
},
desc: "MACD histogram: MACD line minus its signal line (defaults 12/26/9).",
},
Row {
names: &["donchian_high"],
sig: OpSig {
tag: "DonchianHigh",
fields: &[Expr("of"), Num("n")],
},
desc: "Donchian channel upper band: rolling `n`-day high of `of`.",
},
Row {
names: &["donchian_low"],
sig: OpSig {
tag: "DonchianLow",
fields: &[Expr("of"), Num("n")],
},
desc: "Donchian channel lower band: rolling `n`-day low of `of`.",
},
Row {
names: &["donchian_mid"],
sig: OpSig {
tag: "DonchianMid",
fields: &[Expr("of"), Num("n")],
},
desc: "Donchian channel mid-line: (rolling_max + rolling_min) / 2 over `n` days.",
},
Row {
names: &["atr"],
sig: OpSig {
tag: "Atr",
fields: &[Expr("high"), Expr("low"), Expr("close"), Num("n")],
},
desc: "Average True Range over `n` days from high/low/close.",
},
Row {
names: &["natr"],
sig: OpSig {
tag: "Natr",
fields: &[Expr("high"), Expr("low"), Expr("close"), Num("n")],
},
desc: "Normalized ATR (percent) over `n` days.",
},
Row {
names: &["willr"],
sig: OpSig {
tag: "WillR",
fields: &[Expr("high"), Expr("low"), Expr("close"), Num("n")],
},
desc: "Williams %R over `n` days.",
},
Row {
names: &["cci"],
sig: OpSig {
tag: "Cci",
fields: &[Expr("high"), Expr("low"), Expr("close"), Num("n")],
},
desc: "Commodity Channel Index over `n` days.",
},
Row {
names: &["stoch_k"],
sig: OpSig {
tag: "StochK",
fields: &[Expr("high"), Expr("low"), Expr("close"), Num("n")],
},
desc: "Stochastic %K over `n` days.",
},
Row {
names: &["stoch_d"],
sig: OpSig {
tag: "StochD",
fields: &[
Expr("high"),
Expr("low"),
Expr("close"),
Num("n"),
NumOpt("d"),
],
},
desc: "Stochastic %D: `d`-day average of %K over `n` days (d defaults to 3).",
},
Row {
names: &["aroon_up"],
sig: OpSig {
tag: "AroonUp",
fields: &[Expr("high"), Num("n")],
},
desc: "Aroon Up over `n` days from high.",
},
Row {
names: &["aroon_down"],
sig: OpSig {
tag: "AroonDown",
fields: &[Expr("low"), Num("n")],
},
desc: "Aroon Down over `n` days from low.",
},
Row {
names: &["adx"],
sig: OpSig {
tag: "Adx",
fields: &[Expr("high"), Expr("low"), Expr("close"), Num("n")],
},
desc: "Average Directional Index over `n` days.",
},
Row {
names: &["plus_di"],
sig: OpSig {
tag: "PlusDi",
fields: &[Expr("high"), Expr("low"), Expr("close"), Num("n")],
},
desc: "Plus Directional Indicator (+DI) over `n` days.",
},
Row {
names: &["minus_di"],
sig: OpSig {
tag: "MinusDi",
fields: &[Expr("high"), Expr("low"), Expr("close"), Num("n")],
},
desc: "Minus Directional Indicator (-DI) over `n` days.",
},
Row {
names: &["obv"],
sig: OpSig {
tag: "Obv",
fields: &[Expr("close"), Expr("volume")],
},
desc: "On-Balance Volume from close and volume.",
},
Row {
names: &["mfi"],
sig: OpSig {
tag: "Mfi",
fields: &[
Expr("high"),
Expr("low"),
Expr("close"),
Expr("volume"),
Num("n"),
],
},
desc: "Money Flow Index over `n` days.",
},
Row {
names: &["vwap"],
sig: OpSig {
tag: "Vwap",
fields: &[
Expr("high"),
Expr("low"),
Expr("close"),
Expr("volume"),
Num("n"),
],
},
desc: "Volume-Weighted Average Price over `n` days from high/low/close/volume.",
},
Row {
names: &["is_largest"],
sig: OpSig {
tag: "IsLargest",
fields: &[Expr("of"), Num("n")],
},
desc: "1 for the `n` highest values per row (cross-section), else 0.",
},
Row {
names: &["is_smallest"],
sig: OpSig {
tag: "IsSmallest",
fields: &[Expr("of"), Num("n")],
},
desc: "1 for the `n` lowest values per row (cross-section), else 0.",
},
Row {
names: &["sustain"],
sig: OpSig {
tag: "Sustain",
fields: &[Expr("of"), Num("nwindow"), NumOpt("nsatisfy")],
},
desc: "1 where `of` held true at least `nsatisfy` times within the last `nwindow` rows.",
},
Row {
names: &["is_entry"],
sig: OpSig {
tag: "IsEntry",
fields: &[Expr("of")],
},
desc: "1 on the row where `of` turns false->true (rising edge).",
},
Row {
names: &["is_exit"],
sig: OpSig {
tag: "IsExit",
fields: &[Expr("of")],
},
desc: "1 on the row where `of` turns true->false (falling edge).",
},
Row {
names: &["exit_when"],
sig: OpSig {
tag: "ExitWhen",
fields: &[Expr("entry"), Expr("exit")],
},
desc: "Hold true from an entry edge of `entry` until an exit edge (or `exit` is true).",
},
Row {
names: &["quantile_row"],
sig: OpSig {
tag: "QuantileRow",
fields: &[Expr("of"), Num("c")],
},
desc: "Per-row quantile of `of` across symbols at level `c` (e.g. 0.5 = median); one-column result.",
},
Row {
names: &["winsorize"],
sig: OpSig {
tag: "Winsorize",
fields: &[Expr("of"), Num("lower"), Num("upper")],
},
desc: "Per-row winsorize: clip values to empirical quantiles `lower`/`upper` (in 0..1).",
},
Row {
names: &["zscore"],
sig: OpSig {
tag: "Zscore",
fields: &[Expr("of")],
},
desc: "Per-row z-score (population std); NaN preserved; constant rows become 0.",
},
Row {
names: &["bucket"],
sig: OpSig {
tag: "Bucket",
fields: &[Expr("of"), Num("n")],
},
desc: "Per-row quantile buckets labeled 1..=n (ties share average rank).",
},
Row {
names: &["demean"],
sig: OpSig {
tag: "Demean",
fields: &[Expr("of")],
},
desc: "Per-row demean: subtract the cross-sectional mean of non-NaN cells.",
},
Row {
names: &["ceil"],
sig: OpSig {
tag: "Ceil",
fields: &[Expr("of")],
},
desc: "Ceiling of `of`.",
},
Row {
names: &["rank"],
sig: OpSig {
tag: "Rank",
fields: &[Expr("of"), BoolOpt("pct"), BoolOpt("ascending")],
},
desc: "Cross-sectional rank of `of` per row; `pct` for 0..1 percentile (default true), `ascending` sets direction (default true).",
},
Row {
names: &["mask"],
sig: OpSig {
tag: "Mask",
fields: &[Expr("of"), Expr("by")],
},
desc: "`of` kept only where `by` is true; elsewhere dropped.",
},
Row {
names: &["normalize_row"],
sig: OpSig {
tag: "NormalizeRow",
fields: &[Expr("of")],
},
desc: "Scale each row so gross weight (sum of |w|) is 1 — turns a raw signal into explicit portfolio weights. NaN preserved; zero rows unchanged.",
},
Row {
names: &["vol_target"],
sig: OpSig {
tag: "VolTarget",
fields: &[Expr("of"), Expr("prices"), NumOpt("target"), NumOpt("n")],
},
desc: "Scale each row of the weight panel `of` toward an annualized portfolio-volatility `target` (default 0.1) over a trailing `n`-return window (default 63) of `prices`; deleverage only (scale capped at 1, warmup passes through).",
},
Row {
names: &["hold_until"],
sig: OpSig {
tag: "HoldUntil",
fields: &[
Expr("entry"),
Expr("exit"),
NumOpt("nstocks_limit"),
ExprOpt("rank"),
],
},
desc: "Stateful rotation: enter on `entry`, exit on `exit`, hold up to `nstocks_limit` (prioritised by `rank`). Price stops live in the backtest config (stop_loss/take_profit/trailing), not in the op.",
},
Row {
names: &["rebalance"],
sig: OpSig {
tag: "Rebalance",
fields: &[Expr("of"), StrOpt("freq"), ExprOpt("on")],
},
desc: "Hold `of`, refreshing on calendar `freq` (W/ME/QE) or on dates where `on` is true.",
},
Row {
names: &["neutralize"],
sig: OpSig {
tag: "Neutralize",
fields: &[Expr("of"), ExprList("by"), BoolOpt("add_const")],
},
desc: "Cross-sectionally regress `of` against the `by` factors, optionally adding a constant (default true).",
},
Row {
names: &["neutralize_industry"],
sig: OpSig {
tag: "NeutralizeIndustry",
fields: &[Expr("of"), BoolOpt("add_const")],
},
desc: "Neutralize `of` within each industry/sector (add_const defaults to true).",
},
Row {
names: &["industry_rank"],
sig: OpSig {
tag: "IndustryRank",
fields: &[Expr("of"), StrListOpt("categories")],
},
desc: "Rank `of` within each industry, optionally limited to `categories`.",
},
Row {
names: &["cap_industry"],
sig: OpSig {
tag: "CapIndustry",
fields: &[Expr("of"), NumOpt("max_weight")],
},
desc: "Cap each industry's gross weight (sum of |w|) in the weight panel `of` at `max_weight` (default 0.3), scaling that industry's names down proportionally; residual left as cash.",
},
Row {
names: &["groupby_category"],
sig: OpSig {
tag: "GroupbyCategory",
fields: &[Expr("of"), Str("agg")],
},
desc: "Aggregate `of` within each industry using `agg` (e.g. mean).",
},
Row {
names: &["in_sector"],
sig: OpSig {
tag: "InSector",
fields: &[Expr("of"), Str("name")],
},
desc: "Boolean mask (1/0) where the symbol's industry equals `name` (exact, case-sensitive); shape follows `of`.",
},
];
pub fn field_default(tag: &str, field_name: &str) -> Option<serde_json::Value> {
use serde_json::json;
match (tag, field_name) {
("StochD", "d") => Some(json!(3)),
("BollingerUpper", "k") | ("BollingerLower", "k") => Some(json!(2.0)),
("Macd", "fast") | ("MacdSignal", "fast") | ("MacdHist", "fast") => Some(json!(12)),
("Macd", "slow") | ("MacdSignal", "slow") | ("MacdHist", "slow") => Some(json!(26)),
("MacdSignal", "signal") | ("MacdHist", "signal") => Some(json!(9)),
("Rank", "pct") => Some(json!(true)),
("Rank", "ascending") => Some(json!(true)),
("Neutralize", "add_const") => Some(json!(true)),
("NeutralizeIndustry", "add_const") => Some(json!(true)),
("CapIndustry", "max_weight") => Some(json!(0.3)),
("VolTarget", "target") => Some(json!(0.1)),
("VolTarget", "n") => Some(json!(63)),
_ => None,
}
}
pub struct FieldInfo {
pub name: &'static str,
pub kind: &'static str,
pub required: bool,
pub default: Option<serde_json::Value>,
}
pub struct OpInfo {
pub name: &'static str,
pub aliases: &'static [&'static str],
pub tag: &'static str,
pub description: &'static str,
pub fields: Vec<FieldInfo>,
}
pub fn function_ops() -> Vec<OpInfo> {
ROWS.iter()
.map(|r| OpInfo {
name: r.names[0],
aliases: &r.names[1..],
tag: r.sig.tag,
description: r.desc,
fields: r
.sig
.fields
.iter()
.map(|f| {
let name = field_name(f);
FieldInfo {
name,
kind: field_kind(f),
required: field_required(f),
default: field_default(r.sig.tag, name),
}
})
.collect(),
})
.collect()
}
pub fn binary_operators() -> &'static [(&'static str, &'static str)] {
BINOPS
}
pub fn op_by_name(name: &str) -> Option<&'static OpSig> {
ROWS.iter()
.find(|r| r.names.contains(&name))
.map(|r| &r.sig)
}
pub fn sig_by_tag(tag: &str) -> Option<&'static OpSig> {
ROWS.iter().find(|r| r.sig.tag == tag).map(|r| &r.sig)
}
pub fn dsl_name_for_tag(tag: &str) -> &'static str {
ROWS.iter()
.find(|r| r.sig.tag == tag)
.map(|r| r.names[0])
.unwrap_or_else(|| {
ALL_OP_TAGS
.iter()
.copied()
.find(|t| *t == tag)
.unwrap_or("")
})
}
static BINOPS: &[(&str, &str)] = &[
(">", "Gt"),
("<", "Lt"),
(">=", "Ge"),
("<=", "Le"),
("and", "And"),
("or", "Or"),
("+", "Add"),
("-", "Sub"),
("*", "Mul"),
("/", "Div"),
];
pub fn binop_tag(op: &str) -> Option<&'static str> {
BINOPS.iter().find(|(s, _)| *s == op).map(|(_, t)| *t)
}
pub fn binop_symbol_for_tag(tag: &str) -> Option<&'static str> {
BINOPS.iter().find(|(_, t)| *t == tag).map(|(s, _)| *s)
}
pub fn prefix_tag(op: &str) -> Option<&'static str> {
if op == "-" {
Some("Neg")
} else {
None
}
}
pub static ALL_OP_TAGS: &[&str] = &[
"Data",
"Const",
"Average",
"Ema",
"Std",
"Rsi",
"PctChange",
"Rise",
"Shift",
"RollingMax",
"RollingMin",
"BollingerMid",
"BollingerUpper",
"BollingerLower",
"Macd",
"MacdSignal",
"MacdHist",
"DonchianHigh",
"DonchianLow",
"DonchianMid",
"Atr",
"Natr",
"WillR",
"Cci",
"StochK",
"StochD",
"AroonUp",
"AroonDown",
"Adx",
"PlusDi",
"MinusDi",
"Obv",
"Mfi",
"Vwap",
"Fall",
"IsLargest",
"IsSmallest",
"Sustain",
"IsEntry",
"IsExit",
"ExitWhen",
"QuantileRow",
"Winsorize",
"Zscore",
"Bucket",
"Demean",
"Gt",
"Lt",
"Ge",
"Le",
"And",
"Or",
"Not",
"Add",
"Sub",
"Mul",
"Div",
"Neg",
"Ceil",
"Rank",
"Mask",
"NormalizeRow",
"VolTarget",
"HoldUntil",
"Rebalance",
"Neutralize",
"NeutralizeIndustry",
"IndustryRank",
"CapIndustry",
"GroupbyCategory",
"InSector",
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn looks_up_alias_and_snake_case() {
assert_eq!(op_by_name("sma").unwrap().tag, "Average");
assert_eq!(op_by_name("average").unwrap().tag, "Average");
assert_eq!(op_by_name("rolling_max").unwrap().tag, "RollingMax");
assert!(op_by_name("not_an_op").is_none());
}
#[test]
fn maps_operators() {
assert_eq!(binop_tag(">"), Some("Gt"));
assert_eq!(binop_tag("and"), Some("And"));
assert_eq!(binop_tag("+"), Some("Add"));
assert_eq!(prefix_tag("-"), Some("Neg"));
}
#[test]
fn round_trip_names_for_printer() {
assert_eq!(dsl_name_for_tag("Average"), "sma");
assert_eq!(binop_symbol_for_tag("Gt"), Some(">"));
assert_eq!(binop_symbol_for_tag("Average"), None);
}
#[test]
fn function_ops_expose_every_row_with_schema_metadata() {
let ops = function_ops();
assert_eq!(ops.len(), ROWS.len());
let sma = ops.iter().find(|o| o.tag == "Average").unwrap();
assert_eq!(sma.name, "sma");
assert!(sma.aliases.contains(&"average"));
assert!(!sma.description.is_empty());
let mut kinds = std::collections::BTreeSet::new();
for op in &ops {
for f in &op.fields {
assert!(!f.name.is_empty());
assert!(matches!(
f.kind,
"expr" | "number" | "string" | "bool" | "list" | "list-string"
));
kinds.insert(f.kind);
}
}
assert!(kinds.contains("expr"));
assert!(kinds.contains("number"));
}
#[test]
fn field_defaults_match_serde() {
assert_eq!(field_default("StochD", "d"), Some(serde_json::json!(3)));
assert_eq!(field_default("Rank", "pct"), Some(serde_json::json!(true)));
assert_eq!(
field_default("Neutralize", "add_const"),
Some(serde_json::json!(true))
);
assert_eq!(field_default("Average", "n"), None);
assert_eq!(field_default("Nope", "nope"), None);
let stoch_d = function_ops()
.into_iter()
.find(|o| o.tag == "StochD")
.unwrap();
let d_field = stoch_d.fields.iter().find(|f| f.name == "d").unwrap();
assert_eq!(d_field.default, Some(serde_json::json!(3)));
}
#[test]
fn field_kind_and_required_cover_every_variant() {
use Field::*;
for (f, kind, required) in [
(Expr("x"), "expr", true),
(ExprOpt("x"), "expr", false),
(ExprList("x"), "list", true),
(Num("x"), "number", true),
(NumOpt("x"), "number", false),
(BoolOpt("x"), "bool", false),
(Str("x"), "string", true),
(StrOpt("x"), "string", false),
(StrListOpt("x"), "list-string", false),
] {
assert_eq!(field_name(&f), "x");
assert_eq!(field_kind(&f), kind);
assert_eq!(field_required(&f), required);
}
}
#[test]
fn tag_and_operator_lookups() {
assert_eq!(sig_by_tag("Average").unwrap().tag, "Average");
assert!(sig_by_tag("NotATag").is_none());
let ops = binary_operators();
assert!(ops.contains(&(">", "Gt")));
assert!(ops.contains(&("/", "Div")));
assert_eq!(ops.len(), 10);
assert_eq!(dsl_name_for_tag("TotallyUnknownTag"), "");
assert_eq!(prefix_tag("+"), None);
}
#[test]
fn every_spec_op_has_a_signature_or_operator() {
for tag in ALL_OP_TAGS {
let known = ROWS.iter().any(|r| r.sig.tag == *tag)
|| binop_symbol_for_tag(tag).is_some()
|| *tag == "Neg"
|| *tag == "Not"
|| *tag == "Const"
|| *tag == "Data";
assert!(known, "op `{tag}` has no DSL handler");
}
}
}