Skip to main content

lemon/
envelope.rs

1//! The **shareable strategy envelope**: a small, versioned document that wraps a
2//! strategy (as a lemon `source` string or a lowered `spec` [`Expr`] tree)
3//! together with the metadata needed to reproduce a run — a name, an optional
4//! description/author, the engine config knobs, a universe window, and the
5//! engine version it was authored against.
6//!
7//! A strategy is pure data (no market data, ever — see the licensing note in
8//! issue #30), so an envelope is safe to store, share, and re-run in another
9//! browser. The envelope references series **names + universe**, never embedded
10//! prices or fundamentals.
11//!
12//! # Shape
13//!
14//! ```jsonc
15//! {
16//!   "format": 1,                         // envelope version (this build: 1)
17//!   "name": "Cheap quality rotation",
18//!   "description": "…",                  // optional
19//!   "author": "…",                       // optional
20//!   "source": "rank(pe) < 20",           // lemon text — OR —
21//!   "spec": { "op": "…", … },            // a lowered Expr tree (exactly one of the two)
22//!   "config": { "fee_ratio": 0.001 },    // optional engine config (opaque here)
23//!   "universe": { "from": 20180101, "to": 20251231, "symbols_hint": "sp500" },
24//!   "engine_version": "yuzu-core 0.x"    // optional reproducibility pin
25//! }
26//! ```
27//!
28//! [`check`] validates the whole document and returns the resolved `Expr` tree
29//! (parsed from `source`, or the `spec` as given), so a registry / web app can
30//! reject malformed submissions with actionable errors and a runner can pull the
31//! spec straight out. The JSON Schema for external consumers lives at
32//! `schema/strategy-envelope.schema.json`.
33
34use serde::Deserialize;
35use serde_json::Value;
36
37use crate::spec::Expr;
38
39/// Envelope format version understood by this build. Bumped only on a
40/// breaking change to the envelope shape (never for a new engine op).
41pub const FORMAT_VERSION: u32 = 1;
42
43/// The parsed envelope document. Field presence mirrors the JSON; semantic
44/// validation (version, exactly-one-of spec/source, a well-formed spec) is done
45/// by [`check`], not by deserialization alone.
46#[derive(Debug, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct Envelope {
49    /// Envelope format version. Must equal [`FORMAT_VERSION`].
50    pub format: u32,
51    /// Human-readable strategy name (must be non-empty).
52    pub name: String,
53    #[serde(default)]
54    pub description: Option<String>,
55    #[serde(default)]
56    pub author: Option<String>,
57    /// A lowered `Expr` tree. Exactly one of `spec` / `source` must be present.
58    #[serde(default)]
59    pub spec: Option<Value>,
60    /// Lemon source text. Exactly one of `spec` / `source` must be present.
61    #[serde(default)]
62    pub source: Option<String>,
63    /// Engine config knobs (fees, delist, etc.). Opaque at the language layer —
64    /// the engine/runner interprets it; here we only require it be an object.
65    #[serde(default)]
66    pub config: Option<Value>,
67    #[serde(default)]
68    pub universe: Option<Universe>,
69    /// Free-form engine-version pin for reproducibility (e.g. `"yuzu-core 0.4"`).
70    #[serde(default)]
71    pub engine_version: Option<String>,
72}
73
74/// The date window + universe hint a run should cover. Names/windows only — no
75/// embedded data.
76#[derive(Debug, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct Universe {
79    /// Inclusive start date, `YYYYMMDD`.
80    #[serde(default)]
81    pub from: Option<i64>,
82    /// Inclusive end date, `YYYYMMDD`.
83    #[serde(default)]
84    pub to: Option<i64>,
85    /// A named universe hint (e.g. `"sp500"`) the runner resolves to symbols.
86    #[serde(default)]
87    pub symbols_hint: Option<String>,
88    /// An explicit symbol list, if the author pinned one.
89    #[serde(default)]
90    pub symbols: Option<Vec<String>>,
91}
92
93/// A validated envelope: the strategy name and the resolved `Expr` tree (parsed
94/// from `source`, or the `spec` verbatim). Enough to hand to a runner.
95#[derive(Debug)]
96pub struct Checked {
97    pub name: String,
98    /// The lowered strategy tree, guaranteed to deserialize into [`Expr`].
99    pub spec: Value,
100}
101
102/// Validate a strategy-envelope JSON document.
103///
104/// Returns [`Checked`] (name + resolved spec tree) on success, or the list of
105/// human-readable problems found. Checks, in order: the JSON parses; the
106/// envelope shape is valid (unknown keys rejected); `format` matches
107/// [`FORMAT_VERSION`]; `name` is non-empty; exactly one of `spec`/`source` is
108/// present; `source` parses as lemon / `spec` deserializes into a valid `Expr`;
109/// `config` (if present) is an object.
110pub fn check(doc: &str) -> Result<Checked, Vec<String>> {
111    let value: Value = serde_json::from_str(doc).map_err(|e| vec![format!("invalid JSON: {e}")])?;
112    check_value(&value)
113}
114
115/// Like [`check`], starting from an already-parsed JSON value.
116pub fn check_value(value: &Value) -> Result<Checked, Vec<String>> {
117    let env: Envelope = serde_json::from_value(value.clone())
118        .map_err(|e| vec![format!("not a valid strategy envelope: {e}")])?;
119
120    let mut errors: Vec<String> = Vec::new();
121
122    if env.format != FORMAT_VERSION {
123        errors.push(format!(
124            "unsupported envelope format {} (this build understands format {FORMAT_VERSION})",
125            env.format
126        ));
127    }
128    if env.name.trim().is_empty() {
129        errors.push("`name` must not be empty".into());
130    }
131    if let Some(config) = &env.config {
132        if !config.is_object() {
133            errors.push("`config` must be a JSON object".into());
134        }
135    }
136
137    let resolved = match (&env.spec, &env.source) {
138        (Some(_), Some(_)) => {
139            errors.push("provide exactly one of `spec` or `source`, not both".into());
140            None
141        }
142        (None, None) => {
143            errors.push("provide one of `spec` (an Expr tree) or `source` (lemon text)".into());
144            None
145        }
146        (Some(spec), None) => match serde_json::from_value::<Expr>(spec.clone()) {
147            Ok(_) => Some(spec.clone()),
148            Err(e) => {
149                errors.push(format!("`spec` is not a valid strategy tree: {e}"));
150                None
151            }
152        },
153        (None, Some(src)) => match crate::parse(src) {
154            Ok(tree) => Some(tree),
155            Err(e) => {
156                errors.push(format!(
157                    "`source` failed to parse: {}:{}: {}",
158                    e.line, e.col, e.message
159                ));
160                None
161            }
162        },
163    };
164
165    match (errors.is_empty(), resolved) {
166        (true, Some(spec)) => Ok(Checked {
167            name: env.name,
168            spec,
169        }),
170        _ => Err(errors),
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn checks_a_source_envelope_and_resolves_the_spec() {
180        let doc = r#"{
181            "format": 1,
182            "name": "Momentum",
183            "description": "buy strength",
184            "source": "close > sma(close, 2)",
185            "config": { "fee_ratio": 0.001 },
186            "universe": { "from": 20180101, "to": 20251231, "symbols_hint": "sp500" },
187            "engine_version": "yuzu-core 0.x"
188        }"#;
189        let checked = check(doc).expect("valid envelope");
190        assert_eq!(checked.name, "Momentum");
191        // Resolved spec is exactly what the parser produces for the source.
192        assert_eq!(checked.spec, crate::parse("close > sma(close, 2)").unwrap());
193        assert_eq!(checked.spec["op"], "Gt");
194    }
195
196    #[test]
197    fn checks_a_spec_envelope() {
198        let spec = crate::parse("rsi(close, 14)").unwrap();
199        let doc = serde_json::json!({
200            "format": 1,
201            "name": "RSI",
202            "spec": spec,
203        })
204        .to_string();
205        let checked = check(&doc).expect("valid spec envelope");
206        assert_eq!(checked.spec["op"], "Rsi");
207    }
208
209    #[test]
210    fn rejects_wrong_format_version() {
211        let doc = r#"{ "format": 99, "name": "X", "source": "close" }"#;
212        let errs = check(doc).unwrap_err();
213        assert!(
214            errs.iter()
215                .any(|e| e.contains("unsupported envelope format 99")),
216            "{errs:?}"
217        );
218    }
219
220    #[test]
221    fn rejects_empty_name() {
222        let doc = r#"{ "format": 1, "name": "  ", "source": "close" }"#;
223        let errs = check(doc).unwrap_err();
224        assert!(
225            errs.iter().any(|e| e.contains("`name` must not be empty")),
226            "{errs:?}"
227        );
228    }
229
230    #[test]
231    fn rejects_both_spec_and_source() {
232        let doc = r#"{ "format": 1, "name": "X", "source": "close", "spec": {"op":"Data","name":"close"} }"#;
233        let errs = check(doc).unwrap_err();
234        assert!(
235            errs.iter()
236                .any(|e| e.contains("exactly one of `spec` or `source`")),
237            "{errs:?}"
238        );
239    }
240
241    #[test]
242    fn rejects_neither_spec_nor_source() {
243        let doc = r#"{ "format": 1, "name": "X" }"#;
244        let errs = check(doc).unwrap_err();
245        assert!(
246            errs.iter().any(|e| e.contains("provide one of `spec`")),
247            "{errs:?}"
248        );
249    }
250
251    #[test]
252    fn rejects_a_malformed_spec_tree() {
253        // `Average` requires an `n` field; omitting it must be caught.
254        let doc = r#"{ "format": 1, "name": "X",
255            "spec": { "op": "Average", "of": { "op": "Data", "name": "close" } } }"#;
256        let errs = check(doc).unwrap_err();
257        assert!(
258            errs.iter().any(|e| e.contains("not a valid strategy tree")),
259            "{errs:?}"
260        );
261    }
262
263    #[test]
264    fn rejects_an_unknown_op_tag() {
265        let doc = r#"{ "format": 1, "name": "X",
266            "spec": { "op": "Nonesuch", "of": { "op": "Data", "name": "close" } } }"#;
267        let errs = check(doc).unwrap_err();
268        assert!(
269            errs.iter().any(|e| e.contains("not a valid strategy tree")),
270            "{errs:?}"
271        );
272    }
273
274    #[test]
275    fn rejects_a_source_syntax_error() {
276        let doc = r#"{ "format": 1, "name": "X", "source": "sma(close," }"#;
277        let errs = check(doc).unwrap_err();
278        assert!(
279            errs.iter().any(|e| e.contains("`source` failed to parse")),
280            "{errs:?}"
281        );
282    }
283
284    #[test]
285    fn rejects_unknown_envelope_keys() {
286        let doc = r#"{ "format": 1, "name": "X", "source": "close", "descroption": "typo" }"#;
287        let errs = check(doc).unwrap_err();
288        assert!(
289            errs.iter()
290                .any(|e| e.contains("not a valid strategy envelope")),
291            "{errs:?}"
292        );
293    }
294
295    #[test]
296    fn rejects_non_object_config() {
297        let doc = r#"{ "format": 1, "name": "X", "source": "close", "config": 3 }"#;
298        let errs = check(doc).unwrap_err();
299        assert!(
300            errs.iter()
301                .any(|e| e.contains("`config` must be a JSON object")),
302            "{errs:?}"
303        );
304    }
305
306    #[test]
307    fn checked_in_schema_tracks_the_format_version() {
308        // Guard against the committed JSON Schema drifting from the code: its
309        // `format.const` must equal FORMAT_VERSION. CARGO_MANIFEST_DIR is
310        // <workspace>/crates/lemon-lang, so schema/ is two levels up.
311        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
312            .parent()
313            .and_then(|p| p.parent())
314            .expect("workspace root")
315            .join("schema/strategy-envelope.schema.json");
316        let body = std::fs::read_to_string(&path).expect("read envelope schema");
317        let schema: Value = serde_json::from_str(&body).expect("schema is valid JSON");
318        assert_eq!(
319            schema["properties"]["format"]["const"].as_u64(),
320            Some(FORMAT_VERSION as u64),
321            "schema/strategy-envelope.schema.json format const != FORMAT_VERSION",
322        );
323    }
324}