1#[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
31pub 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
44pub 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
58struct 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
555pub 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
585pub struct FieldInfo {
587 pub name: &'static str,
588 pub kind: &'static str,
590 pub required: bool,
591 pub default: Option<serde_json::Value>,
592}
593
594pub 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
604pub 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
630pub 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
645pub 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 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
691pub 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 assert_eq!(ops.len(), ROWS.len());
798
799 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 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 assert!(kinds.contains("expr"));
820 assert!(kinds.contains("number"));
821 }
822
823 #[test]
824 fn field_defaults_match_serde() {
825 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 assert_eq!(field_default("Average", "n"), None);
834 assert_eq!(field_default("Nope", "nope"), None);
835
836 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 assert_eq!(sig_by_tag("Average").unwrap().tag, "Average");
869 assert!(sig_by_tag("NotATag").is_none());
870
871 let ops = binary_operators();
873 assert!(ops.contains(&(">", "Gt")));
874 assert!(ops.contains(&("/", "Div")));
875 assert_eq!(ops.len(), 10);
876
877 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 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}