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                NumOpt("stop_loss"),
494                NumOpt("take_profit"),
495                NumOpt("trail_stop"),
496                NumOpt("trail_stop_activation"),
497            ],
498        },
499        desc: "Stateful rotation: enter on `entry`, exit on `exit`, hold up to `nstocks_limit` (prioritised by `rank`), with optional stop_loss/take_profit/trail_stop/trail_stop_activation.",
500    },
501    Row {
502        names: &["rebalance"],
503        sig: OpSig {
504            tag: "Rebalance",
505            fields: &[Expr("of"), StrOpt("freq"), ExprOpt("on")],
506        },
507        desc: "Hold `of`, refreshing on calendar `freq` (W/ME/QE) or on dates where `on` is true.",
508    },
509    Row {
510        names: &["neutralize"],
511        sig: OpSig {
512            tag: "Neutralize",
513            fields: &[Expr("of"), ExprList("by"), BoolOpt("add_const")],
514        },
515        desc: "Cross-sectionally regress `of` against the `by` factors, optionally adding a constant (default true).",
516    },
517    Row {
518        names: &["neutralize_industry"],
519        sig: OpSig {
520            tag: "NeutralizeIndustry",
521            fields: &[Expr("of"), BoolOpt("add_const")],
522        },
523        desc: "Neutralize `of` within each industry/sector (add_const defaults to true).",
524    },
525    Row {
526        names: &["industry_rank"],
527        sig: OpSig {
528            tag: "IndustryRank",
529            fields: &[Expr("of"), StrListOpt("categories")],
530        },
531        desc: "Rank `of` within each industry, optionally limited to `categories`.",
532    },
533    Row {
534        names: &["cap_industry"],
535        sig: OpSig {
536            tag: "CapIndustry",
537            fields: &[Expr("of"), NumOpt("max_weight")],
538        },
539        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.",
540    },
541    Row {
542        names: &["groupby_category"],
543        sig: OpSig {
544            tag: "GroupbyCategory",
545            fields: &[Expr("of"), Str("agg")],
546        },
547        desc: "Aggregate `of` within each industry using `agg` (e.g. mean).",
548    },
549    Row {
550        names: &["in_sector"],
551        sig: OpSig {
552            tag: "InSector",
553            fields: &[Expr("of"), Str("name")],
554        },
555        desc: "Boolean mask (1/0) where the symbol's industry equals `name` (exact, case-sensitive); shape follows `of`.",
556    },
557];
558
559// ---------------------------------------------------------------------------
560// Public catalog API — the single source of truth consumed by the schema
561// generator (`cargo run -p lemon-lang --example gen-schema`). Everything below
562// is derived from `ROWS`, `BINOPS`, and `prefix_tag`, so the emitted JSON
563// artifacts can never drift from the parser.
564// ---------------------------------------------------------------------------
565
566/// A field's serde default, when it has one. `serde(default)` on an `Option`
567/// field means "absent" (no default value to emit); those return `None` here.
568/// Kept co-located with the field declarations so it stays in sync with
569/// `spec.rs`.
570pub fn field_default(tag: &str, field_name: &str) -> Option<serde_json::Value> {
571    use serde_json::json;
572    match (tag, field_name) {
573        ("StochD", "d") => Some(json!(3)),
574        ("BollingerUpper", "k") | ("BollingerLower", "k") => Some(json!(2.0)),
575        ("Macd", "fast") | ("MacdSignal", "fast") | ("MacdHist", "fast") => Some(json!(12)),
576        ("Macd", "slow") | ("MacdSignal", "slow") | ("MacdHist", "slow") => Some(json!(26)),
577        ("MacdSignal", "signal") | ("MacdHist", "signal") => Some(json!(9)),
578        ("Rank", "pct") => Some(json!(true)),
579        ("Rank", "ascending") => Some(json!(true)),
580        ("Neutralize", "add_const") => Some(json!(true)),
581        ("NeutralizeIndustry", "add_const") => Some(json!(true)),
582        ("CapIndustry", "max_weight") => Some(json!(0.3)),
583        ("VolTarget", "target") => Some(json!(0.1)),
584        ("VolTarget", "n") => Some(json!(63)),
585        _ => None,
586    }
587}
588
589/// One field of a callable op, in schema-friendly form.
590pub struct FieldInfo {
591    pub name: &'static str,
592    /// One of: `expr`, `number`, `string`, `bool`, `list`, `list-string`.
593    pub kind: &'static str,
594    pub required: bool,
595    pub default: Option<serde_json::Value>,
596}
597
598/// One callable op: its canonical name, aliases, op tag, ordered fields, and a
599/// one-line description.
600pub struct OpInfo {
601    pub name: &'static str,
602    pub aliases: &'static [&'static str],
603    pub tag: &'static str,
604    pub description: &'static str,
605    pub fields: Vec<FieldInfo>,
606}
607
608/// Every function-style op in `ROWS`, in declaration order.
609pub fn function_ops() -> Vec<OpInfo> {
610    ROWS.iter()
611        .map(|r| OpInfo {
612            name: r.names[0],
613            aliases: &r.names[1..],
614            tag: r.sig.tag,
615            description: r.desc,
616            fields: r
617                .sig
618                .fields
619                .iter()
620                .map(|f| {
621                    let name = field_name(f);
622                    FieldInfo {
623                        name,
624                        kind: field_kind(f),
625                        required: field_required(f),
626                        default: field_default(r.sig.tag, name),
627                    }
628                })
629                .collect(),
630        })
631        .collect()
632}
633
634/// The binary operator ops: `(symbol, op tag)`. Both operands are `l`/`r` exprs.
635pub fn binary_operators() -> &'static [(&'static str, &'static str)] {
636    BINOPS
637}
638
639pub fn op_by_name(name: &str) -> Option<&'static OpSig> {
640    ROWS.iter()
641        .find(|r| r.names.contains(&name))
642        .map(|r| &r.sig)
643}
644
645pub fn sig_by_tag(tag: &str) -> Option<&'static OpSig> {
646    ROWS.iter().find(|r| r.sig.tag == tag).map(|r| &r.sig)
647}
648
649/// Canonical DSL name for a function-style op tag (printer).
650pub fn dsl_name_for_tag(tag: &str) -> &'static str {
651    ROWS.iter()
652        .find(|r| r.sig.tag == tag)
653        .map(|r| r.names[0])
654        .unwrap_or_else(|| {
655            // Tag not in the function table — callers should not reach this for
656            // operator-style ops; return the tag itself via a leaked copy so the
657            // return type stays `&'static str`.
658            ALL_OP_TAGS
659                .iter()
660                .copied()
661                .find(|t| *t == tag)
662                .unwrap_or("")
663        })
664}
665
666static BINOPS: &[(&str, &str)] = &[
667    (">", "Gt"),
668    ("<", "Lt"),
669    (">=", "Ge"),
670    ("<=", "Le"),
671    ("and", "And"),
672    ("or", "Or"),
673    ("+", "Add"),
674    ("-", "Sub"),
675    ("*", "Mul"),
676    ("/", "Div"),
677];
678
679pub fn binop_tag(op: &str) -> Option<&'static str> {
680    BINOPS.iter().find(|(s, _)| *s == op).map(|(_, t)| *t)
681}
682
683pub fn binop_symbol_for_tag(tag: &str) -> Option<&'static str> {
684    BINOPS.iter().find(|(_, t)| *t == tag).map(|(s, _)| *s)
685}
686
687pub fn prefix_tag(op: &str) -> Option<&'static str> {
688    if op == "-" {
689        Some("Neg")
690    } else {
691        None
692    }
693}
694
695/// All op tags in `spec.rs` — the completeness gate (see ops::tests).
696pub static ALL_OP_TAGS: &[&str] = &[
697    "Data",
698    "Const",
699    "Average",
700    "Ema",
701    "Std",
702    "Rsi",
703    "PctChange",
704    "Rise",
705    "Shift",
706    "RollingMax",
707    "RollingMin",
708    "BollingerMid",
709    "BollingerUpper",
710    "BollingerLower",
711    "Macd",
712    "MacdSignal",
713    "MacdHist",
714    "DonchianHigh",
715    "DonchianLow",
716    "DonchianMid",
717    "Atr",
718    "Natr",
719    "WillR",
720    "Cci",
721    "StochK",
722    "StochD",
723    "AroonUp",
724    "AroonDown",
725    "Adx",
726    "PlusDi",
727    "MinusDi",
728    "Obv",
729    "Mfi",
730    "Vwap",
731    "Fall",
732    "IsLargest",
733    "IsSmallest",
734    "Sustain",
735    "IsEntry",
736    "IsExit",
737    "ExitWhen",
738    "QuantileRow",
739    "Winsorize",
740    "Zscore",
741    "Bucket",
742    "Demean",
743    "Gt",
744    "Lt",
745    "Ge",
746    "Le",
747    "And",
748    "Or",
749    "Not",
750    "Add",
751    "Sub",
752    "Mul",
753    "Div",
754    "Neg",
755    "Ceil",
756    "Rank",
757    "Mask",
758    "NormalizeRow",
759    "VolTarget",
760    "HoldUntil",
761    "Rebalance",
762    "Neutralize",
763    "NeutralizeIndustry",
764    "IndustryRank",
765    "CapIndustry",
766    "GroupbyCategory",
767    "InSector",
768];
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773
774    #[test]
775    fn looks_up_alias_and_snake_case() {
776        assert_eq!(op_by_name("sma").unwrap().tag, "Average");
777        assert_eq!(op_by_name("average").unwrap().tag, "Average");
778        assert_eq!(op_by_name("rolling_max").unwrap().tag, "RollingMax");
779        assert!(op_by_name("not_an_op").is_none());
780    }
781
782    #[test]
783    fn maps_operators() {
784        assert_eq!(binop_tag(">"), Some("Gt"));
785        assert_eq!(binop_tag("and"), Some("And"));
786        assert_eq!(binop_tag("+"), Some("Add"));
787        assert_eq!(prefix_tag("-"), Some("Neg"));
788    }
789
790    #[test]
791    fn round_trip_names_for_printer() {
792        assert_eq!(dsl_name_for_tag("Average"), "sma");
793        assert_eq!(binop_symbol_for_tag("Gt"), Some(">"));
794        assert_eq!(binop_symbol_for_tag("Average"), None);
795    }
796
797    #[test]
798    fn function_ops_expose_every_row_with_schema_metadata() {
799        let ops = function_ops();
800        // One OpInfo per row in the table.
801        assert_eq!(ops.len(), ROWS.len());
802
803        // sma is the canonical name for Average and carries `average` as an alias.
804        let sma = ops.iter().find(|o| o.tag == "Average").unwrap();
805        assert_eq!(sma.name, "sma");
806        assert!(sma.aliases.contains(&"average"));
807        assert!(!sma.description.is_empty());
808
809        // Every field carries a name, a known kind, and a required flag; the field
810        // kinds collectively cover the full `field_kind` match.
811        let mut kinds = std::collections::BTreeSet::new();
812        for op in &ops {
813            for f in &op.fields {
814                assert!(!f.name.is_empty());
815                assert!(matches!(
816                    f.kind,
817                    "expr" | "number" | "string" | "bool" | "list" | "list-string"
818                ));
819                kinds.insert(f.kind);
820            }
821        }
822        // The table exercises expr and number kinds at minimum.
823        assert!(kinds.contains("expr"));
824        assert!(kinds.contains("number"));
825    }
826
827    #[test]
828    fn field_defaults_match_serde() {
829        // Documented serde defaults surface through field_default…
830        assert_eq!(field_default("StochD", "d"), Some(serde_json::json!(3)));
831        assert_eq!(field_default("Rank", "pct"), Some(serde_json::json!(true)));
832        assert_eq!(
833            field_default("Neutralize", "add_const"),
834            Some(serde_json::json!(true))
835        );
836        // …and everything else has no default.
837        assert_eq!(field_default("Average", "n"), None);
838        assert_eq!(field_default("Nope", "nope"), None);
839
840        // field_default is threaded through function_ops for the defaulted fields.
841        let stoch_d = function_ops()
842            .into_iter()
843            .find(|o| o.tag == "StochD")
844            .unwrap();
845        let d_field = stoch_d.fields.iter().find(|f| f.name == "d").unwrap();
846        assert_eq!(d_field.default, Some(serde_json::json!(3)));
847    }
848
849    #[test]
850    fn field_kind_and_required_cover_every_variant() {
851        use Field::*;
852        for (f, kind, required) in [
853            (Expr("x"), "expr", true),
854            (ExprOpt("x"), "expr", false),
855            (ExprList("x"), "list", true),
856            (Num("x"), "number", true),
857            (NumOpt("x"), "number", false),
858            (BoolOpt("x"), "bool", false),
859            (Str("x"), "string", true),
860            (StrOpt("x"), "string", false),
861            (StrListOpt("x"), "list-string", false),
862        ] {
863            assert_eq!(field_name(&f), "x");
864            assert_eq!(field_kind(&f), kind);
865            assert_eq!(field_required(&f), required);
866        }
867    }
868
869    #[test]
870    fn tag_and_operator_lookups() {
871        // sig_by_tag round-trips against op_by_name.
872        assert_eq!(sig_by_tag("Average").unwrap().tag, "Average");
873        assert!(sig_by_tag("NotATag").is_none());
874
875        // binary_operators exposes the BINOPS table.
876        let ops = binary_operators();
877        assert!(ops.contains(&(">", "Gt")));
878        assert!(ops.contains(&("/", "Div")));
879        assert_eq!(ops.len(), 10);
880
881        // Unknown / operator-style tags return "" from dsl_name_for_tag (fallback arm).
882        assert_eq!(dsl_name_for_tag("TotallyUnknownTag"), "");
883        assert_eq!(prefix_tag("+"), None);
884    }
885
886    #[test]
887    fn every_spec_op_has_a_signature_or_operator() {
888        // Every op tag from spec.rs. If a new op is added, add it here AND to the
889        // table/operator maps — this test is the completeness gate for this plan.
890        for tag in ALL_OP_TAGS {
891            let known = ROWS.iter().any(|r| r.sig.tag == *tag)
892                || binop_symbol_for_tag(tag).is_some()
893                || *tag == "Neg"
894                || *tag == "Not"
895                || *tag == "Const"
896                || *tag == "Data";
897            assert!(known, "op `{tag}` has no DSL handler");
898        }
899    }
900}