Files
swym/assets/strategy/schema.json
rob thijssen 5ca566e669 feat: multi-timeframe support for rule-based strategies
Adds `timeframe` field to DSL expressions so a single strategy can
reference candles from multiple intervals simultaneously. The primary
`candle_interval` still drives evaluation cadence; additional timeframes
are read-only context evaluated in parallel.

**DSL / DAL (`strategy_config.rs`)**
- `timeframe: Option<String>` on `Expr::Field`, `Expr::Func`,
  `Expr::ApplyFunc`, and all five legacy shorthand conditions
- `parse_interval_secs(interval)` helper
- `collect_timeframes(params)` walks the rule tree, returns all
  referenced interval strings — used by API validation and executor

**Executor (`paper-executor`)**
- `SwymInstrumentData` now keyed by interval: `candle_histories`,
  `ema_states`, `trade_prices` are all `HashMap<u64, …>`
- `next_candle_interval_hint` side-channel routes each incoming
  `DataKind::Candle` to the correct per-timeframe history
- `candle_ready` is still gated exclusively on the primary timeframe
- `EvalCtx` carries `primary_interval_secs`; resolver helpers
  (`candle_history`, `ema_state`, `trade_prices`) translate an
  `Option<String>` timeframe to the correct map entry
- `ema_registrations() -> Vec<(u64, usize)>` replaces the old
  single-timeframe `ema_periods()` for pre-warming EMA state
- Backtest runner merge-sorts candles from all required intervals by
  `time_exchange` and feeds them in chronological order

**API (`paper_runs.rs`)**
- At backtest creation, calls `collect_timeframes` on rule-based
  strategies and validates each additional timeframe: must be a known
  interval and must have candle data covering the requested range

**Dashboard**
- `AddStrategyPage`: expanded DSL reference panel — added `event_count`,
  `apply_func`, `unary_op`, `bars_since`, all func names (`wma`, `atr`,
  `adx`, `supertrend`, `sum`), `multiplier`, and a new Multi-timeframe
  section with a worked example
- `AddPaperRunPage`: shows per-additional-timeframe coverage chips and
  backfill buttons alongside the primary-interval coverage indicator

**Docs / assets**
- `docs/strategy-dsl.md`: added Multi-timeframe expressions section and
  updated all path references
- `docs/strategy.schema.json` → `assets/strategy/schema.json` (new
  location; updated `$id`, added `TimeframeInterval` definition, added
  `timeframe` property to six schema nodes)
- `assets/strategy/emmanuel-ma-v{1..8}.json` →
  `assets/strategy/emmanuel-ma/v{1..8}.json` (grouped into subfolder);
  seeder `include_str!` paths updated accordingly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:17:12 +02:00

423 lines
16 KiB
JSON

{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://swym.internal/strategy/schema.json",
"title": "SwymRuleBasedStrategy",
"description": "Swym rules-based strategy DSL. Submit as the 'strategy' field of a paper run config. All decimal values must be JSON strings.",
"type": "object",
"required": ["type", "candle_interval", "rules"],
"additionalProperties": false,
"properties": {
"type": {
"const": "rule_based"
},
"candle_interval": {
"type": "string",
"enum": ["1m", "5m", "15m", "1h", "4h", "1d"],
"description": "Primary candle interval driving strategy evaluation cadence."
},
"rules": {
"type": "array",
"items": { "$ref": "#/definitions/Rule" },
"minItems": 1,
"description": "All rules whose 'when' is true on a primary candle close fire simultaneously."
}
},
"definitions": {
"DecimalString": {
"description": "A decimal number encoded as a JSON string, e.g. \"3.14\", \"-0.5\", \"0.001\".",
"type": "string",
"pattern": "^-?[0-9]+(\\.[0-9]+)?$"
},
"CandleField": {
"type": "string",
"enum": ["open", "high", "low", "close", "volume"]
},
"TimeframeInterval": {
"type": "string",
"enum": ["1m", "5m", "15m", "1h", "4h", "1d"],
"description": "An additional candle interval to read from. When absent, uses the primary candle_interval. Requires backfilled candle data for the backtest window."
},
"FuncName": {
"type": "string",
"enum": ["highest", "lowest", "sma", "ema", "wma", "rsi", "std_dev", "sum", "atr", "supertrend", "adx"],
"description": "Rolling-window function. atr/adx/supertrend ignore 'field' and use OHLC internally."
},
"ApplyFuncName": {
"type": "string",
"enum": ["highest", "lowest", "sma", "ema", "wma", "std_dev", "sum"],
"description": "Subset of FuncName valid inside apply_func. atr, supertrend, adx, and rsi are excluded."
},
"CmpOp": {
"type": "string",
"enum": [">", "<", ">=", "<=", "=="]
},
"ArithOp": {
"type": "string",
"enum": ["add", "sub", "mul", "div"]
},
"UnaryOpKind": {
"type": "string",
"enum": ["abs", "sqrt", "neg", "log"]
},
"Action": {
"type": "object",
"required": ["side", "quantity"],
"additionalProperties": false,
"properties": {
"side": { "type": "string", "enum": ["buy", "sell"] },
"quantity": {
"$ref": "#/definitions/DecimalString",
"description": "Per-order size in base asset units, e.g. \"0.001\" for BTC."
}
}
},
"Rule": {
"type": "object",
"required": ["when", "then"],
"additionalProperties": false,
"properties": {
"comment": {
"type": "string",
"description": "Optional human-readable annotation. Included in the content hash so keep stable."
},
"when": { "$ref": "#/definitions/Condition" },
"then": { "$ref": "#/definitions/Action" }
}
},
"Condition": {
"description": "A boolean condition evaluated at candle close. Returns false (safe no-op) when there is insufficient history.",
"oneOf": [
{ "$ref": "#/definitions/ConditionAllOf" },
{ "$ref": "#/definitions/ConditionAnyOf" },
{ "$ref": "#/definitions/ConditionNot" },
{ "$ref": "#/definitions/ConditionPosition" },
{ "$ref": "#/definitions/ConditionEmaCrossover" },
{ "$ref": "#/definitions/ConditionEmaTrend" },
{ "$ref": "#/definitions/ConditionRsi" },
{ "$ref": "#/definitions/ConditionBollinger" },
{ "$ref": "#/definitions/ConditionPriceLevel" },
{ "$ref": "#/definitions/ConditionCompare" },
{ "$ref": "#/definitions/ConditionCrossOver" },
{ "$ref": "#/definitions/ConditionCrossUnder" },
{ "$ref": "#/definitions/ConditionEventCount" }
]
},
"ConditionComment": {
"description": "Optional human-readable annotation on any condition node. Ignored by the evaluator; serde drops unknown fields silently.",
"type": "string"
},
"ConditionAllOf": {
"type": "object",
"required": ["kind", "conditions"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "all_of" },
"conditions": {
"type": "array",
"items": { "$ref": "#/definitions/Condition" },
"minItems": 1
}
}
},
"ConditionAnyOf": {
"type": "object",
"required": ["kind", "conditions"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "any_of" },
"conditions": {
"type": "array",
"items": { "$ref": "#/definitions/Condition" },
"minItems": 1
}
}
},
"ConditionNot": {
"type": "object",
"required": ["kind", "condition"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "not" },
"condition": { "$ref": "#/definitions/Condition" }
}
},
"ConditionPosition": {
"type": "object",
"required": ["kind", "state"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "position" },
"state": { "type": "string", "enum": ["flat", "long", "short"] }
}
},
"ConditionEmaCrossover": {
"description": "True on the bar where fast EMA crosses above (or below) slow EMA. Fires once per cross event.",
"type": "object",
"required": ["kind", "fast_period", "slow_period", "direction"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "ema_crossover" },
"fast_period": { "type": "integer", "minimum": 1 },
"slow_period": { "type": "integer", "minimum": 1 },
"direction": { "type": "string", "enum": ["above", "below"] },
"timeframe": { "$ref": "#/definitions/TimeframeInterval" }
}
},
"ConditionEmaTrend": {
"description": "True while close price is above (or below) the EMA. Persistent condition, not a crossover event.",
"type": "object",
"required": ["kind", "period", "direction"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "ema_trend" },
"period": { "type": "integer", "minimum": 1 },
"direction": { "type": "string", "enum": ["above", "below"] },
"timeframe": { "$ref": "#/definitions/TimeframeInterval" }
}
},
"ConditionRsi": {
"description": "True while RSI (Wilder's method) is above or below the threshold.",
"type": "object",
"required": ["kind", "threshold", "comparison"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "rsi" },
"period": {
"type": "integer",
"minimum": 1,
"default": 14,
"description": "RSI period. Defaults to 14."
},
"threshold": { "$ref": "#/definitions/DecimalString" },
"comparison": { "type": "string", "enum": ["above", "below"] },
"timeframe": { "$ref": "#/definitions/TimeframeInterval" }
}
},
"ConditionBollinger": {
"description": "True when close breaks above the upper or below the lower Bollinger Band.",
"type": "object",
"required": ["kind", "band"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "bollinger" },
"period": {
"type": "integer",
"minimum": 1,
"default": 20
},
"num_std_dev": {
"$ref": "#/definitions/DecimalString",
"description": "Number of standard deviations. Defaults to \"2\"."
},
"band": { "type": "string", "enum": ["above_upper", "below_lower"] },
"timeframe": { "$ref": "#/definitions/TimeframeInterval" }
}
},
"ConditionPriceLevel": {
"description": "True while close is above or below a fixed price level.",
"type": "object",
"required": ["kind", "price", "direction"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "price_level" },
"price": { "$ref": "#/definitions/DecimalString" },
"direction": { "type": "string", "enum": ["above", "below"] },
"timeframe": { "$ref": "#/definitions/TimeframeInterval" }
}
},
"ConditionCompare": {
"description": "True when left op right holds. Use for any indicator comparison not covered by the legacy shortcuts.",
"type": "object",
"required": ["kind", "left", "op", "right"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "compare" },
"left": { "$ref": "#/definitions/Expr" },
"op": { "$ref": "#/definitions/CmpOp" },
"right": { "$ref": "#/definitions/Expr" }
}
},
"ConditionCrossOver": {
"description": "True on the single bar where left crosses above right (left was <= right, now > right).",
"type": "object",
"required": ["kind", "left", "right"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "cross_over" },
"left": { "$ref": "#/definitions/Expr" },
"right": { "$ref": "#/definitions/Expr" }
}
},
"ConditionCrossUnder": {
"description": "True on the single bar where left crosses below right (left was >= right, now < right).",
"type": "object",
"required": ["kind", "left", "right"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "cross_under" },
"left": { "$ref": "#/definitions/Expr" },
"right": { "$ref": "#/definitions/Expr" }
}
},
"Expr": {
"description": "A numeric expression evaluating to Option<Decimal>. Returns None (condition → false) when history is insufficient.",
"oneOf": [
{ "$ref": "#/definitions/ExprLiteral" },
{ "$ref": "#/definitions/ExprField" },
{ "$ref": "#/definitions/ExprFunc" },
{ "$ref": "#/definitions/ExprBinOp" },
{ "$ref": "#/definitions/ExprApplyFunc" },
{ "$ref": "#/definitions/ExprUnaryOp" },
{ "$ref": "#/definitions/ExprBarsSince" }
]
},
"ExprLiteral": {
"description": "A constant numeric value.",
"type": "object",
"required": ["kind", "value"],
"additionalProperties": false,
"properties": {
"kind": { "const": "literal" },
"value": { "$ref": "#/definitions/DecimalString" }
}
},
"ExprField": {
"description": "An OHLCV candle field, optionally from N bars ago and/or from a different timeframe.",
"type": "object",
"required": ["kind", "field"],
"additionalProperties": false,
"properties": {
"kind": { "const": "field" },
"field": { "$ref": "#/definitions/CandleField" },
"offset": {
"type": "integer",
"minimum": 0,
"default": 0,
"description": "Bars ago. 0 = current bar, 1 = previous bar."
},
"timeframe": { "$ref": "#/definitions/TimeframeInterval" }
}
},
"ExprFunc": {
"description": "A rolling-window function applied to a candle field over the trailing N bars, optionally on a different timeframe.",
"type": "object",
"required": ["kind", "name", "period"],
"additionalProperties": false,
"properties": {
"kind": { "const": "func" },
"name": { "$ref": "#/definitions/FuncName" },
"field": {
"$ref": "#/definitions/CandleField",
"description": "Which candle field to use. Defaults to 'close'. Ignored for atr, adx, supertrend."
},
"period": {
"type": "integer",
"minimum": 1,
"description": "Window size in bars. Minimum bars required: period (most funcs), period+1 (atr/rsi), 2*period+1 (adx), period+2 (supertrend)."
},
"offset": {
"type": "integer",
"minimum": 0,
"default": 0,
"description": "Shift the window back N bars. 0 = window ending on current bar."
},
"multiplier": {
"$ref": "#/definitions/DecimalString",
"description": "ATR multiplier for supertrend only (e.g. \"3.0\"). Omit for all other functions."
},
"timeframe": { "$ref": "#/definitions/TimeframeInterval" }
}
},
"ExprBinOp": {
"description": "Arithmetic combination of two sub-expressions. Division by zero returns None.",
"type": "object",
"required": ["kind", "op", "left", "right"],
"additionalProperties": false,
"properties": {
"kind": { "const": "bin_op" },
"op": { "$ref": "#/definitions/ArithOp" },
"left": { "$ref": "#/definitions/Expr" },
"right": { "$ref": "#/definitions/Expr" }
}
},
"ExprApplyFunc": {
"description": "Applies a rolling function to an arbitrary sub-expression evaluated at each bar in the window. Use for function composition: EMA-of-EMA, Hull MA, VWAP, etc. NOT valid with atr, adx, supertrend, or rsi.",
"type": "object",
"required": ["kind", "name", "input", "period"],
"additionalProperties": false,
"properties": {
"kind": { "const": "apply_func" },
"name": { "$ref": "#/definitions/ApplyFuncName" },
"input": { "$ref": "#/definitions/Expr" },
"period": { "type": "integer", "minimum": 1 },
"offset": {
"type": "integer",
"minimum": 0,
"default": 0
},
"timeframe": { "$ref": "#/definitions/TimeframeInterval" }
}
},
"ExprUnaryOp": {
"description": "Unary math operation. sqrt and log return None for invalid inputs (negative / non-positive).",
"type": "object",
"required": ["kind", "op", "operand"],
"additionalProperties": false,
"properties": {
"kind": { "const": "unary_op" },
"op": { "$ref": "#/definitions/UnaryOpKind" },
"operand": { "$ref": "#/definitions/Expr" }
}
},
"ConditionEventCount": {
"description": "Counts how many of the trailing `period` bars (offsets 1..=period, excluding the current bar) the sub-condition was true, then applies op against count. Returns false during warm-up. Only Compare, CrossOver, CrossUnder, AllOf, AnyOf, Not, and nested EventCount sub-conditions are offset-aware; Position/EmaCrossover/EmaTrend/Rsi/Bollinger return false at non-zero offsets.",
"type": "object",
"required": ["kind", "condition", "period", "op", "count"],
"additionalProperties": false,
"properties": {
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "event_count" },
"condition": { "$ref": "#/definitions/Condition" },
"period": {
"type": "integer",
"minimum": 1,
"description": "Number of trailing bars to scan (not including the current bar)."
},
"op": { "$ref": "#/definitions/CmpOp" },
"count": {
"type": "integer",
"minimum": 0,
"description": "Integer threshold to compare the fire count against."
}
}
},
"ExprBarsSince": {
"description": "Scans back up to `period` bars and returns how many bars ago the sub-condition was last true (1 = previous bar). Returns None if the condition never fired within the window. Use inside compare to express recency constraints.",
"type": "object",
"required": ["kind", "condition", "period"],
"additionalProperties": false,
"properties": {
"kind": { "const": "bars_since" },
"condition": { "$ref": "#/definitions/Condition" },
"period": {
"type": "integer",
"minimum": 1,
"description": "Maximum bars to look back."
}
}
}
}
}