lemon/spec.rs
1//! [`Expr`]: the serializable strategy AST. A JSON tree (tagged by `"op"`) that the
2//! website and the batch runner both emit; the engine's evaluator walks it against a data
3//! context to produce a position matrix.
4
5use serde::Deserialize;
6
7fn default_true() -> bool {
8 true
9}
10
11fn default_stoch_d() -> usize {
12 3
13}
14
15fn default_macd_fast() -> usize {
16 12
17}
18
19fn default_macd_slow() -> usize {
20 26
21}
22
23fn default_macd_signal() -> usize {
24 9
25}
26
27fn default_bollinger_k() -> f64 {
28 2.0
29}
30
31fn default_cap_max_weight() -> f64 {
32 0.3
33}
34
35fn default_vol_target() -> f64 {
36 0.1
37}
38
39fn default_vol_target_n() -> usize {
40 63
41}
42
43#[derive(Debug, Deserialize)]
44#[serde(tag = "op")]
45pub enum Expr {
46 /// A raw input series by name (e.g. close, pe, revenue_growth).
47 Data { name: String },
48 /// A constant scalar value, broadcast across the panel.
49 Const { value: f64 },
50 /// Simple moving average of `of` over `n` days.
51 Average { of: Box<Expr>, n: usize },
52 /// Exponential moving average of `of` over `n` days.
53 Ema { of: Box<Expr>, n: usize },
54 /// Rolling standard deviation of `of` over `n` days.
55 Std { of: Box<Expr>, n: usize },
56 /// Relative Strength Index of `of` over `n` days.
57 Rsi { of: Box<Expr>, n: usize },
58 /// Percentage change of `of` over `n` days.
59 PctChange { of: Box<Expr>, n: usize },
60 /// 1 where `of` rose for `n` consecutive days, else 0.
61 Rise { of: Box<Expr>, n: usize },
62 /// `of` lagged forward by `n` days.
63 Shift { of: Box<Expr>, n: usize },
64 /// Rolling maximum of `of` over `n` days.
65 RollingMax { of: Box<Expr>, n: usize },
66 /// Rolling minimum of `of` over `n` days.
67 RollingMin { of: Box<Expr>, n: usize },
68 /// Bollinger mid band: the `n`-day simple moving average of `of`.
69 BollingerMid { of: Box<Expr>, n: usize },
70 /// Bollinger upper band: `sma(of, n) + k * std(of, n)` (`k` defaults to 2).
71 BollingerUpper {
72 of: Box<Expr>,
73 n: usize,
74 #[serde(default = "default_bollinger_k")]
75 k: f64,
76 },
77 /// Bollinger lower band: `sma(of, n) - k * std(of, n)` (`k` defaults to 2).
78 BollingerLower {
79 of: Box<Expr>,
80 n: usize,
81 #[serde(default = "default_bollinger_k")]
82 k: f64,
83 },
84 /// MACD line: `ema(of, fast) - ema(of, slow)` (defaults 12/26).
85 Macd {
86 of: Box<Expr>,
87 #[serde(default = "default_macd_fast")]
88 fast: usize,
89 #[serde(default = "default_macd_slow")]
90 slow: usize,
91 },
92 /// MACD signal line: `signal`-day EMA of the MACD line (defaults 12/26/9).
93 MacdSignal {
94 of: Box<Expr>,
95 #[serde(default = "default_macd_fast")]
96 fast: usize,
97 #[serde(default = "default_macd_slow")]
98 slow: usize,
99 #[serde(default = "default_macd_signal")]
100 signal: usize,
101 },
102 /// MACD histogram: MACD line minus its signal line (defaults 12/26/9).
103 MacdHist {
104 of: Box<Expr>,
105 #[serde(default = "default_macd_fast")]
106 fast: usize,
107 #[serde(default = "default_macd_slow")]
108 slow: usize,
109 #[serde(default = "default_macd_signal")]
110 signal: usize,
111 },
112 /// Donchian channel upper band: rolling `n`-day high of `of`.
113 DonchianHigh { of: Box<Expr>, n: usize },
114 /// Donchian channel lower band: rolling `n`-day low of `of`.
115 DonchianLow { of: Box<Expr>, n: usize },
116 /// Donchian channel mid-line: `(rolling_max + rolling_min) / 2` over `n` days.
117 DonchianMid { of: Box<Expr>, n: usize },
118 /// Average True Range over `n` days from high/low/close.
119 Atr {
120 high: Box<Expr>,
121 low: Box<Expr>,
122 close: Box<Expr>,
123 n: usize,
124 },
125 /// Normalized ATR (percent) over `n` days.
126 Natr {
127 high: Box<Expr>,
128 low: Box<Expr>,
129 close: Box<Expr>,
130 n: usize,
131 },
132 /// Williams %R over `n` days.
133 WillR {
134 high: Box<Expr>,
135 low: Box<Expr>,
136 close: Box<Expr>,
137 n: usize,
138 },
139 /// Commodity Channel Index over `n` days.
140 Cci {
141 high: Box<Expr>,
142 low: Box<Expr>,
143 close: Box<Expr>,
144 n: usize,
145 },
146 /// Stochastic %K over `n` days.
147 StochK {
148 high: Box<Expr>,
149 low: Box<Expr>,
150 close: Box<Expr>,
151 n: usize,
152 },
153 /// Stochastic %D: `d`-day average of %K over `n` days.
154 StochD {
155 high: Box<Expr>,
156 low: Box<Expr>,
157 close: Box<Expr>,
158 n: usize,
159 #[serde(default = "default_stoch_d")]
160 d: usize,
161 },
162 /// Aroon Up over `n` days from high.
163 AroonUp { high: Box<Expr>, n: usize },
164 /// Aroon Down over `n` days from low.
165 AroonDown { low: Box<Expr>, n: usize },
166 /// Average Directional Index over `n` days.
167 Adx {
168 high: Box<Expr>,
169 low: Box<Expr>,
170 close: Box<Expr>,
171 n: usize,
172 },
173 /// Plus Directional Indicator (+DI) over `n` days.
174 PlusDi {
175 high: Box<Expr>,
176 low: Box<Expr>,
177 close: Box<Expr>,
178 n: usize,
179 },
180 /// Minus Directional Indicator (−DI) over `n` days.
181 MinusDi {
182 high: Box<Expr>,
183 low: Box<Expr>,
184 close: Box<Expr>,
185 n: usize,
186 },
187 /// On-Balance Volume from close and volume.
188 Obv { close: Box<Expr>, volume: Box<Expr> },
189 /// Money Flow Index over `n` days.
190 Mfi {
191 high: Box<Expr>,
192 low: Box<Expr>,
193 close: Box<Expr>,
194 volume: Box<Expr>,
195 n: usize,
196 },
197 /// Volume-Weighted Average Price over `n` days from high/low/close/volume.
198 Vwap {
199 high: Box<Expr>,
200 low: Box<Expr>,
201 close: Box<Expr>,
202 volume: Box<Expr>,
203 n: usize,
204 },
205 /// 1 where `of` fell for `n` consecutive days, else 0.
206 Fall { of: Box<Expr>, n: usize },
207 /// 1 for the `n` highest values per row (cross-section), else 0.
208 IsLargest { of: Box<Expr>, n: usize },
209 /// 1 for the `n` lowest values per row (cross-section), else 0.
210 IsSmallest { of: Box<Expr>, n: usize },
211 /// 1 where `of` held true at least `nsatisfy` times within the last `nwindow` rows.
212 Sustain {
213 of: Box<Expr>,
214 nwindow: usize,
215 nsatisfy: Option<usize>,
216 },
217 /// 1 on the row where `of` turns false→true (rising edge).
218 IsEntry { of: Box<Expr> },
219 /// 1 on the row where `of` turns true→false (falling edge).
220 IsExit { of: Box<Expr> },
221 /// Hold true from an entry edge until an exit edge (or the `exit` panel is true).
222 ExitWhen { entry: Box<Expr>, exit: Box<Expr> },
223 /// Per-row quantile of `of` across symbols; collapses to one column (`quantile`).
224 QuantileRow { of: Box<Expr>, c: f64 },
225 /// Per-row winsorize: clip to empirical quantiles `lower`/`upper` in `[0, 1]`.
226 Winsorize {
227 of: Box<Expr>,
228 lower: f64,
229 upper: f64,
230 },
231 /// Per-row z-score `(x − mean) / std` (population std); constant rows → 0.
232 Zscore { of: Box<Expr> },
233 /// Per-row quantile buckets labeled 1..=`n` (NaN preserved).
234 Bucket { of: Box<Expr>, n: usize },
235 /// Per-row demean: subtract the cross-sectional mean of non-NaN cells.
236 Demean { of: Box<Expr> },
237 /// 1 where `l` is greater than `r`, else 0.
238 Gt { l: Box<Expr>, r: Box<Expr> },
239 /// 1 where `l` is less than `r`, else 0.
240 Lt { l: Box<Expr>, r: Box<Expr> },
241 /// 1 where `l` is greater than or equal to `r`, else 0.
242 Ge { l: Box<Expr>, r: Box<Expr> },
243 /// 1 where `l` is less than or equal to `r`, else 0.
244 Le { l: Box<Expr>, r: Box<Expr> },
245 /// Logical AND of two boolean panels.
246 And { l: Box<Expr>, r: Box<Expr> },
247 /// Logical OR of two boolean panels.
248 Or { l: Box<Expr>, r: Box<Expr> },
249 /// Logical NOT of a boolean panel (NaN is falsy, so `not` of NaN is 1).
250 Not { of: Box<Expr> },
251 /// Element-wise `l` + `r`.
252 Add { l: Box<Expr>, r: Box<Expr> },
253 /// Element-wise `l` − `r`.
254 Sub { l: Box<Expr>, r: Box<Expr> },
255 /// Element-wise `l` × `r`.
256 Mul { l: Box<Expr>, r: Box<Expr> },
257 /// Element-wise `l` ÷ `r`.
258 Div { l: Box<Expr>, r: Box<Expr> },
259 /// Negation (−`of`).
260 Neg { of: Box<Expr> },
261 /// Ceiling of `of`.
262 Ceil { of: Box<Expr> },
263 /// Cross-sectional rank of `of` per row; `pct` for 0..1 percentile, `ascending` sets direction.
264 Rank {
265 of: Box<Expr>,
266 #[serde(default = "default_true")]
267 pct: bool,
268 #[serde(default = "default_true")]
269 ascending: bool,
270 },
271 /// `of` kept only where `by` is true; elsewhere dropped.
272 Mask { of: Box<Expr>, by: Box<Expr> },
273 /// Scale each row so gross weight (Σ|w|) is 1; NaN preserved, zero rows unchanged.
274 NormalizeRow { of: Box<Expr> },
275 /// Scale each row of the weight panel `of` toward an annualized portfolio-vol
276 /// `target` (default 0.1) over a trailing `n`-return window (default 63) of
277 /// `prices`; deleverage only (scale capped at 1).
278 VolTarget {
279 of: Box<Expr>,
280 prices: Box<Expr>,
281 #[serde(default = "default_vol_target")]
282 target: f64,
283 #[serde(default = "default_vol_target_n")]
284 n: usize,
285 },
286 /// Stateful rotation: enter on `entry`, exit on `exit`, hold up to `nstocks_limit` (prioritised by `rank`). Price stops live in the backtest config, not here.
287 HoldUntil {
288 entry: Box<Expr>,
289 exit: Box<Expr>,
290 nstocks_limit: Option<usize>,
291 rank: Option<Box<Expr>>,
292 },
293 /// Hold `of`, refreshing on calendar `freq` (W/ME/QE) or on dates where `on` is true.
294 Rebalance {
295 of: Box<Expr>,
296 #[serde(default)]
297 freq: Option<String>,
298 #[serde(default)]
299 on: Option<Box<Expr>>,
300 },
301 /// Cross-sectionally regress `of` against the `by` factors, optionally adding a constant.
302 Neutralize {
303 of: Box<Expr>,
304 by: Vec<Expr>,
305 #[serde(default = "default_true")]
306 add_const: bool,
307 },
308 /// Neutralize `of` within each industry/sector.
309 NeutralizeIndustry {
310 of: Box<Expr>,
311 #[serde(default = "default_true")]
312 add_const: bool,
313 },
314 /// Rank `of` within each industry, optionally limited to `categories`.
315 IndustryRank {
316 of: Box<Expr>,
317 #[serde(default)]
318 categories: Option<Vec<String>>,
319 },
320 /// Cap each industry's gross weight in the weight panel `of` at `max_weight`
321 /// (scale that industry's names down proportionally; `max_weight` defaults to 0.3).
322 CapIndustry {
323 of: Box<Expr>,
324 #[serde(default = "default_cap_max_weight")]
325 max_weight: f64,
326 },
327 /// Aggregate `of` within each industry using `agg` (e.g. mean).
328 GroupbyCategory { of: Box<Expr>, agg: String },
329 /// Boolean mask: `1` where the symbol's industry equals `name` (exact, case-sensitive).
330 /// Shape follows `of`; symbols missing from the industry map are `0`.
331 InSector { of: Box<Expr>, name: String },
332}