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 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
559pub 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
589pub struct FieldInfo {
591 pub name: &'static str,
592 pub kind: &'static str,
594 pub required: bool,
595 pub default: Option<serde_json::Value>,
596}
597
598pub 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
608pub 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
634pub 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
649pub 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 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
695pub 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 assert_eq!(ops.len(), ROWS.len());
802
803 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 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 assert!(kinds.contains("expr"));
824 assert!(kinds.contains("number"));
825 }
826
827 #[test]
828 fn field_defaults_match_serde() {
829 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 assert_eq!(field_default("Average", "n"), None);
838 assert_eq!(field_default("Nope", "nope"), None);
839
840 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 assert_eq!(sig_by_tag("Average").unwrap().tag, "Average");
873 assert!(sig_by_tag("NotATag").is_none());
874
875 let ops = binary_operators();
877 assert!(ops.contains(&(">", "Gt")));
878 assert!(ops.contains(&("/", "Div")));
879 assert_eq!(ops.len(), 10);
880
881 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 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}