Skip to main content

wickra_backtest_core/
spec.rs

1//! The data-driven strategy specification (`StrategySpec`).
2//!
3//! A strategy is **data, not code** — a JSON document — so the exact same
4//! strategy runs identically across every Wickra language binding and over the
5//! C-ABI. This module defines the serde representation of the spec and a
6//! structural [`StrategySpec::validate`] that checks every indicator reference
7//! is declared.
8//!
9//! See `schema/strategy_spec.schema.json` (generated) for the canonical schema.
10
11use std::collections::{BTreeMap, BTreeSet};
12
13use serde::{Deserialize, Serialize};
14
15use crate::error::{BacktestError, Result};
16use crate::registry::feed_of;
17
18/// Current strategy-spec format version. Bumped on breaking DSL changes.
19pub const SPEC_VERSION: u32 = 1;
20
21fn default_spec_version() -> u32 {
22    SPEC_VERSION
23}
24
25/// A complete strategy specification.
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
27pub struct StrategySpec {
28    /// Spec format version (defaults to [`SPEC_VERSION`]).
29    ///
30    /// A spec from an older format still parses -- the DSL only grows within a
31    /// version -- but one declaring a newer format is rejected rather than
32    /// read with fields this build does not know about.
33    #[serde(default = "default_spec_version")]
34    pub spec_version: u32,
35    /// Primary trading symbol.
36    ///
37    /// Metadata, not an input: the engine never resolves it and cannot fetch
38    /// data. The caller supplies the candles, and this records which instrument
39    /// they are expected to be, so a stored spec still says what it was written
40    /// for.
41    pub symbol: String,
42    /// The reference instrument a pairwise indicator is meant to be run against.
43    ///
44    /// Also metadata. Naming it here does not load anything: the reference series
45    /// is passed alongside the candles, through `RunRequest.reference` or
46    /// `run_with_ref`. This field records which instrument the caller is expected
47    /// to pass, so a spec using a pairwise indicator is not ambiguous about what
48    /// it is pairing against.
49    #[serde(default)]
50    pub ref_symbol: Option<String>,
51    /// Bar timeframe (e.g. `"1h"`).
52    ///
53    /// Metadata, like `symbol`: a free-form label recording the bar size the spec
54    /// was written for. The engine reads whatever candles it is given and does not
55    /// check them against this.
56    pub timeframe: String,
57    /// Named indicators available to the rules.
58    pub indicators: BTreeMap<String, IndicatorSpec>,
59    /// Long-entry condition.
60    pub entry: Condition,
61    /// Long-exit condition.
62    pub exit: Condition,
63    /// Optional short-entry condition.
64    #[serde(default)]
65    pub short_entry: Option<Condition>,
66    /// Optional short-exit condition.
67    #[serde(default)]
68    pub short_exit: Option<Condition>,
69    /// Position sizing.
70    pub sizing: Sizing,
71    /// Trading costs.
72    #[serde(default)]
73    pub costs: Costs,
74    /// Risk controls.
75    #[serde(default)]
76    pub risk: Risk,
77    /// Execution model.
78    #[serde(default)]
79    pub execution: Execution,
80    /// Explicit warmup bars (defaults to the max indicator warmup).
81    #[serde(default)]
82    pub warmup: Option<u32>,
83}
84
85impl StrategySpec {
86    /// Parse a spec from JSON and validate it.
87    pub fn parse(json: &str) -> Result<Self> {
88        let spec: Self =
89            serde_json::from_str(json).map_err(|e| BacktestError::InvalidSpec(e.to_string()))?;
90        spec.validate()?;
91        Ok(spec)
92    }
93
94    /// Validate structural invariants: every indicator referenced by the rules
95    /// must be declared in `indicators`.
96    pub fn validate(&self) -> Result<()> {
97        let declared: BTreeSet<&str> = self.indicators.keys().map(String::as_str).collect();
98        check_condition(&self.entry, &declared)?;
99        check_condition(&self.exit, &declared)?;
100        if let Some(c) = &self.short_entry {
101            check_condition(c, &declared)?;
102        }
103        if let Some(c) = &self.short_exit {
104            check_condition(c, &declared)?;
105        }
106        // Refuse a format this build cannot know how to read. Accepting it would
107        // mean silently ignoring whatever the newer version added, which produces
108        // a run that looks successful and answers a different question than the
109        // spec asked. Older versions stay readable: the DSL only grows within a
110        // version, so nothing an old spec says has changed meaning.
111        if self.spec_version == 0 || self.spec_version > SPEC_VERSION {
112            return Err(BacktestError::InvalidSpec(format!(
113                "spec_version {} is not supported; this build reads 1..={SPEC_VERSION}",
114                self.spec_version
115            )));
116        }
117        // Risk-per-trade sizes the position from the distance to the stop, so
118        // without a stop there is no distance and nothing to size from. The
119        // documentation already says the two go together; this makes it true.
120        if matches!(self.sizing, Sizing::RiskPerTrade { .. }) && self.risk.stop_loss_pct.is_none() {
121            return Err(BacktestError::InvalidSpec(
122                "sizing risk_per_trade requires risk.stop_loss_pct: the position size is                  derived from the distance to the stop"
123                    .into(),
124            ));
125        }
126        // A declared feed is redundant -- the indicator type already determines it --
127        // so the only thing it can do is contradict the indicator, and that is what
128        // this catches. An unknown kind is left to `build`, which reports it with a
129        // better message than this could.
130        for (name, ind) in &self.indicators {
131            let (Some(declared), Some(actual)) = (ind.feed, feed_of(&ind.kind)) else {
132                continue;
133            };
134            if declared != actual {
135                return Err(BacktestError::InvalidSpec(format!(
136                    "indicator '{name}' ({}) declares feed {declared:?} but consumes {actual:?}",
137                    ind.kind
138                )));
139            }
140        }
141        match self.execution.order_type {
142            OrderType::Limit if self.execution.limit_offset_pct.is_none() => {
143                return Err(BacktestError::InvalidSpec(
144                    "limit order_type requires execution.limit_offset_pct".into(),
145                ));
146            }
147            OrderType::Stop if self.execution.stop_offset_pct.is_none() => {
148                return Err(BacktestError::InvalidSpec(
149                    "stop order_type requires execution.stop_offset_pct".into(),
150                ));
151            }
152            OrderType::StopLimit
153                if self.execution.stop_offset_pct.is_none()
154                    || self.execution.limit_offset_pct.is_none() =>
155            {
156                return Err(BacktestError::InvalidSpec(
157                    "stop_limit order_type requires both execution.stop_offset_pct and                      execution.limit_offset_pct"
158                        .into(),
159                ));
160            }
161            _ => {}
162        }
163        if self.execution.partial_fills && self.execution.max_participation.is_none() {
164            return Err(BacktestError::InvalidSpec(
165                "partial_fills requires execution.max_participation".into(),
166            ));
167        }
168        if matches!(self.execution.fill_timing, FillTiming::Close) {
169            // Close fills happen on the signalling bar itself, which the resting
170            // limit/stop and latency models (both next-bar) cannot express.
171            if !matches!(self.execution.order_type, OrderType::Market) {
172                return Err(BacktestError::InvalidSpec(
173                    "fill_timing close requires a market order_type".into(),
174                ));
175            }
176            if self.execution.latency_bars != 0 {
177                return Err(BacktestError::InvalidSpec(
178                    "fill_timing close is incompatible with latency_bars".into(),
179                ));
180            }
181        }
182        Ok(())
183    }
184}
185
186/// One indicator instance: a `wickra-core` type name plus its parameters.
187#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
188pub struct IndicatorSpec {
189    /// The `wickra-core` indicator type name (e.g. `"Ema"`).
190    #[serde(rename = "type")]
191    pub kind: String,
192    /// Constructor parameters.
193    #[serde(default)]
194    pub params: Vec<f64>,
195    /// Which feed drives it. Optional, and redundant when present: the indicator
196    /// type already determines its feed. State it and the spec is cross-checked
197    /// against the registry, so a rename or a copied block that no longer matches
198    /// the indicator fails at parse instead of silently producing no values.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub feed: Option<Feed>,
201}
202
203/// The data feed an indicator is driven by.
204#[derive(
205    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
206)]
207#[serde(rename_all = "snake_case")]
208pub enum Feed {
209    /// OHLCV candles (default). Also the feed for pairwise indicators, which are
210    /// fed the bar close alongside the reference series' close.
211    #[default]
212    Kline,
213    /// Trade prints.
214    Trade,
215    /// Order-book snapshots.
216    Orderbook,
217    /// Trade prints quoted against the book mid.
218    TradeQuote,
219    /// Perpetual/derivatives ticks — funding, open interest, mark and index.
220    Derivatives,
221    /// The market cross-section, for breadth indicators.
222    CrossSection,
223}
224
225/// A price field of the current bar.
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
227#[serde(rename_all = "snake_case")]
228pub enum PriceField {
229    /// Open.
230    Open,
231    /// High.
232    High,
233    /// Low.
234    Low,
235    /// Close.
236    Close,
237    /// Volume.
238    Volume,
239    /// `(high + low + close) / 3`.
240    Hlc3,
241    /// `(open + high + low + close) / 4`.
242    Ohlc4,
243}
244
245/// A value node — evaluates to a number each bar.
246#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
247#[serde(untagged)]
248pub enum Operand {
249    /// Indicator reference by name, optionally `"name.field"` for multi-output.
250    Ref(String),
251    /// A literal constant.
252    Const(f64),
253    /// A compound expression.
254    Expr(Box<OperandExpr>),
255}
256
257/// The object-shaped operand forms.
258#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
259#[serde(rename_all = "snake_case")]
260pub enum OperandExpr {
261    /// A price field of the current bar.
262    Price(PriceField),
263    /// The value of an operand `n` bars ago: `["operand", n]`.
264    Prev((Box<Operand>, u32)),
265    /// `a + b`.
266    Add((Box<Operand>, Box<Operand>)),
267    /// `a - b`.
268    Sub((Box<Operand>, Box<Operand>)),
269    /// `a * b`.
270    Mul((Box<Operand>, Box<Operand>)),
271    /// `a / b`.
272    Div((Box<Operand>, Box<Operand>)),
273}
274
275/// A boolean node — evaluates to true/false each bar.
276#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
277#[serde(rename_all = "snake_case")]
278pub enum Condition {
279    /// `a > b`.
280    Gt((Operand, Operand)),
281    /// `a < b`.
282    Lt((Operand, Operand)),
283    /// `a >= b`.
284    Ge((Operand, Operand)),
285    /// `a <= b`.
286    Le((Operand, Operand)),
287    /// `a == b`.
288    Eq((Operand, Operand)),
289    /// `a != b`.
290    Ne((Operand, Operand)),
291    /// `a` crosses above `b` this bar.
292    CrossAbove((Operand, Operand)),
293    /// `a` crosses below `b` this bar.
294    CrossBelow((Operand, Operand)),
295    /// `lo <= a <= hi`: `[a, lo, hi]`.
296    Between((Operand, Operand, Operand)),
297    /// `a` is greater than its value `n` bars ago: `[a, n]`.
298    Rising((Operand, u32)),
299    /// `a` is less than its value `n` bars ago: `[a, n]`.
300    Falling((Operand, u32)),
301    /// All sub-conditions true (AND).
302    All(Vec<Condition>),
303    /// Any sub-condition true (OR).
304    Any(Vec<Condition>),
305    /// Negation.
306    Not(Box<Condition>),
307    /// True iff a position is currently open.
308    InPosition(bool),
309    /// Predicate on the number of bars since entry.
310    BarsSinceEntry(IntPredicate),
311}
312
313/// An integer comparison predicate (used by stateful conditions).
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
315#[serde(rename_all = "snake_case")]
316pub enum IntPredicate {
317    /// `> n`.
318    Gt(u32),
319    /// `< n`.
320    Lt(u32),
321    /// `>= n`.
322    Ge(u32),
323    /// `<= n`.
324    Le(u32),
325    /// `== n`.
326    Eq(u32),
327}
328
329/// Position sizing model.
330#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
331#[serde(tag = "type", rename_all = "snake_case")]
332pub enum Sizing {
333    /// A fraction of current equity.
334    FixedFraction {
335        /// Fraction in `[0, 1]`.
336        fraction: f64,
337    },
338    /// A fixed quantity of the base asset.
339    FixedQty {
340        /// Quantity.
341        qty: f64,
342    },
343    /// A fixed cash notional.
344    FixedCash {
345        /// Cash amount.
346        cash: f64,
347    },
348    /// Size to a target volatility: the position notional is scaled so the
349    /// position's per-bar return volatility approximates `target_vol`. With
350    /// realized per-bar volatility `rv` over `lookback` bars, the notional is
351    /// `equity * target_vol / rv` (then capped by the leverage limits). No
352    /// position is taken until `lookback` bars of history exist.
353    VolTarget {
354        /// Target per-bar return volatility, as a fraction (e.g. `0.02` = 2%).
355        target_vol: f64,
356        /// Lookback bars for the realized-volatility estimate.
357        lookback: u32,
358    },
359    /// Size from the stop-loss distance and a per-trade risk budget.
360    RiskPerTrade {
361        /// Risk per trade in percent of equity.
362        risk_pct: f64,
363    },
364}
365
366/// Trading costs.
367#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, schemars::JsonSchema)]
368pub struct Costs {
369    /// Maker fee in basis points.
370    #[serde(default)]
371    pub maker_bps: f64,
372    /// Taker fee in basis points.
373    #[serde(default)]
374    pub taker_bps: f64,
375    /// Slippage model.
376    #[serde(default)]
377    pub slippage: Slippage,
378    /// Charge perpetual funding each bar to an open position, using the
379    /// derivatives feed's funding rate and mark price (longs pay when the rate
380    /// is positive, shorts receive). Requires a derivatives feed; default off.
381    #[serde(default)]
382    pub funding: bool,
383}
384
385/// Slippage model.
386#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
387#[serde(tag = "type", rename_all = "snake_case")]
388pub enum Slippage {
389    /// A fixed number of basis points.
390    FixedBps {
391        /// Basis points.
392        bps: f64,
393    },
394    /// Slippage equal to the bid/ask spread (needs an order-book feed).
395    Spread,
396    /// Linear price impact in the traded volume.
397    VolumeImpact {
398        /// Impact coefficient.
399        coef: f64,
400    },
401}
402
403impl Default for Slippage {
404    fn default() -> Self {
405        Self::FixedBps { bps: 0.0 }
406    }
407}
408
409/// Risk controls (all optional).
410#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, schemars::JsonSchema)]
411pub struct Risk {
412    /// Stop-loss as a percent move against the position.
413    #[serde(default)]
414    pub stop_loss_pct: Option<f64>,
415    /// Take-profit as a percent move in favour.
416    #[serde(default)]
417    pub take_profit_pct: Option<f64>,
418    /// Trailing-stop as a percent retrace from the peak.
419    #[serde(default)]
420    pub trailing_stop_pct: Option<f64>,
421    /// Maximum leverage.
422    #[serde(default)]
423    pub max_leverage: Option<f64>,
424    /// Maximum position as a percent of equity.
425    #[serde(default)]
426    pub max_position_pct: Option<f64>,
427    /// Liquidate a leveraged position intrabar at its bankruptcy price (where
428    /// account equity reaches zero). Only bites above 1x leverage; default off.
429    #[serde(default)]
430    pub liquidation: bool,
431}
432
433/// Execution model.
434#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, schemars::JsonSchema)]
435pub struct Execution {
436    /// Order type.
437    #[serde(default)]
438    pub order_type: OrderType,
439    /// When a signalled order fills.
440    #[serde(default)]
441    pub fill_timing: FillTiming,
442    /// Limit-order trigger as a percent offset from the signal bar's close
443    /// (required for `order_type = "limit"`). Negative places a long limit
444    /// below the market (buy the dip); positive places a short limit above it.
445    #[serde(default)]
446    pub limit_offset_pct: Option<f64>,
447    /// Stop-order trigger as a percent offset from the signal bar's close
448    /// (required for `order_type = "stop"`). Positive places a long stop above
449    /// the market (breakout); negative places a short stop below it.
450    #[serde(default)]
451    pub stop_offset_pct: Option<f64>,
452    /// Simulated latency in bars before a fill.
453    #[serde(default)]
454    pub latency_bars: u32,
455    /// Whether partial fills are modelled. When set, an entry fills at most
456    /// `max_participation * bar_volume` and the unfilled remainder is cancelled.
457    #[serde(default)]
458    pub partial_fills: bool,
459    /// Maximum fraction of a bar's volume a single entry may consume (required
460    /// when `partial_fills` is set).
461    #[serde(default)]
462    pub max_participation: Option<f64>,
463}
464
465/// Order type.
466#[derive(
467    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
468)]
469#[serde(rename_all = "snake_case")]
470pub enum OrderType {
471    /// Market order (default).
472    #[default]
473    Market,
474    /// Limit order.
475    Limit,
476    /// Stop order.
477    Stop,
478    /// Stop-limit order.
479    StopLimit,
480}
481
482/// When a signalled order fills.
483#[derive(
484    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
485)]
486#[serde(rename_all = "snake_case")]
487pub enum FillTiming {
488    /// On the next bar's open — the look-ahead-bias-free default.
489    #[default]
490    NextOpen,
491    /// On the signalling bar's own close (close-to-close execution). An opt-in,
492    /// deliberately optimistic mode: the fill uses the very close that produced
493    /// the signal, which is not actually tradeable in live execution. Market
494    /// orders only, and incompatible with `latency_bars`.
495    Close,
496}
497
498// --- validation helpers ------------------------------------------------------
499
500fn check_operand(op: &Operand, declared: &BTreeSet<&str>) -> Result<()> {
501    match op {
502        Operand::Ref(name) => {
503            let base = name.split('.').next().unwrap_or(name.as_str());
504            if !declared.contains(base) {
505                return Err(BacktestError::UndeclaredRef(name.clone()));
506            }
507        }
508        Operand::Const(_) => {}
509        Operand::Expr(expr) => match expr.as_ref() {
510            OperandExpr::Price(_) => {}
511            OperandExpr::Prev((a, _)) => check_operand(a, declared)?,
512            OperandExpr::Add((a, b))
513            | OperandExpr::Sub((a, b))
514            | OperandExpr::Mul((a, b))
515            | OperandExpr::Div((a, b)) => {
516                check_operand(a, declared)?;
517                check_operand(b, declared)?;
518            }
519        },
520    }
521    Ok(())
522}
523
524fn check_condition(cond: &Condition, declared: &BTreeSet<&str>) -> Result<()> {
525    match cond {
526        Condition::Gt((a, b))
527        | Condition::Lt((a, b))
528        | Condition::Ge((a, b))
529        | Condition::Le((a, b))
530        | Condition::Eq((a, b))
531        | Condition::Ne((a, b))
532        | Condition::CrossAbove((a, b))
533        | Condition::CrossBelow((a, b)) => {
534            check_operand(a, declared)?;
535            check_operand(b, declared)?;
536        }
537        Condition::Between((a, lo, hi)) => {
538            check_operand(a, declared)?;
539            check_operand(lo, declared)?;
540            check_operand(hi, declared)?;
541        }
542        Condition::Rising((a, _)) | Condition::Falling((a, _)) => check_operand(a, declared)?,
543        Condition::All(cs) | Condition::Any(cs) => {
544            for c in cs {
545                check_condition(c, declared)?;
546            }
547        }
548        Condition::Not(c) => check_condition(c, declared)?,
549        Condition::InPosition(_) | Condition::BarsSinceEntry(_) => {}
550    }
551    Ok(())
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557
558    const EXAMPLE: &str = r#"{
559      "spec_version": 1, "symbol": "BTCUSDT", "timeframe": "1h",
560      "indicators": {
561        "ema_fast": {"type": "Ema", "params": [20]},
562        "ema_slow": {"type": "Ema", "params": [50]},
563        "rsi": {"type": "Rsi", "params": [14]}
564      },
565      "entry": {"all": [{"cross_above": ["ema_fast", "ema_slow"]}, {"lt": ["rsi", 70]}]},
566      "exit": {"any": [{"cross_below": ["ema_fast", "ema_slow"]}, {"gt": ["rsi", 80]}]},
567      "sizing": {"type": "fixed_fraction", "fraction": 0.95},
568      "costs": {"maker_bps": 2, "taker_bps": 5, "slippage": {"type": "fixed_bps", "bps": 2}},
569      "risk": {"stop_loss_pct": 2.0, "take_profit_pct": 5.0},
570      "execution": {"order_type": "market", "fill_timing": "next_open"}
571    }"#;
572
573    #[test]
574    fn parses_and_validates_example() {
575        let spec = StrategySpec::parse(EXAMPLE).unwrap();
576        assert_eq!(spec.spec_version, 1);
577        assert_eq!(spec.symbol, "BTCUSDT");
578        assert_eq!(spec.indicators.len(), 3);
579        assert!(matches!(spec.sizing, Sizing::FixedFraction { .. }));
580        assert!(matches!(spec.execution.fill_timing, FillTiming::NextOpen));
581    }
582
583    #[test]
584    fn roundtrips_losslessly() {
585        let spec = StrategySpec::parse(EXAMPLE).unwrap();
586        let json = serde_json::to_string(&spec).unwrap();
587        let again: StrategySpec = serde_json::from_str(&json).unwrap();
588        assert_eq!(spec, again);
589    }
590
591    #[test]
592    fn defaults_fill_in() {
593        let json = r#"{
594          "symbol": "ETHUSDT", "timeframe": "5m",
595          "indicators": {"sma": {"type": "Sma", "params": [10]}},
596          "entry": {"gt": ["sma", {"price": "close"}]},
597          "exit": {"lt": ["sma", {"price": "close"}]},
598          "sizing": {"type": "fixed_qty", "qty": 1.0}
599        }"#;
600        let spec = StrategySpec::parse(json).unwrap();
601        assert_eq!(spec.spec_version, SPEC_VERSION);
602        assert_eq!(spec.execution.fill_timing, FillTiming::NextOpen);
603        // Not stated, so nothing to cross-check; the indicator's own family
604        // decides, and `feed_of` reports it.
605        assert_eq!(spec.indicators["sma"].feed, None);
606        assert_eq!(crate::registry::feed_of("Sma"), Some(Feed::Kline));
607        assert!(spec.risk.stop_loss_pct.is_none());
608        assert!((spec.costs.maker_bps).abs() < f64::EPSILON);
609    }
610
611    #[test]
612    fn rejects_undeclared_reference() {
613        let json = r#"{
614          "symbol": "X", "timeframe": "1h",
615          "indicators": {"a": {"type": "Sma", "params": [5]}},
616          "entry": {"gt": ["a", "b"]},
617          "exit": {"in_position": true},
618          "sizing": {"type": "fixed_qty", "qty": 1.0}
619        }"#;
620        let err = StrategySpec::parse(json).unwrap_err();
621        assert!(matches!(err, BacktestError::UndeclaredRef(r) if r == "b"));
622    }
623
624    #[test]
625    fn a_spec_version_this_build_cannot_read_is_rejected() {
626        let spec = |version: &str| {
627            format!(
628                r#"{{
629                  {version}
630                  "symbol": "X", "timeframe": "1h",
631                  "indicators": {{"a": {{"type": "Sma", "params": [5]}}}},
632                  "entry": {{"gt": ["a", "a"]}},
633                  "exit": {{"in_position": true}},
634                  "sizing": {{"type": "fixed_qty", "qty": 1.0}}
635                }}"#
636            )
637        };
638        // Omitted defaults to the current version.
639        assert!(StrategySpec::parse(&spec("")).is_ok());
640        assert!(StrategySpec::parse(&spec(r#""spec_version": 1,"#)).is_ok());
641        // A newer format would carry fields this build does not know, and reading
642        // it while ignoring them answers a different question than the spec asked.
643        let err = StrategySpec::parse(&spec(r#""spec_version": 999,"#)).unwrap_err();
644        let BacktestError::InvalidSpec(msg) = err else {
645            panic!("expected InvalidSpec");
646        };
647        assert!(
648            msg.contains("999"),
649            "message should name the version: {msg}"
650        );
651        // Zero is not a format anyone wrote; it is a missing value that survived
652        // serialisation somewhere.
653        assert!(StrategySpec::parse(&spec(r#""spec_version": 0,"#)).is_err());
654    }
655
656    #[test]
657    fn a_declared_feed_must_match_the_indicator_that_declares_it() {
658        let spec = |feed: &str| {
659            format!(
660                r#"{{
661                  "symbol": "X", "timeframe": "1h",
662                  "indicators": {{"a": {{"type": "Sma", "params": [5]{feed}}}}},
663                  "entry": {{"gt": ["a", "a"]}},
664                  "exit": {{"in_position": true}},
665                  "sizing": {{"type": "fixed_qty", "qty": 1.0}}
666                }}"#
667            )
668        };
669        // Omitted: the indicator's own family decides, and nothing to contradict.
670        assert!(StrategySpec::parse(&spec("")).is_ok());
671        // Declared and correct: Sma is fed the bar close.
672        assert!(StrategySpec::parse(&spec(r#", "feed": "kline""#)).is_ok());
673        // Declared and wrong. This is the case that used to be accepted in silence:
674        // the field was never read, so the spec ran and the indicator quietly
675        // consumed candles regardless of what it claimed.
676        let err = StrategySpec::parse(&spec(r#", "feed": "trade""#)).unwrap_err();
677        let BacktestError::InvalidSpec(msg) = err else {
678            panic!("expected InvalidSpec");
679        };
680        assert!(
681            msg.contains("'a'"),
682            "message should name the indicator: {msg}"
683        );
684        assert!(
685            msg.contains("Trade") && msg.contains("Kline"),
686            "both feeds: {msg}"
687        );
688    }
689
690    #[test]
691    fn every_feed_family_is_reachable_from_the_registry() {
692        use crate::registry::feed_of;
693        // One indicator per family, so a family losing its mapping is caught here
694        // rather than by a spec that silently stops being checked.
695        assert_eq!(feed_of("Sma"), Some(Feed::Kline));
696        assert_eq!(feed_of("Atr"), Some(Feed::Kline));
697        assert_eq!(feed_of("Beta"), Some(Feed::Kline));
698        assert_eq!(feed_of("FundingRate"), Some(Feed::Derivatives));
699        assert_eq!(feed_of("Nope"), None);
700    }
701
702    #[test]
703    fn execution_validation_rejections() {
704        // Each invalid execution config is rejected at parse (which validates).
705        let base = |exec: &str| {
706            format!(
707                r#"{{"symbol":"x","timeframe":"1h","indicators":{{}},
708                    "entry":{{"gt":[{{"price":"close"}},0]}},
709                    "exit":{{"in_position":true}},
710                    "sizing":{{"type":"fixed_qty","qty":1}},
711                    "execution":{exec}}}"#
712            )
713        };
714        let rejects = |exec: &str| {
715            matches!(
716                StrategySpec::parse(&base(exec)),
717                Err(BacktestError::InvalidSpec(_))
718            )
719        };
720        let accepts = |exec: &str| StrategySpec::parse(&base(exec)).is_ok();
721        // A stop-limit carrying both offsets is a valid spec. Without this the
722        // rejection tests below would still pass if the order type were rejected
723        // outright, which is what it used to be.
724        assert!(accepts(
725            r#"{"order_type":"stop_limit","stop_offset_pct":0.5,"limit_offset_pct":0.6}"#
726        ));
727        // An order type whose trigger offset is missing. A stop-limit needs both:
728        // the stop that arms it and the limit it arms.
729        assert!(rejects(r#"{"order_type":"limit"}"#));
730        assert!(rejects(r#"{"order_type":"stop"}"#));
731        assert!(rejects(r#"{"order_type":"stop_limit"}"#));
732        assert!(rejects(
733            r#"{"order_type":"stop_limit","stop_offset_pct":0.5}"#
734        ));
735        assert!(rejects(
736            r#"{"order_type":"stop_limit","limit_offset_pct":0.2}"#
737        ));
738        // Partial fills without a participation cap.
739        assert!(rejects(r#"{"partial_fills":true}"#));
740        // Close fill timing is market-only and latency-free.
741        assert!(rejects(
742            r#"{"fill_timing":"close","order_type":"limit","limit_offset_pct":-0.5}"#
743        ));
744        assert!(rejects(r#"{"fill_timing":"close","latency_bars":1}"#));
745
746        // The valid counterparts pass.
747        assert!(
748            StrategySpec::parse(&base(r#"{"order_type":"limit","limit_offset_pct":-0.5}"#)).is_ok()
749        );
750        assert!(
751            StrategySpec::parse(&base(r#"{"order_type":"stop","stop_offset_pct":0.5}"#)).is_ok()
752        );
753        assert!(
754            StrategySpec::parse(&base(r#"{"partial_fills":true,"max_participation":0.1}"#)).is_ok()
755        );
756        assert!(StrategySpec::parse(&base(r#"{"fill_timing":"close"}"#)).is_ok());
757    }
758
759    #[test]
760    fn operand_forms_parse() {
761        let op: Operand = serde_json::from_str(r#""ema_fast""#).unwrap();
762        assert!(matches!(op, Operand::Ref(_)));
763        let op: Operand = serde_json::from_str("70").unwrap();
764        assert!(matches!(op, Operand::Const(_)));
765        let op: Operand = serde_json::from_str(r#"{"price": "close"}"#).unwrap();
766        assert!(matches!(op, Operand::Expr(_)));
767        let op: Operand = serde_json::from_str(r#"{"prev": ["ema_fast", 1]}"#).unwrap();
768        assert!(matches!(op, Operand::Expr(_)));
769        let op: Operand = serde_json::from_str(r#"{"add": [1, 2]}"#).unwrap();
770        assert!(matches!(op, Operand::Expr(_)));
771    }
772
773    #[test]
774    fn multi_output_ref_is_allowed_when_base_declared() {
775        let json = r#"{
776          "symbol": "X", "timeframe": "1h",
777          "indicators": {"macd": {"type": "Macd", "params": [12, 26, 9]}},
778          "entry": {"cross_above": ["macd.macd", "macd.signal"]},
779          "exit": {"in_position": true},
780          "sizing": {"type": "fixed_qty", "qty": 1.0}
781        }"#;
782        assert!(StrategySpec::parse(json).is_ok());
783    }
784}