Skip to main content

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}