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>
423 lines
16 KiB
JSON
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."
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|