1use std::collections::{BTreeMap, BTreeSet};
12
13use serde::{Deserialize, Serialize};
14
15use crate::error::{BacktestError, Result};
16use crate::registry::feed_of;
17
18pub const SPEC_VERSION: u32 = 1;
20
21fn default_spec_version() -> u32 {
22 SPEC_VERSION
23}
24
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
27pub struct StrategySpec {
28 #[serde(default = "default_spec_version")]
34 pub spec_version: u32,
35 pub symbol: String,
42 #[serde(default)]
50 pub ref_symbol: Option<String>,
51 pub timeframe: String,
57 pub indicators: BTreeMap<String, IndicatorSpec>,
59 pub entry: Condition,
61 pub exit: Condition,
63 #[serde(default)]
65 pub short_entry: Option<Condition>,
66 #[serde(default)]
68 pub short_exit: Option<Condition>,
69 pub sizing: Sizing,
71 #[serde(default)]
73 pub costs: Costs,
74 #[serde(default)]
76 pub risk: Risk,
77 #[serde(default)]
79 pub execution: Execution,
80 #[serde(default)]
82 pub warmup: Option<u32>,
83}
84
85impl StrategySpec {
86 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 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 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 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 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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
188pub struct IndicatorSpec {
189 #[serde(rename = "type")]
191 pub kind: String,
192 #[serde(default)]
194 pub params: Vec<f64>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub feed: Option<Feed>,
201}
202
203#[derive(
205 Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
206)]
207#[serde(rename_all = "snake_case")]
208pub enum Feed {
209 #[default]
212 Kline,
213 Trade,
215 Orderbook,
217 TradeQuote,
219 Derivatives,
221 CrossSection,
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
227#[serde(rename_all = "snake_case")]
228pub enum PriceField {
229 Open,
231 High,
233 Low,
235 Close,
237 Volume,
239 Hlc3,
241 Ohlc4,
243}
244
245#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
247#[serde(untagged)]
248pub enum Operand {
249 Ref(String),
251 Const(f64),
253 Expr(Box<OperandExpr>),
255}
256
257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
259#[serde(rename_all = "snake_case")]
260pub enum OperandExpr {
261 Price(PriceField),
263 Prev((Box<Operand>, u32)),
265 Add((Box<Operand>, Box<Operand>)),
267 Sub((Box<Operand>, Box<Operand>)),
269 Mul((Box<Operand>, Box<Operand>)),
271 Div((Box<Operand>, Box<Operand>)),
273}
274
275#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
277#[serde(rename_all = "snake_case")]
278pub enum Condition {
279 Gt((Operand, Operand)),
281 Lt((Operand, Operand)),
283 Ge((Operand, Operand)),
285 Le((Operand, Operand)),
287 Eq((Operand, Operand)),
289 Ne((Operand, Operand)),
291 CrossAbove((Operand, Operand)),
293 CrossBelow((Operand, Operand)),
295 Between((Operand, Operand, Operand)),
297 Rising((Operand, u32)),
299 Falling((Operand, u32)),
301 All(Vec<Condition>),
303 Any(Vec<Condition>),
305 Not(Box<Condition>),
307 InPosition(bool),
309 BarsSinceEntry(IntPredicate),
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
315#[serde(rename_all = "snake_case")]
316pub enum IntPredicate {
317 Gt(u32),
319 Lt(u32),
321 Ge(u32),
323 Le(u32),
325 Eq(u32),
327}
328
329#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
331#[serde(tag = "type", rename_all = "snake_case")]
332pub enum Sizing {
333 FixedFraction {
335 fraction: f64,
337 },
338 FixedQty {
340 qty: f64,
342 },
343 FixedCash {
345 cash: f64,
347 },
348 VolTarget {
354 target_vol: f64,
356 lookback: u32,
358 },
359 RiskPerTrade {
361 risk_pct: f64,
363 },
364}
365
366#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, schemars::JsonSchema)]
368pub struct Costs {
369 #[serde(default)]
371 pub maker_bps: f64,
372 #[serde(default)]
374 pub taker_bps: f64,
375 #[serde(default)]
377 pub slippage: Slippage,
378 #[serde(default)]
382 pub funding: bool,
383}
384
385#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
387#[serde(tag = "type", rename_all = "snake_case")]
388pub enum Slippage {
389 FixedBps {
391 bps: f64,
393 },
394 Spread,
396 VolumeImpact {
398 coef: f64,
400 },
401}
402
403impl Default for Slippage {
404 fn default() -> Self {
405 Self::FixedBps { bps: 0.0 }
406 }
407}
408
409#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, schemars::JsonSchema)]
411pub struct Risk {
412 #[serde(default)]
414 pub stop_loss_pct: Option<f64>,
415 #[serde(default)]
417 pub take_profit_pct: Option<f64>,
418 #[serde(default)]
420 pub trailing_stop_pct: Option<f64>,
421 #[serde(default)]
423 pub max_leverage: Option<f64>,
424 #[serde(default)]
426 pub max_position_pct: Option<f64>,
427 #[serde(default)]
430 pub liquidation: bool,
431}
432
433#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, schemars::JsonSchema)]
435pub struct Execution {
436 #[serde(default)]
438 pub order_type: OrderType,
439 #[serde(default)]
441 pub fill_timing: FillTiming,
442 #[serde(default)]
446 pub limit_offset_pct: Option<f64>,
447 #[serde(default)]
451 pub stop_offset_pct: Option<f64>,
452 #[serde(default)]
454 pub latency_bars: u32,
455 #[serde(default)]
458 pub partial_fills: bool,
459 #[serde(default)]
462 pub max_participation: Option<f64>,
463}
464
465#[derive(
467 Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
468)]
469#[serde(rename_all = "snake_case")]
470pub enum OrderType {
471 #[default]
473 Market,
474 Limit,
476 Stop,
478 StopLimit,
480}
481
482#[derive(
484 Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
485)]
486#[serde(rename_all = "snake_case")]
487pub enum FillTiming {
488 #[default]
490 NextOpen,
491 Close,
496}
497
498fn 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 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 assert!(StrategySpec::parse(&spec("")).is_ok());
640 assert!(StrategySpec::parse(&spec(r#""spec_version": 1,"#)).is_ok());
641 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 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 assert!(StrategySpec::parse(&spec("")).is_ok());
671 assert!(StrategySpec::parse(&spec(r#", "feed": "kline""#)).is_ok());
673 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 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 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 assert!(accepts(
725 r#"{"order_type":"stop_limit","stop_offset_pct":0.5,"limit_offset_pct":0.6}"#
726 ));
727 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 assert!(rejects(r#"{"partial_fills":true}"#));
740 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 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}