Skip to main content

lemon/dsl/
ops.rs

1//! Op vocabulary: DSL surface names ⇄ JSON `Expr` op tags + field layout.
2//! Field declaration order MUST match `spec.rs` so positional args line up.
3
4#[derive(Debug, Clone, Copy)]
5pub enum Field {
6    Expr(&'static str),
7    ExprOpt(&'static str),
8    ExprList(&'static str),
9    Num(&'static str),
10    NumOpt(&'static str),
11    BoolOpt(&'static str),
12    Str(&'static str),
13    StrOpt(&'static str),
14    StrListOpt(&'static str),
15}
16
17pub fn field_name(f: &Field) -> &'static str {
18    match f {
19        Field::Expr(n)
20        | Field::ExprOpt(n)
21        | Field::ExprList(n)
22        | Field::Num(n)
23        | Field::NumOpt(n)
24        | Field::BoolOpt(n)
25        | Field::Str(n)
26        | Field::StrOpt(n)
27        | Field::StrListOpt(n) => n,
28    }
29}
30
31/// JSON-schema kind for a field: `expr` (an `Expr` subtree), `number`, `string`,
32/// `bool`, or `list` (a list of `Expr`) / `list-string`.
33pub fn field_kind(f: &Field) -> &'static str {
34    match f {
35        Field::Expr(_) | Field::ExprOpt(_) => "expr",
36        Field::ExprList(_) => "list",
37        Field::Num(_) | Field::NumOpt(_) => "number",
38        Field::BoolOpt(_) => "bool",
39        Field::Str(_) | Field::StrOpt(_) => "string",
40        Field::StrListOpt(_) => "list-string",
41    }
42}
43
44/// Whether the parser requires this field (required scalar/Expr/list) or accepts
45/// it as optional (`*Opt` variants).
46pub fn field_required(f: &Field) -> bool {
47    matches!(
48        f,
49        Field::Expr(_) | Field::Num(_) | Field::Str(_) | Field::ExprList(_)
50    )
51}
52
53pub struct OpSig {
54    pub tag: &'static str,
55    pub fields: &'static [Field],
56}
57
58/// One row per function-style op. `names[0]` is the canonical DSL name (used by the
59/// printer); the rest are accepted aliases. Operator-style ops (Gt/And/Add/Neg/…)
60/// are NOT here — see `binop_tag`/`prefix_tag`.
61///
62/// `desc` is a one-line, machine-readable description consumed by the schema
63/// generator (`cargo run -p lemon-lang --example gen-schema`); keep it terse and
64/// in sync with the `Expr` doc-comment in `spec.rs`.
65struct Row {
66    names: &'static [&'static str],
67    sig: OpSig,
68    desc: &'static str,
69}
70
71use Field::*;
72
73static ROWS: &[Row] = &[
74    Row {
75        names: &["sma", "average"],
76        sig: OpSig {
77            tag: "Average",
78            fields: &[Expr("of"), Num("n")],
79        },
80        desc: "Simple moving average of `of` over `n` days.",
81    },
82    Row {
83        names: &["ema"],
84        sig: OpSig {
85            tag: "Ema",
86            fields: &[Expr("of"), Num("n")],
87        },
88        desc: "Exponential moving average of `of` over `n` days.",
89    },
90    Row {
91        names: &["std"],
92        sig: OpSig {
93            tag: "Std",
94            fields: &[Expr("of"), Num("n")],
95        },
96        desc: "Rolling standard deviation of `of` over `n` days.",
97    },
98    Row {
99        names: &["rsi"],
100        sig: OpSig {
101            tag: "Rsi",
102            fields: &[Expr("of"), Num("n")],
103        },
104        desc: "Relative Strength Index of `of` over `n` days.",
105    },
106    Row {
107        names: &["pct_change"],
108        sig: OpSig {
109            tag: "PctChange",
110            fields: &[Expr("of"), Num("n")],
111        },
112        desc: "Percentage change of `of` over `n` days.",
113    },
114    Row {
115        names: &["rise"],
116        sig: OpSig {
117            tag: "Rise",
118            fields: &[Expr("of"), Num("n")],
119        },
120        desc: "1 where `of` rose for `n` consecutive days, else 0.",
121    },
122    Row {
123        names: &["fall"],
124        sig: OpSig {
125            tag: "Fall",
126            fields: &[Expr("of"), Num("n")],
127        },
128        desc: "1 where `of` fell for `n` consecutive days, else 0.",
129    },
130    Row {
131        names: &["shift"],
132        sig: OpSig {
133            tag: "Shift",
134            fields: &[Expr("of"), Num("n")],
135        },
136        desc: "`of` lagged forward by `n` days.",
137    },
138    Row {
139        names: &["rolling_max"],
140        sig: OpSig {
141            tag: "RollingMax",
142            fields: &[Expr("of"), Num("n")],
143        },
144        desc: "Rolling maximum of `of` over `n` days.",
145    },
146    Row {
147        names: &["rolling_min"],
148        sig: OpSig {
149            tag: "RollingMin",
150            fields: &[Expr("of"), Num("n")],
151        },
152        desc: "Rolling minimum of `of` over `n` days.",
153    },
154    Row {
155        names: &["bollinger_mid"],
156        sig: OpSig {
157            tag: "BollingerMid",
158            fields: &[Expr("of"), Num("n")],
159        },
160        desc: "Bollinger mid band: the `n`-day simple moving average of `of`.",
161    },
162    Row {
163        names: &["bollinger_upper"],
164        sig: OpSig {
165            tag: "BollingerUpper",
166            fields: &[Expr("of"), Num("n"), NumOpt("k")],
167        },
168        desc: "Bollinger upper band: sma(of, n) + k * std(of, n) (k defaults to 2).",
169    },
170    Row {
171        names: &["bollinger_lower"],
172        sig: OpSig {
173            tag: "BollingerLower",
174            fields: &[Expr("of"), Num("n"), NumOpt("k")],
175        },
176        desc: "Bollinger lower band: sma(of, n) - k * std(of, n) (k defaults to 2).",
177    },
178    Row {
179        names: &["macd"],
180        sig: OpSig {
181            tag: "Macd",
182            fields: &[Expr("of"), NumOpt("fast"), NumOpt("slow")],
183        },
184        desc: "MACD line: ema(of, fast) - ema(of, slow) (fast/slow default 12/26).",
185    },
186    Row {
187        names: &["macd_signal"],
188        sig: OpSig {
189            tag: "MacdSignal",
190            fields: &[Expr("of"), NumOpt("fast"), NumOpt("slow"), NumOpt("signal")],
191        },
192        desc: "MACD signal line: `signal`-day EMA of the MACD line (defaults 12/26/9).",
193    },
194    Row {
195        names: &["macd_hist"],
196        sig: OpSig {
197            tag: "MacdHist",
198            fields: &[Expr("of"), NumOpt("fast"), NumOpt("slow"), NumOpt("signal")],
199        },
200        desc: "MACD histogram: MACD line minus its signal line (defaults 12/26/9).",
201    },
202    Row {
203        names: &["donchian_high"],
204        sig: OpSig {
205            tag: "DonchianHigh",
206            fields: &[Expr("of"), Num("n")],
207        },
208        desc: "Donchian channel upper band: rolling `n`-day high of `of`.",
209    },
210    Row {
211        names: &["donchian_low"],
212        sig: OpSig {
213            tag: "DonchianLow",
214            fields: &[Expr("of"), Num("n")],
215        },
216        desc: "Donchian channel lower band: rolling `n`-day low of `of`.",
217    },
218    Row {
219        names: &["donchian_mid"],
220        sig: OpSig {
221            tag: "DonchianMid",
222            fields: &[Expr("of"), Num("n")],
223        },
224        desc: "Donchian channel mid-line: (rolling_max + rolling_min) / 2 over `n` days.",
225    },
226    Row {
227        names: &["atr"],
228        sig: OpSig {
229            tag: "Atr",
230            fields: &[Expr("high"), Expr("low"), Expr("close"), Num("n")],
231        },
232        desc: "Average True Range over `n` days from high/low/close.",
233    },
234    Row {
235        names: &["natr"],
236        sig: OpSig {
237            tag: "Natr",
238            fields: &[Expr("high"), Expr("low"), Expr("close"), Num("n")],
239        },
240        desc: "Normalized ATR (percent) over `n` days.",
241    },
242    Row {
243        names: &["willr"],
244        sig: OpSig {
245            tag: "WillR",
246            fields: &[Expr("high"), Expr("low"), Expr("close"), Num("n")],
247        },
248        desc: "Williams %R over `n` days.",
249    },
250    Row {
251        names: &["cci"],
252        sig: OpSig {
253            tag: "Cci",
254            fields: &[Expr("high"), Expr("low"), Expr("close"), Num("n")],
255        },
256        desc: "Commodity Channel Index over `n` days.",
257    },
258    Row {
259        names: &["stoch_k"],
260        sig: OpSig {
261            tag: "StochK",
262            fields: &[Expr("high"), Expr("low"), Expr("close"), Num("n")],
263        },
264        desc: "Stochastic %K over `n` days.",
265    },
266    Row {
267        names: &["stoch_d"],
268        sig: OpSig {
269            tag: "StochD",
270            fields: &[
271                Expr("high"),
272                Expr("low"),
273                Expr("close"),
274                Num("n"),
275                NumOpt("d"),
276            ],
277        },
278        desc: "Stochastic %D: `d`-day average of %K over `n` days (d defaults to 3).",
279    },
280    Row {
281        names: &["aroon_up"],
282        sig: OpSig {
283            tag: "AroonUp",
284            fields: &[Expr("high"), Num("n")],
285        },
286        desc: "Aroon Up over `n` days from high.",
287    },
288    Row {
289        names: &["aroon_down"],
290        sig: OpSig {
291            tag: "AroonDown",
292            fields: &[Expr("low"), Num("n")],
293        },
294        desc: "Aroon Down over `n` days from low.",
295    },
296    Row {
297        names: &["adx"],
298        sig: OpSig {
299            tag: "Adx",
300            fields: &[Expr("high"), Expr("low"), Expr("close"), Num("n")],
301        },
302        desc: "Average Directional Index over `n` days.",
303    },
304    Row {
305        names: &["plus_di"],
306        sig: OpSig {
307            tag: "PlusDi",
308            fields: &[Expr("high"), Expr("low"), Expr("close"), Num("n")],
309        },
310        desc: "Plus Directional Indicator (+DI) over `n` days.",
311    },
312    Row {
313        names: &["minus_di"],
314        sig: OpSig {
315            tag: "MinusDi",
316            fields: &[Expr("high"), Expr("low"), Expr("close"), Num("n")],
317        },
318        desc: "Minus Directional Indicator (-DI) over `n` days.",
319    },
320    Row {
321        names: &["obv"],
322        sig: OpSig {
323            tag: "Obv",
324            fields: &[Expr("close"), Expr("volume")],
325        },
326        desc: "On-Balance Volume from close and volume.",
327    },
328    Row {
329        names: &["mfi"],
330        sig: OpSig {
331            tag: "Mfi",
332            fields: &[
333                Expr("high"),
334                Expr("low"),
335                Expr("close"),
336                Expr("volume"),
337                Num("n"),
338            ],
339        },
340        desc: "Money Flow Index over `n` days.",
341    },
342    Row {
343        names: &["vwap"],
344        sig: OpSig {
345            tag: "Vwap",
346            fields: &[
347                Expr("high"),
348                Expr("low"),
349                Expr("close"),
350                Expr("volume"),
351                Num("n"),
352            ],
353        },
354        desc: "Volume-Weighted Average Price over `n` days from high/low/close/volume.",
355    },
356    Row {
357        names: &["is_largest"],
358        sig: OpSig {
359            tag: "IsLargest",
360            fields: &[Expr("of"), Num("n")],
361        },
362        desc: "1 for the `n` highest values per row (cross-section), else 0.",
363    },
364    Row {
365        names: &["is_smallest"],
366        sig: OpSig {
367            tag: "IsSmallest",
368            fields: &[Expr("of"), Num("n")],
369        },
370        desc: "1 for the `n` lowest values per row (cross-section), else 0.",
371    },
372    Row {
373        names: &["sustain"],
374        sig: OpSig {
375            tag: "Sustain",
376            fields: &[Expr("of"), Num("nwindow"), NumOpt("nsatisfy")],
377        },
378        desc: "1 where `of` held true at least `nsatisfy` times within the last `nwindow` rows.",
379    },
380    Row {
381        names: &["is_entry"],
382        sig: OpSig {
383            tag: "IsEntry",
384            fields: &[Expr("of")],
385        },
386        desc: "1 on the row where `of` turns false->true (rising edge).",
387    },
388    Row {
389        names: &["is_exit"],
390        sig: OpSig {
391            tag: "IsExit",
392            fields: &[Expr("of")],
393        },
394        desc: "1 on the row where `of` turns true->false (falling edge).",
395    },
396    Row {
397        names: &["exit_when"],
398        sig: OpSig {
399            tag: "ExitWhen",
400            fields: &[Expr("entry"), Expr("exit")],
401        },
402        desc: "Hold true from an entry edge of `entry` until an exit edge (or `exit` is true).",
403    },
404    Row {
405        names: &["quantile_row"],
406        sig: OpSig {
407            tag: "QuantileRow",
408            fields: &[Expr("of"), Num("c")],
409        },
410        desc: "Per-row quantile of `of` across symbols at level `c` (e.g. 0.5 = median); one-column result.",
411    },
412    Row {
413        names: &["winsorize"],
414        sig: OpSig {
415            tag: "Winsorize",
416            fields: &[Expr("of"), Num("lower"), Num("upper")],
417        },
418        desc: "Per-row winsorize: clip values to empirical quantiles `lower`/`upper` (in 0..1).",
419    },
420    Row {
421        names: &["zscore"],
422        sig: OpSig {
423            tag: "Zscore",
424            fields: &[Expr("of")],
425        },
426        desc: "Per-row z-score (population std); NaN preserved; constant rows become 0.",
427    },
428    Row {
429        names: &["bucket"],
430        sig: OpSig {
431            tag: "Bucket",
432            fields: &[Expr("of"), Num("n")],
433        },
434        desc: "Per-row quantile buckets labeled 1..=n (ties share average rank).",
435    },
436    Row {
437        names: &["demean"],
438        sig: OpSig {
439            tag: "Demean",
440            fields: &[Expr("of")],
441        },
442        desc: "Per-row demean: subtract the cross-sectional mean of non-NaN cells.",
443    },
444    Row {
445        names: &["ceil"],
446        sig: OpSig {
447            tag: "Ceil",
448            fields: &[Expr("of")],
449        },
450        desc: "Ceiling of `of`.",
451    },
452    Row {
453        names: &["rank"],
454        sig: OpSig {
455            tag: "Rank",
456            fields: &[Expr("of"), BoolOpt("pct"), BoolOpt("ascending")],
457        },
458        desc: "Cross-sectional rank of `of` per row; `pct` for 0..1 percentile (default true), `ascending` sets direction (default true).",
459    },
460    Row {
461        names: &["mask"],
462        sig: OpSig {
463            tag: "Mask",
464            fields: &[Expr("of"), Expr("by")],
465        },
466        desc: "`of` kept only where `by` is true; elsewhere dropped.",
467    },
468    Row {
469        names: &["normalize_row"],
470        sig: OpSig {
471            tag: "NormalizeRow",
472            fields: &[Expr("of")],
473        },
474        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.",
475    },
476    Row {
477        names: &["vol_target"],
478        sig: OpSig {
479            tag: "VolTarget",
480            fields: &[Expr("of"), Expr("prices"), NumOpt("target"), NumOpt("n")],
481        },
482        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).",
483    },
484    Row {
485        names: &["hold_until"],
486        sig: OpSig {
487            tag: "HoldUntil",
488            fields: &[
489                Expr("entry"),
490                Expr("exit"),
491                NumOpt("nstocks_limit"),
492                ExprOpt("rank"),
493            ],
494        },
495        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.",
496    },
497    Row {
498        names: &["rebalance"],
499        sig: OpSig {
500            tag: "Rebalance",
501            fields: &[Expr("of"), StrOpt("freq"), ExprOpt("on")],
502        },
503        desc: "Hold `of`, refreshing on calendar `freq` (W/ME/QE) or on dates where `on` is true.",
504    },
505    Row {
506        names: &["neutralize"],
507        sig: OpSig {
508            tag: "Neutralize",
509            fields: &[Expr("of"), ExprList("by"), BoolOpt("add_const")],
510        },
511        desc: "Cross-sectionally regress `of` against the `by` factors, optionally adding a constant (default true).",
512    },
513    Row {
514        names: &["neutralize_industry"],
515        sig: OpSig {
516            tag: "NeutralizeIndustry",
517            fields: &[Expr("of"), BoolOpt("add_const")],
518        },
519        desc: "Neutralize `of` within each industry/sector (add_const defaults to true).",
520    },
521    Row {
522        names: &["industry_rank"],
523        sig: OpSig {
524            tag: "IndustryRank",
525            fields: &[Expr("of"), StrListOpt("categories")],
526        },
527        desc: "Rank `of` within each industry, optionally limited to `categories`.",
528    },
529    Row {
530        names: &["cap_industry"],
531        sig: OpSig {
532            tag: "CapIndustry",
533            fields: &[Expr("of"), NumOpt("max_weight")],
534        },
535        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.",
536    },
537    Row {
538        names: &["groupby_category"],
539        sig: OpSig {
540            tag: "GroupbyCategory",
541            fields: &[Expr("of"), Str("agg")],
542        },
543        desc: "Aggregate `of` within each industry using `agg` (e.g. mean).",
544    },
545    Row {
546        names: &["in_sector"],
547        sig: OpSig {
548            tag: "InSector",
549            fields: &[Expr("of"), Str("name")],
550        },
551        desc: "Boolean mask (1/0) where the symbol's industry equals `name` (exact, case-sensitive); shape follows `of`.",
552    },
553];
554
555// ---------------------------------------------------------------------------
556// Public catalog API — the single source of truth consumed by the schema
557// generator (`cargo run -p lemon-lang --example gen-schema`). Everything below
558// is derived from `ROWS`, `BINOPS`, and `prefix_tag`, so the emitted JSON
559// artifacts can never drift from the parser.
560// ---------------------------------------------------------------------------
561
562/// A field's serde default, when it has one. `serde(default)` on an `Option`
563/// field means "absent" (no default value to emit); those return `None` here.
564/// Kept co-located with the field declarations so it stays in sync with
565/// `spec.rs`.
566pub fn field_default(tag: &str, field_name: &str) -> Option<serde_json::Value> {
567    use serde_json::json;
568    match (tag, field_name) {
569        ("StochD", "d") => Some(json!(3)),
570        ("BollingerUpper", "k") | ("BollingerLower", "k") => Some(json!(2.0)),
571        ("Macd", "fast") | ("MacdSignal", "fast") | ("MacdHist", "fast") => Some(json!(12)),
572        ("Macd", "slow") | ("MacdSignal", "slow") | ("MacdHist", "slow") => Some(json!(26)),
573        ("MacdSignal", "signal") | ("MacdHist", "signal") => Some(json!(9)),
574        ("Rank", "pct") => Some(json!(true)),
575        ("Rank", "ascending") => Some(json!(true)),
576        ("Neutralize", "add_const") => Some(json!(true)),
577        ("NeutralizeIndustry", "add_const") => Some(json!(true)),
578        ("CapIndustry", "max_weight") => Some(json!(0.3)),
579        ("VolTarget", "target") => Some(json!(0.1)),
580        ("VolTarget", "n") => Some(json!(63)),
581        _ => None,
582    }
583}
584
585/// One field of a callable op, in schema-friendly form.
586pub struct FieldInfo {
587    pub name: &'static str,
588    /// One of: `expr`, `number`, `string`, `bool`, `list`, `list-string`.
589    pub kind: &'static str,
590    pub required: bool,
591    pub default: Option<serde_json::Value>,
592}
593
594/// One callable op: its canonical name, aliases, op tag, ordered fields, and a
595/// one-line description.
596pub struct OpInfo {
597    pub name: &'static str,
598    pub aliases: &'static [&'static str],
599    pub tag: &'static str,
600    pub description: &'static str,
601    pub fields: Vec<FieldInfo>,
602}
603
604/// Every function-style op in `ROWS`, in declaration order.
605pub fn function_ops() -> Vec<OpInfo> {
606    ROWS.iter()
607        .map(|r| OpInfo {
608            name: r.names[0],
609            aliases: &r.names[1..],
610            tag: r.sig.tag,
611            description: r.desc,
612            fields: r
613                .sig
614                .fields
615                .iter()
616                .map(|f| {
617                    let name = field_name(f);
618                    FieldInfo {
619                        name,
620                        kind: field_kind(f),
621                        required: field_required(f),
622                        default: field_default(r.sig.tag, name),
623                    }
624                })
625                .collect(),
626        })
627        .collect()
628}
629
630/// The binary operator ops: `(symbol, op tag)`. Both operands are `l`/`r` exprs.
631pub fn binary_operators() -> &'static [(&'static str, &'static str)] {
632    BINOPS
633}
634
635pub fn op_by_name(name: &str) -> Option<&'static OpSig> {
636    ROWS.iter()
637        .find(|r| r.names.contains(&name))
638        .map(|r| &r.sig)
639}
640
641pub fn sig_by_tag(tag: &str) -> Option<&'static OpSig> {
642    ROWS.iter().find(|r| r.sig.tag == tag).map(|r| &r.sig)
643}
644
645/// Canonical DSL name for a function-style op tag (printer).
646pub fn dsl_name_for_tag(tag: &str) -> &'static str {
647    ROWS.iter()
648        .find(|r| r.sig.tag == tag)
649        .map(|r| r.names[0])
650        .unwrap_or_else(|| {
651            // Tag not in the function table — callers should not reach this for
652            // operator-style ops; return the tag itself via a leaked copy so the
653            // return type stays `&'static str`.
654            ALL_OP_TAGS
655                .iter()
656                .copied()
657                .find(|t| *t == tag)
658                .unwrap_or("")
659        })
660}
661
662static BINOPS: &[(&str, &str)] = &[
663    (">", "Gt"),
664    ("<", "Lt"),
665    (">=", "Ge"),
666    ("<=", "Le"),
667    ("and", "And"),
668    ("or", "Or"),
669    ("+", "Add"),
670    ("-", "Sub"),
671    ("*", "Mul"),
672    ("/", "Div"),
673];
674
675pub fn binop_tag(op: &str) -> Option<&'static str> {
676    BINOPS.iter().find(|(s, _)| *s == op).map(|(_, t)| *t)
677}
678
679pub fn binop_symbol_for_tag(tag: &str) -> Option<&'static str> {
680    BINOPS.iter().find(|(_, t)| *t == tag).map(|(s, _)| *s)
681}
682
683pub fn prefix_tag(op: &str) -> Option<&'static str> {
684    if op == "-" {
685        Some("Neg")
686    } else {
687        None
688    }
689}
690
691/// All op tags in `spec.rs` — the completeness gate (see ops::tests).
692pub static ALL_OP_TAGS: &[&str] = &[
693    "Data",
694    "Const",
695    "Average",
696    "Ema",
697    "Std",
698    "Rsi",
699    "PctChange",
700    "Rise",
701    "Shift",
702    "RollingMax",
703    "RollingMin",
704    "BollingerMid",
705    "BollingerUpper",
706    "BollingerLower",
707    "Macd",
708    "MacdSignal",
709    "MacdHist",
710    "DonchianHigh",
711    "DonchianLow",
712    "DonchianMid",
713    "Atr",
714    "Natr",
715    "WillR",
716    "Cci",
717    "StochK",
718    "StochD",
719    "AroonUp",
720    "AroonDown",
721    "Adx",
722    "PlusDi",
723    "MinusDi",
724    "Obv",
725    "Mfi",
726    "Vwap",
727    "Fall",
728    "IsLargest",
729    "IsSmallest",
730    "Sustain",
731    "IsEntry",
732    "IsExit",
733    "ExitWhen",
734    "QuantileRow",
735    "Winsorize",
736    "Zscore",
737    "Bucket",
738    "Demean",
739    "Gt",
740    "Lt",
741    "Ge",
742    "Le",
743    "And",
744    "Or",
745    "Not",
746    "Add",
747    "Sub",
748    "Mul",
749    "Div",
750    "Neg",
751    "Ceil",
752    "Rank",
753    "Mask",
754    "NormalizeRow",
755    "VolTarget",
756    "HoldUntil",
757    "Rebalance",
758    "Neutralize",
759    "NeutralizeIndustry",
760    "IndustryRank",
761    "CapIndustry",
762    "GroupbyCategory",
763    "InSector",
764];
765
766#[cfg(test)]
767mod tests {
768    use super::*;
769
770    #[test]
771    fn looks_up_alias_and_snake_case() {
772        assert_eq!(op_by_name("sma").unwrap().tag, "Average");
773        assert_eq!(op_by_name("average").unwrap().tag, "Average");
774        assert_eq!(op_by_name("rolling_max").unwrap().tag, "RollingMax");
775        assert!(op_by_name("not_an_op").is_none());
776    }
777
778    #[test]
779    fn maps_operators() {
780        assert_eq!(binop_tag(">"), Some("Gt"));
781        assert_eq!(binop_tag("and"), Some("And"));
782        assert_eq!(binop_tag("+"), Some("Add"));
783        assert_eq!(prefix_tag("-"), Some("Neg"));
784    }
785
786    #[test]
787    fn round_trip_names_for_printer() {
788        assert_eq!(dsl_name_for_tag("Average"), "sma");
789        assert_eq!(binop_symbol_for_tag("Gt"), Some(">"));
790        assert_eq!(binop_symbol_for_tag("Average"), None);
791    }
792
793    #[test]
794    fn function_ops_expose_every_row_with_schema_metadata() {
795        let ops = function_ops();
796        // One OpInfo per row in the table.
797        assert_eq!(ops.len(), ROWS.len());
798
799        // sma is the canonical name for Average and carries `average` as an alias.
800        let sma = ops.iter().find(|o| o.tag == "Average").unwrap();
801        assert_eq!(sma.name, "sma");
802        assert!(sma.aliases.contains(&"average"));
803        assert!(!sma.description.is_empty());
804
805        // Every field carries a name, a known kind, and a required flag; the field
806        // kinds collectively cover the full `field_kind` match.
807        let mut kinds = std::collections::BTreeSet::new();
808        for op in &ops {
809            for f in &op.fields {
810                assert!(!f.name.is_empty());
811                assert!(matches!(
812                    f.kind,
813                    "expr" | "number" | "string" | "bool" | "list" | "list-string"
814                ));
815                kinds.insert(f.kind);
816            }
817        }
818        // The table exercises expr and number kinds at minimum.
819        assert!(kinds.contains("expr"));
820        assert!(kinds.contains("number"));
821    }
822
823    #[test]
824    fn field_defaults_match_serde() {
825        // Documented serde defaults surface through field_default…
826        assert_eq!(field_default("StochD", "d"), Some(serde_json::json!(3)));
827        assert_eq!(field_default("Rank", "pct"), Some(serde_json::json!(true)));
828        assert_eq!(
829            field_default("Neutralize", "add_const"),
830            Some(serde_json::json!(true))
831        );
832        // …and everything else has no default.
833        assert_eq!(field_default("Average", "n"), None);
834        assert_eq!(field_default("Nope", "nope"), None);
835
836        // field_default is threaded through function_ops for the defaulted fields.
837        let stoch_d = function_ops()
838            .into_iter()
839            .find(|o| o.tag == "StochD")
840            .unwrap();
841        let d_field = stoch_d.fields.iter().find(|f| f.name == "d").unwrap();
842        assert_eq!(d_field.default, Some(serde_json::json!(3)));
843    }
844
845    #[test]
846    fn field_kind_and_required_cover_every_variant() {
847        use Field::*;
848        for (f, kind, required) in [
849            (Expr("x"), "expr", true),
850            (ExprOpt("x"), "expr", false),
851            (ExprList("x"), "list", true),
852            (Num("x"), "number", true),
853            (NumOpt("x"), "number", false),
854            (BoolOpt("x"), "bool", false),
855            (Str("x"), "string", true),
856            (StrOpt("x"), "string", false),
857            (StrListOpt("x"), "list-string", false),
858        ] {
859            assert_eq!(field_name(&f), "x");
860            assert_eq!(field_kind(&f), kind);
861            assert_eq!(field_required(&f), required);
862        }
863    }
864
865    #[test]
866    fn tag_and_operator_lookups() {
867        // sig_by_tag round-trips against op_by_name.
868        assert_eq!(sig_by_tag("Average").unwrap().tag, "Average");
869        assert!(sig_by_tag("NotATag").is_none());
870
871        // binary_operators exposes the BINOPS table.
872        let ops = binary_operators();
873        assert!(ops.contains(&(">", "Gt")));
874        assert!(ops.contains(&("/", "Div")));
875        assert_eq!(ops.len(), 10);
876
877        // Unknown / operator-style tags return "" from dsl_name_for_tag (fallback arm).
878        assert_eq!(dsl_name_for_tag("TotallyUnknownTag"), "");
879        assert_eq!(prefix_tag("+"), None);
880    }
881
882    #[test]
883    fn every_spec_op_has_a_signature_or_operator() {
884        // Every op tag from spec.rs. If a new op is added, add it here AND to the
885        // table/operator maps — this test is the completeness gate for this plan.
886        for tag in ALL_OP_TAGS {
887            let known = ROWS.iter().any(|r| r.sig.tag == *tag)
888                || binop_symbol_for_tag(tag).is_some()
889                || *tag == "Neg"
890                || *tag == "Not"
891                || *tag == "Const"
892                || *tag == "Data";
893            assert!(known, "op `{tag}` has no DSL handler");
894        }
895    }
896}