Compare commits

...

10 Commits

Author SHA1 Message Date
c4f5d60ba9 feat: extend price chart overlays to EMA and Supertrend indicators
Broadens extractSmaSpecs.ts into a general extractIndicatorSpecs utility
that walks the rule DSL and extracts three indicator types:

- SMA: solid line (unchanged)
- EMA: dashed line, same color pool — visually distinct from SMA
- Supertrend: solid magenta (#e040fb), width 2 — prominent line showing
  where the band sits relative to price

Client-side computations added to CandlestickChart:
- computeEma(): standard EMA seeded with SMA of first period values
- computeSupertrend(): Wilder's ATR smoothing + iterative band-flip
  logic matching TradingView's ta.supertrend() behaviour

The walker also descends into event_count conditions and apply_func
inputs (previously missed), and the deprecated extractSmaSpecs export
is kept as a thin wrapper for any future callers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 18:09:05 +02:00
197ac5e659 chore: ignore transcripts and text errata 2026-03-06 18:06:54 +02:00
e479351d82 feat: audit and revise all 13 non-Emmanuel seeded strategies
Creates assets/strategy/<name>/ folders for all 13 strategies following the
emmanuel-ma/mtf pattern: versioned JSON files and a readme per strategy.

Tier 1 — DSL improvements (7 strategies get a new version):
- simple-trend v2: ADX(14) > 25 regime gate
- buy-2-factors v2: ADX(14) > 20 completes the originally-intended 3-factor gate
- ichimoku v2: Chikou Span check + cloud-colour filter (Span A > Span B)
- bull-market-support-band v2: event_count bull confirmation (≥35/50 bars above SMA100)
- supertrend v3: ADX(14) > 25 regime gate
- gaussian-channel v3: cross_over/cross_under replaces persistent compare on band breaks
- stochastic-keltner v3: proper cross_over(stoch_k, 20) replaces manual two-bar simulation

Tier 2 — folders + readmes only, no revision needed (6 strategies):
hodl, momentum-cascade, money-line, trend-detector, hull-suite, supertrend-fusion

common.rs updated with 7 new seed functions using include_str! for the revised
versions; existing inline json!() seeds unchanged for historical comparison.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 18:00:33 +02:00
d0706a5d8f feat: overlay entry/exit signal markers on candlestick chart
- Increase position sample limit from 500 to 5000
- Pass positions to CandlestickChart; render entry (green arrowUp) and
  exit (green/red arrowDown by PnL) markers via createSeriesMarkers(),
  pinned to exact entry/exit prices on the chart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 17:09:22 +02:00
e2cbd53354 feat: candlestick chart on paper run detail page with SMA overlays
Adds a price chart between the "Run Details" and "Equity Curve" cards
on the paper run detail page, shown only for candle-mode backtests.

Backend:
- DAL: add load_candles_range() — single-query candle fetch by instrument/interval/range
- API: GET /api/v1/paper-runs/{id}/candles — resolves instrument from run config,
  fetches OHLCV rows from market_candles for the run's time range and candle_interval

Frontend:
- Install lightweight-charts (TradingView) for candlestick rendering
- CandlestickChart component with SMA line overlay support
- extractSmaSpecs() utility: recursively walks the rule-based strategy DSL to
  collect unique SMA(field, period, timeframe) specs from all condition nodes
- PaperRunDetailPage: extracts same-timeframe SMA specs from run.config.strategy
  and passes them to CandlestickChart for overlay

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 17:03:16 +02:00
60cc34d2a2 feat: emmanuel-mtf v4 — triple SMA alignment + tighter proximity + lower take-profit
Driven by v3 audit (127 trades, 21% win rate, PF 0.686, take-profit fired 8.7% of exits):
- Add 15m SMA stack: SMA(20,15m) > SMA(200,15m) — forces triple alignment (4h bull +
  15m local bull + pullback to 15m 20 SMA); eliminates entries where 15m 20 SMA is
  acting as resistance rather than support in a local downtrend
- Tighten entry proximity: 0.5% → 0.3% — closer entries reduce average loss per
  failed trade and improve risk/reward toward the take-profit
- Lower take-profit: 1.5% → 1.0% — captures more trades at profit before reversal;
  v3 take-profit only triggered on 8.7% of exits

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 13:46:44 +02:00
ce97e37c29 feat: emmanuel-mtf v3 — take-profit exit + tighter regime filters
Driven by v2 audit (179 trades, 19% win rate, profit factor 0.586):
- Add take-profit exit at close >1.5% above 15m 20 SMA (highest impact: v2 had
  no upside exit, so every winner reversed back to zero before closing)
- Replace cross_under trailing stop with 0.1%-below-SMA compare (absorbs
  single-bar noise that caused spurious exits in v2)
- Add close > open entry condition (bullish candle at the MA touch)
- Tighten 4h SMA rising check: offset 5 → 2 (20h → 8h window)
- Raise 4h ADX threshold: 20 → 25 (v2 ADX>20 passed 71.9% of bars)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 13:38:18 +02:00
86889ac9de feat: emmanuel-mtf v2 strategy + indicator warmup pre-loading
Strategy changes (v2.json):
- Remove not-first-touch filter (condition 11, 7% pass rate — fatal bottleneck that
  misread the transcript: Emmanuel says skip the FIRST touch after a parabolic run,
  not require proof of a prior impulse)
- Replace with not-parabolic guard: close - SMA(20) < SMA(20) * 0.03
- Widen pullback-evidence tolerance from 0.3% to 0.5% (next tightest filter at 46.9%)
- Add 4h ADX(14) > 20 to maintain selectivity after removing condition 11

Engine changes (indicator warmup):
- Add compute_warmup_secs() to strategy_config.rs: walks rule tree to find the maximum
  warmup period required (period × interval_secs) across all timeframes
- Add process_candle_warmup() to SwymInstrumentData: feeds historical candles into
  indicator histories/EMAs without triggering candle_ready or generating orders
- Pre-load warmup candles in execute_backtest() before starts_at: eliminates the ~33-day
  dead zone for SMA(200) on 4h (3,199 insufficient-data bars in the v1 audit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 13:07:19 +02:00
8da26ebbdb feat: add Emmanuel MTF v1 strategy seed
Multi-timeframe SMA pullback strategy: 4h trend context (20/200 SMA
stack) + 15m entry timing (pullback to 20 SMA with bounce evidence).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:33:53 +02:00
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
70 changed files with 5580 additions and 274 deletions

4
.gitignore vendored
View File

@@ -6,5 +6,9 @@ docs/strategy-extensions.md
docs/stateful-event-counters.md
docs/strategy-backtest-audit.md
docs/strategy-multi-timeframe.md
docs/strategy-emmanuel-improvements.md
script/debug.sh
assets/reference/
assets/strategy/**/audit/
assets/strategy/**/backtest/
assets/strategy/**/*.txt

View File

@@ -0,0 +1,37 @@
# Bull Market Support Band
A well-known concept from crypto and equity analysis, popularised by Benjamin Cowen.
The "band" formed by the 20-week SMA and 21-week EMA acts as dynamic support in
bull markets; breaches below it signal the end of a bull phase.
## Versions
| Version | File | Notes |
|---------|---------|-----------------------------------------------------------------------|
| v1 | v1.json | Bounce detection only; no explicit bull market confirmation |
| v2 | v2.json | Adds EventCount bull confirmation (35 of last 50 days above SMA100) |
## Logic
On daily charts, the weekly MAs are approximated as SMA(100) and EMA(105).
**Entry** (v1): close[1] was below SMA(100)[1] (touched band) AND close > SMA(100) AND close > EMA(105) (bounced).
**Entry** (v2): same PLUS `event_count(close > SMA(100), period=50) >= 35` — confirms at least 35 of the last 50 days were above the band, establishing a bull market context.
**Exit**: close < SMA(100) AND close < EMA(105) — band is lost on both MAs.
## Indicators
- `SMA(100)` — proxy for 20-week SMA on daily chart
- `EMA(105)` — proxy for 21-week EMA on daily chart
## Data requirements
- Daily (`1d`) candles required.
- Minimum warmup: 105 candles for EMA(105); 150 recommended to satisfy EventCount.
## Known limitations
- The weekly 20 SMA / 21 EMA are approximated with daily 100 / 105 periods.
A more precise approximation uses 140/147 (7 × 20/21) but those require more data.
- The bounce entry is fragile — a single wick below the band can trigger a false entry.
Adding a close-above confirmation bar would reduce whipsaws but increases lag.

View File

@@ -0,0 +1,56 @@
{
"type": "rule_based",
"candle_interval": "1d",
"rules": [
{
"comment": "Enter long: price bounces off support band (was below, now above both MAs)",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "flat" },
{
"kind": "compare",
"left": { "kind": "field", "field": "close", "offset": 1 },
"op": "<",
"right": { "kind": "func", "name": "sma", "field": "close", "period": 100, "offset": 1 }
},
{
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">",
"right": { "kind": "func", "name": "sma", "field": "close", "period": 100 }
},
{
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">",
"right": { "kind": "func", "name": "ema", "field": "close", "period": 105 }
}
]
},
"then": { "side": "buy", "quantity": "0.001" }
},
{
"comment": "Exit long: price closes below both band MAs",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": "<",
"right": { "kind": "func", "name": "sma", "field": "close", "period": 100 }
},
{
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": "<",
"right": { "kind": "func", "name": "ema", "field": "close", "period": 105, "offset": 1 }
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
}
]
}

View File

@@ -0,0 +1,69 @@
{
"type": "rule_based",
"candle_interval": "1d",
"rules": [
{
"comment": "Enter long: bull market confirmed (35 of last 50 days above SMA100) + price bounces off band",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "flat" },
{
"comment": "Bull market confirmation: close has been above SMA(100) in at least 35 of the last 50 bars",
"kind": "event_count",
"condition": {
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">",
"right": { "kind": "func", "name": "sma", "field": "close", "period": 100 }
},
"period": 50,
"op": ">=",
"count": 35
},
{
"kind": "compare",
"left": { "kind": "field", "field": "close", "offset": 1 },
"op": "<",
"right": { "kind": "func", "name": "sma", "field": "close", "period": 100, "offset": 1 }
},
{
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">",
"right": { "kind": "func", "name": "sma", "field": "close", "period": 100 }
},
{
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">",
"right": { "kind": "func", "name": "ema", "field": "close", "period": 105 }
}
]
},
"then": { "side": "buy", "quantity": "0.001" }
},
{
"comment": "Exit long: price closes below both band MAs",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": "<",
"right": { "kind": "func", "name": "sma", "field": "close", "period": 100 }
},
{
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": "<",
"right": { "kind": "func", "name": "ema", "field": "close", "period": 105, "offset": 1 }
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
}
]
}

View File

@@ -0,0 +1,44 @@
# Buy 2 Factors, Reversion Entry
A mean-reversion approach filtered by two confirming factors. Enters on RSI
oversold readings within a macro uptrend, exits when RSI is overbought.
## Versions
| Version | File | Notes |
|---------|---------|---------------------------------------------------------------|
| v1 | v1.json | RSI < 30 + close > SMA(200); ADX factor omitted |
| v2 | v2.json | Adds ADX(14) > 20 as Factor 2 — full 3-factor implementation |
## Logic
**Entry** (v1): RSI(14) < 30 AND close > SMA(200).
**Entry** (v2): RSI(14) < 30 AND close > SMA(200) AND ADX(14) > 20.
**Exit**: RSI(14) > 70.
## Factors
1. **Macro uptrend filter**: close > SMA(200) — only buy dips in bull markets.
2. **Trend strength / non-ranging**: ADX(14) > 20 — confirms the market is
directional (v2 only; was the missing factor in v1).
3. **Entry signal**: RSI(14) < 30 — price is oversold relative to recent history.
## Indicators
- `RSI(14)` — Relative Strength Index on close
- `SMA(200)` — 200-period simple moving average on close
- `ADX(14)` — Average Directional Index (v2 only)
## Data requirements
- 1h candles recommended.
- Minimum warmup: ~200 candles for SMA(200).
## Known limitations
- MACD histogram (originally proposed as an alternative to ADX) requires DSL
extensions not yet available.
- Exit is RSI > 70 only; no stop-loss. A sharp downside move during an RSI < 30
entry that never recovers will hold the position indefinitely.
- Mean-reversion strategies can suffer large drawdowns in prolonged downtrends
even with the SMA(200) filter.

View File

@@ -0,0 +1,44 @@
{
"type": "rule_based",
"candle_interval": "1h",
"rules": [
{
"comment": "Enter long: RSI oversold + price above 200 SMA",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "flat" },
{
"kind": "compare",
"left": { "kind": "func", "name": "rsi", "field": "close", "period": 14 },
"op": "<",
"right": { "kind": "literal", "value": "30" }
},
{
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">",
"right": { "kind": "func", "name": "sma", "field": "close", "period": 200 }
}
]
},
"then": { "side": "buy", "quantity": "0.001" }
},
{
"comment": "Exit long: RSI overbought",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "compare",
"left": { "kind": "func", "name": "rsi", "field": "close", "period": 14 },
"op": ">",
"right": { "kind": "literal", "value": "70" }
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
}
]
}

View File

@@ -0,0 +1,50 @@
{
"type": "rule_based",
"candle_interval": "1h",
"rules": [
{
"comment": "Enter long: RSI oversold + price above 200 SMA + ADX trending",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "flat" },
{
"kind": "compare",
"left": { "kind": "func", "name": "rsi", "field": "close", "period": 14 },
"op": "<",
"right": { "kind": "literal", "value": "30" }
},
{
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">",
"right": { "kind": "func", "name": "sma", "field": "close", "period": 200 }
},
{
"kind": "compare",
"left": { "kind": "func", "name": "adx", "field": "close", "period": 14 },
"op": ">",
"right": { "kind": "literal", "value": "20" }
}
]
},
"then": { "side": "buy", "quantity": "0.001" }
},
{
"comment": "Exit long: RSI overbought",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "compare",
"left": { "kind": "func", "name": "rsi", "field": "close", "period": 14 },
"op": ">",
"right": { "kind": "literal", "value": "70" }
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
}
]
}

View File

@@ -0,0 +1,35 @@
# Emmanuel MTF — Multi-Timeframe SMA Pullback Strategy
Inspired by [Emmanuel Malyarovich's video](https://www.youtube.com/watch?v=0aCaq-_N3nI) on trading with the 20 and 200 period simple moving averages.
## Core idea
Emmanuel's approach uses only two indicators — the 20 SMA and the 200 SMA — applied across multiple timeframes. The higher timeframe establishes trend direction; the lower timeframe times entries at pullbacks to the 20 SMA.
From the video: *"I like to look for potential entries whenever prices are close to or near the 20 MA"* during an uptrend where the *"20 MA is under price, trending higher."*
## How it works
**4h (trend context):** Confirms a bullish regime — close above the 200 SMA, 20 SMA stacked above the 200 and rising, price above the 20 SMA but not overextended from it.
**15m (entry timing):** Waits for price to retrace to the 15m 20 SMA and bounce. Requires the MA to be rising, price to be in a tight proximity zone just above it, evidence of an actual pullback (low touched the MA recently), and evidence of a prior impulsive move (recent high was meaningfully above the MA — avoids first-touch traps after exhaustion moves).
**Exits:** Trailing exit on a cross below the 15m 20 SMA, a structural exit if the 4h MAs unstack, and a hard stop at 2% below the 15m 20 SMA.
## Adaptation notes
Emmanuel trades US equities intraday, often using gap-up/gap-down catalysts and order flow as additional context. This strategy adapts his SMA logic for crypto (BTC), which trades 24/7 without gaps. The multi-timeframe structure, proximity-based entries, and extension filters are faithful to his described method; the discretionary elements (candle structure, level 2 order flow, gap dynamics) are not expressible in the current DSL.
## Data requirements
Requires backfilled candle data for both **15m** and **4h** intervals across the backtest window.
## Lineage
This strategy evolved through eight single-timeframe iterations (emmanuel-ma v1v8) before the DSL gained multi-timeframe support. Key learnings from that process: strict cross_over triggers are too rare on higher timeframes; proximity-and-rising entries generate more trades with comparable edge; exit timeframe must match entry timeframe to avoid whipsaw; and the strategy is consistently green but low-conviction without the multi-timeframe layering Emmanuel describes.
**v1 → v2**: Condition audit on a 360-day backtest revealed v1 produced only 1 trade. The fatal bottleneck was condition 11 (not-first-touch filter, 7% pass rate) which misread the transcript — Emmanuel says to SKIP the first touch after a parabolic run, not to require proof of a prior impulse. v2 replaces it with a not-parabolic guard (`close - SMA(20) < SMA(20) * 0.03`) that blocks entry when already extended, which is the scenario Emmanuel actually describes. Also: widened pullback-evidence tolerance from 0.3% to 0.5% (was the next tightest filter at 46.9%); added 4h ADX(14) > 20 to compensate for removed selectivity.
**v2 → v3**: v2 ran 179 trades over 360 days with 19% win rate and profit factor 0.586. All 179 exits were via the trailing cross_under — the strategy had no upside exit, so winners reversed back through the entry zone before closing. Diagnosis: (1) no take-profit exit; (2) cross_under fired on single-bar noise; (3) ADX>20 was too loose at 71.9% pass rate; (4) 4h SMA rising check (offset=5, 20 hours) allowed entries in a turning-flat regime; (5) no candle-body confirmation at entry. v3 adds a take-profit exit at +1.5% above 15m SMA, replaces cross_under with a 0.1%-below-SMA compare, adds `close > open` (bullish candle), tightens 4h SMA rising to offset=2 (8 hours), and raises ADX threshold to 25.
**v3 → v4**: v3 ran 127 trades with 21.26% win rate and profit factor 0.686. Take-profit fired only 11 times (8.7% of exits) — most trades reversed before reaching +1.5%. The 15m conditions all pass at ~50%, indicating entries are happening regardless of whether the 15m is itself in a local bull or bear regime. v4 adds a 15m SMA stack requirement (`SMA(20,15m) > SMA(200,15m)`) for triple timeframe alignment; tightens entry proximity from 0.5% to 0.3%; and lowers the take-profit target from 1.5% to 1.0%.

View File

@@ -0,0 +1,329 @@
{
"type": "rule_based",
"candle_interval": "15m",
"rules": [
{
"comment": "ENTRY: Emmanuel MTF pullback long v1. 4h trend context (20 SMA rising under price, close > 200 SMA) + 15m entry (price retraces to 20 SMA and bounces). Faithful to transcript: higher TF establishes trend, lower TF times entry at the 20 MA.",
"when": {
"kind": "all_of",
"conditions": [
{
"comment": "Gate: only enter from flat",
"kind": "position",
"state": "flat"
},
{
"comment": "4h trend: close is above the 200 SMA. Emmanuel uses 200 as support/resistance context.",
"kind": "compare",
"left": { "kind": "field", "field": "close", "timeframe": "4h" },
"op": ">",
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 200,
"timeframe": "4h"
}
},
{
"comment": "4h trend: 20 SMA is above 200 SMA (MAs stacked bullish).",
"kind": "compare",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"timeframe": "4h"
},
"op": ">",
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 200,
"timeframe": "4h"
}
},
{
"comment": "4h trend: 20 SMA is rising — current > 5 bars ago. Emmanuel: 'under price, trending higher'.",
"kind": "compare",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"timeframe": "4h"
},
"op": ">",
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"offset": 5,
"timeframe": "4h"
}
},
{
"comment": "4h trend: price is above the 4h 20 SMA. Emmanuel: 'I want to see the 20 MA under price'.",
"kind": "compare",
"left": { "kind": "field", "field": "close", "timeframe": "4h" },
"op": ">",
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"timeframe": "4h"
}
},
{
"comment": "4h: not extended — close < 5% above the 4h 20 SMA. Emmanuel: 'I don't want to be entering when prices are super far away from the 20 MA'.",
"kind": "compare",
"left": {
"kind": "bin_op",
"op": "sub",
"left": { "kind": "field", "field": "close", "timeframe": "4h" },
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"timeframe": "4h"
}
},
"op": "<",
"right": {
"kind": "bin_op",
"op": "mul",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"timeframe": "4h"
},
"right": { "kind": "literal", "value": "0.05" }
}
},
{
"comment": "15m entry: 20 SMA is rising on the entry timeframe — momentum pointing up on the lower TF too.",
"kind": "compare",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"op": ">",
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"offset": 4
}
},
{
"comment": "15m entry: close is above the 15m 20 SMA (price is on the right side of the MA).",
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">=",
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
}
},
{
"comment": "15m proximity: close is within 0.5% above the 15m 20 SMA. Tighter than 4h because 15m candles are smaller.",
"kind": "compare",
"left": {
"kind": "bin_op",
"op": "sub",
"left": { "kind": "field", "field": "close" },
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
}
},
"op": "<",
"right": {
"kind": "bin_op",
"op": "mul",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "literal", "value": "0.005" }
}
},
{
"comment": "15m rising: close > close 3 bars ago. Price is moving up off the MA, not still falling.",
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">",
"right": { "kind": "field", "field": "close", "offset": 3 }
},
{
"comment": "15m pullback evidence: low of last 8 bars touched within 0.3% of the 15m 20 SMA. Confirms price actually retraced to the MA.",
"kind": "compare",
"left": {
"kind": "unary_op",
"op": "abs",
"operand": {
"kind": "bin_op",
"op": "sub",
"left": {
"kind": "func",
"name": "lowest",
"field": "low",
"period": 8
},
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
}
}
},
"op": "<",
"right": {
"kind": "bin_op",
"op": "mul",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "literal", "value": "0.003" }
}
},
{
"comment": "15m not-first-touch after extension: highest high in last 20 bars was > 1.5% above the 15m 20 SMA. Emmanuel: 'always pass on the first touch after a huge parabolic run' — we require evidence of a prior move.",
"kind": "compare",
"left": {
"kind": "bin_op",
"op": "sub",
"left": {
"kind": "func",
"name": "highest",
"field": "high",
"period": 20
},
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
}
},
"op": ">",
"right": {
"kind": "bin_op",
"op": "mul",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "literal", "value": "0.015" }
}
}
]
},
"then": { "side": "buy", "quantity": "0.001" }
},
{
"comment": "EXIT 1 (trend exit): close crosses below the 15m 20 SMA. Emmanuel trails his exits against the 20 MA on the entry timeframe.",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "cross_under",
"left": { "kind": "field", "field": "close" },
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
}
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
},
{
"comment": "EXIT 2 (higher TF structure lost): 4h 20 SMA crosses below 4h 200 SMA. Macro trend has broken.",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "cross_under",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"timeframe": "4h"
},
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 200,
"timeframe": "4h"
}
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
},
{
"comment": "EXIT 3 (hard stop): close is more than 2% below the 15m 20 SMA. Tighter than 4h version — on 15m timeframe, 2% is a significant breakdown.",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "compare",
"left": {
"kind": "bin_op",
"op": "sub",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "field", "field": "close" }
},
"op": ">",
"right": {
"kind": "bin_op",
"op": "mul",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "literal", "value": "0.02" }
}
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
}
]
}

View File

@@ -0,0 +1,337 @@
{
"type": "rule_based",
"candle_interval": "15m",
"rules": [
{
"comment": "ENTRY: Emmanuel MTF pullback long v2. Same 4h trend context as v1 (20/200 SMA stack) + 15m entry. Key changes from v1: (1) removes the not-first-touch filter (condition 11 in v1) which was a 7% bottleneck that misread the transcript — Emmanuel says skip the FIRST touch after a parabolic run, not require proof of a prior impulse; (2) widens pullback-evidence tolerance from 0.3% to 0.5% for more candidates; (3) adds ADX(14) > 20 on 4h to compensate for removed selectivity.",
"when": {
"kind": "all_of",
"conditions": [
{
"comment": "Gate: only enter from flat",
"kind": "position",
"state": "flat"
},
{
"comment": "4h trend: close is above the 200 SMA.",
"kind": "compare",
"left": { "kind": "field", "field": "close", "timeframe": "4h" },
"op": ">",
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 200,
"timeframe": "4h"
}
},
{
"comment": "4h trend: 20 SMA is above 200 SMA (MAs stacked bullish).",
"kind": "compare",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"timeframe": "4h"
},
"op": ">",
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 200,
"timeframe": "4h"
}
},
{
"comment": "4h trend: 20 SMA is rising — current > 5 bars ago.",
"kind": "compare",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"timeframe": "4h"
},
"op": ">",
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"offset": 5,
"timeframe": "4h"
}
},
{
"comment": "4h trend: price is above the 4h 20 SMA.",
"kind": "compare",
"left": { "kind": "field", "field": "close", "timeframe": "4h" },
"op": ">",
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"timeframe": "4h"
}
},
{
"comment": "4h: not extended — close < 5% above the 4h 20 SMA.",
"kind": "compare",
"left": {
"kind": "bin_op",
"op": "sub",
"left": { "kind": "field", "field": "close", "timeframe": "4h" },
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"timeframe": "4h"
}
},
"op": "<",
"right": {
"kind": "bin_op",
"op": "mul",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"timeframe": "4h"
},
"right": { "kind": "literal", "value": "0.05" }
}
},
{
"comment": "4h: ADX(14) > 20 — trending conditions on the higher timeframe, avoids choppy sideways markets. Replaces selectivity lost by removing the not-first-touch filter.",
"kind": "compare",
"left": {
"kind": "func",
"name": "adx",
"field": "close",
"period": 14,
"timeframe": "4h"
},
"op": ">",
"right": { "kind": "literal", "value": "20" }
},
{
"comment": "15m entry: 20 SMA is rising on the entry timeframe.",
"kind": "compare",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"op": ">",
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"offset": 4
}
},
{
"comment": "15m entry: close is above the 15m 20 SMA.",
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">=",
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
}
},
{
"comment": "15m proximity: close is within 0.5% above the 15m 20 SMA.",
"kind": "compare",
"left": {
"kind": "bin_op",
"op": "sub",
"left": { "kind": "field", "field": "close" },
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
}
},
"op": "<",
"right": {
"kind": "bin_op",
"op": "mul",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "literal", "value": "0.005" }
}
},
{
"comment": "15m rising: close > close 3 bars ago. Price is moving up off the MA.",
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">",
"right": { "kind": "field", "field": "close", "offset": 3 }
},
{
"comment": "15m pullback evidence: low of last 8 bars touched within 0.5% of the 15m 20 SMA (widened from v1's 0.3% which was a secondary bottleneck at 46.9% pass rate). Confirms price actually retraced to the MA.",
"kind": "compare",
"left": {
"kind": "unary_op",
"op": "abs",
"operand": {
"kind": "bin_op",
"op": "sub",
"left": {
"kind": "func",
"name": "lowest",
"field": "low",
"period": 8
},
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
}
}
},
"op": "<",
"right": {
"kind": "bin_op",
"op": "mul",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "literal", "value": "0.005" }
}
},
{
"comment": "15m not-parabolic: close is NOT more than 3% above the 15m 20 SMA. Emmanuel: 'always pass on the first touch of the 20 MA after a huge parabolic run' — filter out overextended bounces where price shot far above the MA and is now coming back for the first time.",
"kind": "compare",
"left": {
"kind": "bin_op",
"op": "sub",
"left": { "kind": "field", "field": "close" },
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
}
},
"op": "<",
"right": {
"kind": "bin_op",
"op": "mul",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "literal", "value": "0.03" }
}
}
]
},
"then": { "side": "buy", "quantity": "0.001" }
},
{
"comment": "EXIT 1 (trend exit): close crosses below the 15m 20 SMA. Emmanuel trails his exits against the 20 MA on the entry timeframe.",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "cross_under",
"left": { "kind": "field", "field": "close" },
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
}
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
},
{
"comment": "EXIT 2 (higher TF structure lost): 4h 20 SMA crosses below 4h 200 SMA. Macro trend has broken.",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "cross_under",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"timeframe": "4h"
},
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 200,
"timeframe": "4h"
}
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
},
{
"comment": "EXIT 3 (hard stop): close is more than 2% below the 15m 20 SMA.",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "compare",
"left": {
"kind": "bin_op",
"op": "sub",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "field", "field": "close" }
},
"op": ">",
"right": {
"kind": "bin_op",
"op": "mul",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "literal", "value": "0.02" }
}
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
}
]
}

View File

@@ -0,0 +1,396 @@
{
"type": "rule_based",
"candle_interval": "15m",
"rules": [
{
"comment": "ENTRY: Emmanuel MTF pullback long v3. Key changes from v2: (1) tightens 4h SMA rising check from offset=5 to offset=2 (8 hours instead of 20 — requires the MA to be rising NOW, not just net-up over a long window); (2) raises ADX(14) threshold from 20 to 25 (v2 audit showed ADX>20 passing 71.9% of bars — barely filtering; >25 focuses on genuinely trending regimes); (3) adds bullish-candle confirmation: close > open on the 15m entry bar (momentum at the MA touch); (4) replaces cross_under exit with a meaningful close-below-SMA threshold (0.1%) to absorb single-bar noise; (5) adds take-profit exit at close 1.5% above 15m 20 SMA — v2 had no upside exit, so winners reversed back to losses; profit factor was 0.586 with 19% win rate (needs ~28.6% to break even).",
"when": {
"kind": "all_of",
"conditions": [
{
"comment": "Gate: only enter from flat",
"kind": "position",
"state": "flat"
},
{
"comment": "4h trend: close is above the 200 SMA.",
"kind": "compare",
"left": { "kind": "field", "field": "close", "timeframe": "4h" },
"op": ">",
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 200,
"timeframe": "4h"
}
},
{
"comment": "4h trend: 20 SMA is above 200 SMA (MAs stacked bullish).",
"kind": "compare",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"timeframe": "4h"
},
"op": ">",
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 200,
"timeframe": "4h"
}
},
{
"comment": "4h trend: 20 SMA is rising — current > 2 bars ago (8 hours). Tightened from v2's offset=5 (20 hours) which allowed entries in a turning-flat MA regime.",
"kind": "compare",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"timeframe": "4h"
},
"op": ">",
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"offset": 2,
"timeframe": "4h"
}
},
{
"comment": "4h trend: price is above the 4h 20 SMA.",
"kind": "compare",
"left": { "kind": "field", "field": "close", "timeframe": "4h" },
"op": ">",
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"timeframe": "4h"
}
},
{
"comment": "4h: not extended — close < 5% above the 4h 20 SMA.",
"kind": "compare",
"left": {
"kind": "bin_op",
"op": "sub",
"left": { "kind": "field", "field": "close", "timeframe": "4h" },
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"timeframe": "4h"
}
},
"op": "<",
"right": {
"kind": "bin_op",
"op": "mul",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"timeframe": "4h"
},
"right": { "kind": "literal", "value": "0.05" }
}
},
{
"comment": "4h: ADX(14) > 25 — stronger trending-regime filter. v2 used >20 which passed 71.9% of bars (barely selective). >25 focuses entries on higher-conviction uptrends.",
"kind": "compare",
"left": {
"kind": "func",
"name": "adx",
"field": "close",
"period": 14,
"timeframe": "4h"
},
"op": ">",
"right": { "kind": "literal", "value": "25" }
},
{
"comment": "15m entry: 20 SMA is rising on the entry timeframe.",
"kind": "compare",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"op": ">",
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"offset": 4
}
},
{
"comment": "15m entry: close is above the 15m 20 SMA.",
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">=",
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
}
},
{
"comment": "15m proximity: close is within 0.5% above the 15m 20 SMA.",
"kind": "compare",
"left": {
"kind": "bin_op",
"op": "sub",
"left": { "kind": "field", "field": "close" },
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
}
},
"op": "<",
"right": {
"kind": "bin_op",
"op": "mul",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "literal", "value": "0.005" }
}
},
{
"comment": "15m rising: close > close 3 bars ago. Price is moving up off the MA.",
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">",
"right": { "kind": "field", "field": "close", "offset": 3 }
},
{
"comment": "15m pullback evidence: low of last 8 bars touched within 0.5% of the 15m 20 SMA. Confirms price actually retraced to the MA.",
"kind": "compare",
"left": {
"kind": "unary_op",
"op": "abs",
"operand": {
"kind": "bin_op",
"op": "sub",
"left": {
"kind": "func",
"name": "lowest",
"field": "low",
"period": 8
},
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
}
}
},
"op": "<",
"right": {
"kind": "bin_op",
"op": "mul",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "literal", "value": "0.005" }
}
},
{
"comment": "15m not-parabolic: close is NOT more than 3% above the 15m 20 SMA.",
"kind": "compare",
"left": {
"kind": "bin_op",
"op": "sub",
"left": { "kind": "field", "field": "close" },
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
}
},
"op": "<",
"right": {
"kind": "bin_op",
"op": "mul",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "literal", "value": "0.03" }
}
},
{
"comment": "15m bullish candle: close > open. Confirms buyers are in control at the MA touch — proxy for Emmanuel's candle-structure read.",
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">",
"right": { "kind": "field", "field": "open" }
}
]
},
"then": { "side": "buy", "quantity": "0.001" }
},
{
"comment": "EXIT 1 (trailing stop): close is more than 0.1% below the 15m 20 SMA. Replaced v2's cross_under (which fired on single-bar noise) with a meaningful threshold — price must actually close below the MA, not just tick through it.",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "compare",
"left": {
"kind": "bin_op",
"op": "sub",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "field", "field": "close" }
},
"op": ">",
"right": {
"kind": "bin_op",
"op": "mul",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "literal", "value": "0.001" }
}
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
},
{
"comment": "EXIT 2 (take-profit): close is more than 1.5% above the 15m 20 SMA. v2 had no upside exit — winners reversed back to losses. This banks profit when price has moved meaningfully above the MA (mirrors the 2% hard stop on the downside).",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "compare",
"left": {
"kind": "bin_op",
"op": "sub",
"left": { "kind": "field", "field": "close" },
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
}
},
"op": ">",
"right": {
"kind": "bin_op",
"op": "mul",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "literal", "value": "0.015" }
}
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
},
{
"comment": "EXIT 3 (higher TF structure lost): 4h 20 SMA crosses below 4h 200 SMA. Macro trend has broken.",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "cross_under",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"timeframe": "4h"
},
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 200,
"timeframe": "4h"
}
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
},
{
"comment": "EXIT 4 (hard stop): close is more than 2% below the 15m 20 SMA.",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "compare",
"left": {
"kind": "bin_op",
"op": "sub",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "field", "field": "close" }
},
"op": ">",
"right": {
"kind": "bin_op",
"op": "mul",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "literal", "value": "0.02" }
}
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
}
]
}

View File

@@ -0,0 +1,413 @@
{
"type": "rule_based",
"candle_interval": "15m",
"rules": [
{
"comment": "ENTRY: Emmanuel MTF pullback long v4. Key changes from v3: (1) adds 15m SMA stack: SMA(20,15m) > SMA(200,15m) — requires the entry timeframe itself to be in a local bull regime, not just the 4h. Forces triple alignment (4h bullish + 15m bullish + pullback to 15m 20 SMA); eliminates entries where the 15m 20 SMA is merely acting as resistance in a local downtrend. (2) tightens entry proximity from 0.5% to 0.3% — reduces average loss per trade by requiring entries closer to the SMA; fewer bars qualify but each has smaller downside if wrong. (3) take-profit threshold lowered from 1.5% to 1.0% — v3 audit showed take-profit fired only 11 times (8.7% of 127 exits); most trades never reached 1.5%, reversing back through the trailing stop. Lowering captures more trades at profit before reversal.",
"when": {
"kind": "all_of",
"conditions": [
{
"comment": "Gate: only enter from flat",
"kind": "position",
"state": "flat"
},
{
"comment": "4h trend: close is above the 200 SMA.",
"kind": "compare",
"left": { "kind": "field", "field": "close", "timeframe": "4h" },
"op": ">",
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 200,
"timeframe": "4h"
}
},
{
"comment": "4h trend: 20 SMA is above 200 SMA (MAs stacked bullish).",
"kind": "compare",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"timeframe": "4h"
},
"op": ">",
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 200,
"timeframe": "4h"
}
},
{
"comment": "4h trend: 20 SMA is rising — current > 2 bars ago (8 hours).",
"kind": "compare",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"timeframe": "4h"
},
"op": ">",
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"offset": 2,
"timeframe": "4h"
}
},
{
"comment": "4h trend: price is above the 4h 20 SMA.",
"kind": "compare",
"left": { "kind": "field", "field": "close", "timeframe": "4h" },
"op": ">",
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"timeframe": "4h"
}
},
{
"comment": "4h: not extended — close < 5% above the 4h 20 SMA.",
"kind": "compare",
"left": {
"kind": "bin_op",
"op": "sub",
"left": { "kind": "field", "field": "close", "timeframe": "4h" },
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"timeframe": "4h"
}
},
"op": "<",
"right": {
"kind": "bin_op",
"op": "mul",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"timeframe": "4h"
},
"right": { "kind": "literal", "value": "0.05" }
}
},
{
"comment": "4h: ADX(14) > 25 — trending conditions on the higher timeframe.",
"kind": "compare",
"left": {
"kind": "func",
"name": "adx",
"field": "close",
"period": 14,
"timeframe": "4h"
},
"op": ">",
"right": { "kind": "literal", "value": "25" }
},
{
"comment": "15m local bull regime: 15m 20 SMA is above 15m 200 SMA. Triple alignment — 4h bullish, 15m bullish, now pulling back to the 15m 20 SMA. Without this, the 15m 20 SMA may be acting as resistance (local downtrend) rather than support (local uptrend).",
"kind": "compare",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"op": ">",
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 200
}
},
{
"comment": "15m entry: 20 SMA is rising on the entry timeframe.",
"kind": "compare",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"op": ">",
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"offset": 4
}
},
{
"comment": "15m entry: close is above the 15m 20 SMA.",
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">=",
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
}
},
{
"comment": "15m proximity: close is within 0.3% above the 15m 20 SMA. Tightened from v3's 0.5% — closer entries mean smaller losses when wrong and better risk/reward toward the take-profit.",
"kind": "compare",
"left": {
"kind": "bin_op",
"op": "sub",
"left": { "kind": "field", "field": "close" },
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
}
},
"op": "<",
"right": {
"kind": "bin_op",
"op": "mul",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "literal", "value": "0.003" }
}
},
{
"comment": "15m rising: close > close 3 bars ago. Price is moving up off the MA.",
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">",
"right": { "kind": "field", "field": "close", "offset": 3 }
},
{
"comment": "15m pullback evidence: low of last 8 bars touched within 0.5% of the 15m 20 SMA. Confirms price actually retraced to the MA.",
"kind": "compare",
"left": {
"kind": "unary_op",
"op": "abs",
"operand": {
"kind": "bin_op",
"op": "sub",
"left": {
"kind": "func",
"name": "lowest",
"field": "low",
"period": 8
},
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
}
}
},
"op": "<",
"right": {
"kind": "bin_op",
"op": "mul",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "literal", "value": "0.005" }
}
},
{
"comment": "15m not-parabolic: close is NOT more than 3% above the 15m 20 SMA.",
"kind": "compare",
"left": {
"kind": "bin_op",
"op": "sub",
"left": { "kind": "field", "field": "close" },
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
}
},
"op": "<",
"right": {
"kind": "bin_op",
"op": "mul",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "literal", "value": "0.03" }
}
},
{
"comment": "15m bullish candle: close > open. Confirms buyers are in control at the MA touch.",
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">",
"right": { "kind": "field", "field": "open" }
}
]
},
"then": { "side": "buy", "quantity": "0.001" }
},
{
"comment": "EXIT 1 (trailing stop): close is more than 0.1% below the 15m 20 SMA.",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "compare",
"left": {
"kind": "bin_op",
"op": "sub",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "field", "field": "close" }
},
"op": ">",
"right": {
"kind": "bin_op",
"op": "mul",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "literal", "value": "0.001" }
}
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
},
{
"comment": "EXIT 2 (take-profit): close is more than 1.0% above the 15m 20 SMA. Lowered from v3's 1.5% — v3 audit showed take-profit firing only 11 times (8.7% of exits); most trades reversed before reaching 1.5%. 1.0% captures more trades at profit before the inevitable pullback.",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "compare",
"left": {
"kind": "bin_op",
"op": "sub",
"left": { "kind": "field", "field": "close" },
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
}
},
"op": ">",
"right": {
"kind": "bin_op",
"op": "mul",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "literal", "value": "0.010" }
}
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
},
{
"comment": "EXIT 3 (higher TF structure lost): 4h 20 SMA crosses below 4h 200 SMA. Macro trend has broken.",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "cross_under",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20,
"timeframe": "4h"
},
"right": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 200,
"timeframe": "4h"
}
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
},
{
"comment": "EXIT 4 (hard stop): close is more than 2% below the 15m 20 SMA.",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "compare",
"left": {
"kind": "bin_op",
"op": "sub",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "field", "field": "close" }
},
"op": ">",
"right": {
"kind": "bin_op",
"op": "mul",
"left": {
"kind": "func",
"name": "sma",
"field": "close",
"period": 20
},
"right": { "kind": "literal", "value": "0.02" }
}
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
}
]
}

View File

@@ -0,0 +1,42 @@
# Gaussian Channel
Based on the Gaussian Channel indicator (DonovanWall). A true Gaussian filter is
approximated by applying EMA through itself four times (4-pole EMA), then adding
ATR-based bands above and below.
## Versions
| Version | File | Notes |
|---------|---------|------------------------------------------------------------------------|
| v2 | v2.json | Persistent `compare` for band break (holds while above upper band) |
| v3 | v3.json | Uses `cross_over`/`cross_under` — signals only on the transition bar |
## Logic
**4-pole EMA (period 20)**: `EMA(EMA(EMA(EMA(close, 20), 20), 20), 20)`
**Upper band**: 4-pole-EMA + 1.6 × ATR(14)
**Lower band**: 4-pole-EMA 1.6 × ATR(14)
**Entry** (v2): `close > upper_band` AND 4-pole-EMA rising (slope positive). Holds
while these conditions remain true — re-enters after any exit if price is still above band.
**Entry** (v3): `close cross_over upper_band` AND 4-pole-EMA rising. Single-bar
transition only — avoids re-entry while price stays above the band.
**Exit** (both): `close < lower_band` OR 4-pole-EMA declining (slope negative).
## Indicators
- `EMA` applied 4× (Gaussian approximation), period 20 at each pole
- `ATR(14)` — band width
## Data requirements
- 1h candles recommended.
- Minimum warmup: ~80 candles for 4-pole EMA (4 × 20).
## Known limitations
- 4-pole EMA is a Gaussian approximation, not a true Gaussian filter (which would
require a convolution kernel not expressible in the DSL).
- ATR-based bands are symmetric; volatility asymmetry is ignored.

View File

@@ -0,0 +1,125 @@
{
"type": "rule_based",
"candle_interval": "1h",
"rules": [
{
"comment": "Entry: close above upper Gaussian band and filter rising",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "flat" },
{
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">",
"right": {
"kind": "bin_op", "op": "add",
"left": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": { "kind": "func", "name": "ema", "field": "close", "period": 20 }
}
}
},
"right": {
"kind": "bin_op", "op": "mul",
"left": { "kind": "literal", "value": "1.6" },
"right": { "kind": "func", "name": "atr", "field": "close", "period": 14 }
}
}
},
{
"kind": "compare",
"left": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": { "kind": "func", "name": "ema", "field": "close", "period": 20 }
}
}
},
"op": ">",
"right": {
"kind": "apply_func", "name": "ema", "period": 20, "offset": 1,
"input": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": { "kind": "func", "name": "ema", "field": "close", "period": 20 }
}
}
}
}
]
},
"then": { "side": "buy", "quantity": "0.001" }
},
{
"comment": "Exit: close below lower band or filter declining",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "any_of",
"conditions": [
{
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": "<",
"right": {
"kind": "bin_op", "op": "sub",
"left": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": { "kind": "func", "name": "ema", "field": "close", "period": 20 }
}
}
},
"right": {
"kind": "bin_op", "op": "mul",
"left": { "kind": "literal", "value": "1.6" },
"right": { "kind": "func", "name": "atr", "field": "close", "period": 14 }
}
}
},
{
"kind": "compare",
"left": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": { "kind": "func", "name": "ema", "field": "close", "period": 20 }
}
}
},
"op": "<",
"right": {
"kind": "apply_func", "name": "ema", "period": 20, "offset": 1,
"input": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": { "kind": "func", "name": "ema", "field": "close", "period": 20 }
}
}
}
}
]
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
}
]
}

View File

@@ -0,0 +1,123 @@
{
"type": "rule_based",
"candle_interval": "1h",
"rules": [
{
"comment": "Entry: close crosses above upper Gaussian band and filter rising (transition, not persistent state)",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "flat" },
{
"kind": "cross_over",
"left": { "kind": "field", "field": "close" },
"right": {
"kind": "bin_op", "op": "add",
"left": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": { "kind": "func", "name": "ema", "field": "close", "period": 20 }
}
}
},
"right": {
"kind": "bin_op", "op": "mul",
"left": { "kind": "literal", "value": "1.6" },
"right": { "kind": "func", "name": "atr", "field": "close", "period": 14 }
}
}
},
{
"kind": "compare",
"left": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": { "kind": "func", "name": "ema", "field": "close", "period": 20 }
}
}
},
"op": ">",
"right": {
"kind": "apply_func", "name": "ema", "period": 20, "offset": 1,
"input": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": { "kind": "func", "name": "ema", "field": "close", "period": 20 }
}
}
}
}
]
},
"then": { "side": "buy", "quantity": "0.001" }
},
{
"comment": "Exit: close crosses below lower Gaussian band or filter declining",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "any_of",
"conditions": [
{
"kind": "cross_under",
"left": { "kind": "field", "field": "close" },
"right": {
"kind": "bin_op", "op": "sub",
"left": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": { "kind": "func", "name": "ema", "field": "close", "period": 20 }
}
}
},
"right": {
"kind": "bin_op", "op": "mul",
"left": { "kind": "literal", "value": "1.6" },
"right": { "kind": "func", "name": "atr", "field": "close", "period": 14 }
}
}
},
{
"kind": "compare",
"left": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": { "kind": "func", "name": "ema", "field": "close", "period": 20 }
}
}
},
"op": "<",
"right": {
"kind": "apply_func", "name": "ema", "period": 20, "offset": 1,
"input": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": {
"kind": "apply_func", "name": "ema", "period": 20,
"input": { "kind": "func", "name": "ema", "field": "close", "period": 20 }
}
}
}
}
]
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
}
]
}

View File

@@ -0,0 +1,30 @@
# HODL — Buy & Hold Benchmark
A simple buy-and-hold strategy that enters a position at the start and never exits.
Used as a benchmark: any active strategy should outperform this on a risk-adjusted basis.
## Versions
| Version | File | Notes |
|---------|---------|------------------------------|
| v1 | v1.json | Initial implementation |
## Logic
**Entry**: Buy immediately when flat (first candle).
**Exit**: Never.
## Indicators
None.
## Data requirements
- Any instrument and interval.
- Daily candles (`1d`) are standard.
## Known limitations
This is intentionally a do-nothing strategy. Its primary use is to establish a
baseline return that active strategies must beat on a risk-adjusted basis. It
should not be used for live trading.

View File

@@ -0,0 +1,11 @@
{
"type": "rule_based",
"candle_interval": "1d",
"rules": [
{
"comment": "Buy and hold: enter immediately when flat, never exit",
"when": { "kind": "position", "state": "flat" },
"then": { "side": "buy", "quantity": "0.001" }
}
]
}

View File

@@ -0,0 +1,38 @@
# Hull Suite
Based on the Hull Moving Average by Alan Hull. Combines HMA with ATR-based bands
to define a breakout zone, trading only when price escapes the band with the HMA
confirming the trend direction.
## Versions
| Version | File | Notes |
|---------|---------|--------------------------------------------|
| v2 | v2.json | HMA(9) + ATR(14) bands, slope confirmation |
## Logic
**HMA(9)** = `WMA(2 × WMA(close, 4) WMA(close, 9), 3)``floor(sqrt(9)) = 3`
**Upper band**: HMA(9) + ATR(14)
**Lower band**: HMA(9) ATR(14)
**Entry**: close > upper band AND HMA rising (HMA > HMA[1]).
**Exit**: close < lower band OR HMA declining (HMA < HMA[1]).
## Indicators
- `WMA(4)`, `WMA(9)` — weighted moving averages
- `apply_func(wma, period=3)` — outer WMA for HMA calculation
- `ATR(14)` — band width
## Data requirements
- 1h candles recommended.
- Minimum warmup: ~20 candles for WMA/ATR components.
## Known limitations
- HMA(9) is a short-period indicator; it reacts quickly to price changes but
generates more false signals in low-volatility periods.
- ATR bands are fixed multiplier (×1); some implementations use ×1.5 or ×2 for
wider bands and fewer false breakouts.

View File

@@ -0,0 +1,129 @@
{
"type": "rule_based",
"candle_interval": "1h",
"rules": [
{
"comment": "Entry: close above HMA upper band and HMA rising",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "flat" },
{
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">",
"right": {
"kind": "bin_op", "op": "add",
"left": {
"kind": "apply_func", "name": "wma", "period": 3,
"input": {
"kind": "bin_op", "op": "sub",
"left": {
"kind": "bin_op", "op": "mul",
"left": { "kind": "literal", "value": "2" },
"right": { "kind": "func", "name": "wma", "field": "close", "period": 4 }
},
"right": { "kind": "func", "name": "wma", "field": "close", "period": 9 }
}
},
"right": { "kind": "func", "name": "atr", "field": "close", "period": 14 }
}
},
{
"kind": "compare",
"left": {
"kind": "apply_func", "name": "wma", "period": 3,
"input": {
"kind": "bin_op", "op": "sub",
"left": {
"kind": "bin_op", "op": "mul",
"left": { "kind": "literal", "value": "2" },
"right": { "kind": "func", "name": "wma", "field": "close", "period": 4 }
},
"right": { "kind": "func", "name": "wma", "field": "close", "period": 9 }
}
},
"op": ">",
"right": {
"kind": "apply_func", "name": "wma", "period": 3, "offset": 1,
"input": {
"kind": "bin_op", "op": "sub",
"left": {
"kind": "bin_op", "op": "mul",
"left": { "kind": "literal", "value": "2" },
"right": { "kind": "func", "name": "wma", "field": "close", "period": 4 }
},
"right": { "kind": "func", "name": "wma", "field": "close", "period": 9 }
}
}
}
]
},
"then": { "side": "buy", "quantity": "0.001" }
},
{
"comment": "Exit: close below HMA lower band or HMA declining",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "any_of",
"conditions": [
{
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": "<",
"right": {
"kind": "bin_op", "op": "sub",
"left": {
"kind": "apply_func", "name": "wma", "period": 3,
"input": {
"kind": "bin_op", "op": "sub",
"left": {
"kind": "bin_op", "op": "mul",
"left": { "kind": "literal", "value": "2" },
"right": { "kind": "func", "name": "wma", "field": "close", "period": 4 }
},
"right": { "kind": "func", "name": "wma", "field": "close", "period": 9 }
}
},
"right": { "kind": "func", "name": "atr", "field": "close", "period": 14 }
}
},
{
"kind": "compare",
"left": {
"kind": "apply_func", "name": "wma", "period": 3,
"input": {
"kind": "bin_op", "op": "sub",
"left": {
"kind": "bin_op", "op": "mul",
"left": { "kind": "literal", "value": "2" },
"right": { "kind": "func", "name": "wma", "field": "close", "period": 4 }
},
"right": { "kind": "func", "name": "wma", "field": "close", "period": 9 }
}
},
"op": "<",
"right": {
"kind": "apply_func", "name": "wma", "period": 3, "offset": 1,
"input": {
"kind": "bin_op", "op": "sub",
"left": {
"kind": "bin_op", "op": "mul",
"left": { "kind": "literal", "value": "2" },
"right": { "kind": "func", "name": "wma", "field": "close", "period": 4 }
},
"right": { "kind": "func", "name": "wma", "field": "close", "period": 9 }
}
}
}
]
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
}
]
}

View File

@@ -0,0 +1,40 @@
# Ichimoku Kinko Hyo
A well-known Japanese charting system providing support/resistance, trend
direction, and momentum signals from a single chart overlay.
## Versions
| Version | File | Notes |
|---------|---------|---------------------------------------------------------------------|
| v1 | v1.json | Core TK cross + price above Kijun |
| v2 | v2.json | Adds Chikou Span check + cloud-colour filter (Span A > Span B) |
## Components
| Component | Formula |
|-------------------|----------------------------------------------------|
| **Tenkan-sen** | `(highest(high,9) + lowest(low,9)) / 2` |
| **Kijun-sen** | `(highest(high,26) + lowest(low,26)) / 2` |
| **Senkou Span A** | `(Tenkan + Kijun) / 2` (current bar, not projected)|
| **Senkou Span B** | `(highest(high,52) + lowest(low,52)) / 2` |
| **Chikou Span** | Current close vs close 26 bars ago |
## Logic
**Entry** (v1): Tenkan crosses above Kijun AND close > Kijun.
**Entry** (v2): same PLUS close > close[26] (Chikou above past price) AND Span A > Span B (bullish cloud).
**Exit**: Tenkan crosses below Kijun.
## Data requirements
- 1h candles recommended.
- Minimum warmup: 52 candles for Span B + 26 candles offset for Chikou.
## Known limitations
- The cloud is normally plotted 26 bars *ahead*, giving a forward view of
support/resistance. This is not possible in the DSL; Span A and B are computed
at the current bar as a proxy for current cloud position.
- Chikou cross-check against Kumo (cloud) upper bound is omitted.
- No stop-loss; relies entirely on the Tenkan/Kijun exit crossover.

View File

@@ -0,0 +1,82 @@
{
"type": "rule_based",
"candle_interval": "1h",
"rules": [
{
"comment": "Enter long: Tenkan crosses above Kijun and price above Kijun",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "flat" },
{
"kind": "cross_over",
"left": {
"kind": "bin_op", "op": "div",
"left": {
"kind": "bin_op", "op": "add",
"left": { "kind": "func", "name": "highest", "field": "high", "period": 9 },
"right": { "kind": "func", "name": "lowest", "field": "low", "period": 9 }
},
"right": { "kind": "literal", "value": "2" }
},
"right": {
"kind": "bin_op", "op": "div",
"left": {
"kind": "bin_op", "op": "add",
"left": { "kind": "func", "name": "highest", "field": "high", "period": 26 },
"right": { "kind": "func", "name": "lowest", "field": "low", "period": 26 }
},
"right": { "kind": "literal", "value": "2" }
}
},
{
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">",
"right": {
"kind": "bin_op", "op": "div",
"left": {
"kind": "bin_op", "op": "add",
"left": { "kind": "func", "name": "highest", "field": "high", "period": 26 },
"right": { "kind": "func", "name": "lowest", "field": "low", "period": 26 }
},
"right": { "kind": "literal", "value": "2" }
}
}
]
},
"then": { "side": "buy", "quantity": "0.001" }
},
{
"comment": "Exit long: Tenkan crosses below Kijun",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "cross_under",
"left": {
"kind": "bin_op", "op": "div",
"left": {
"kind": "bin_op", "op": "add",
"left": { "kind": "func", "name": "highest", "field": "high", "period": 9 },
"right": { "kind": "func", "name": "lowest", "field": "low", "period": 9 }
},
"right": { "kind": "literal", "value": "2" }
},
"right": {
"kind": "bin_op", "op": "div",
"left": {
"kind": "bin_op", "op": "add",
"left": { "kind": "func", "name": "highest", "field": "high", "period": 26 },
"right": { "kind": "func", "name": "lowest", "field": "low", "period": 26 }
},
"right": { "kind": "literal", "value": "2" }
}
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
}
]
}

View File

@@ -0,0 +1,128 @@
{
"type": "rule_based",
"candle_interval": "1h",
"rules": [
{
"comment": "Enter long: Tenkan crosses above Kijun + price above Kijun + Chikou above past price + bullish cloud (Span A > Span B)",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "flat" },
{
"kind": "cross_over",
"left": {
"kind": "bin_op", "op": "div",
"left": {
"kind": "bin_op", "op": "add",
"left": { "kind": "func", "name": "highest", "field": "high", "period": 9 },
"right": { "kind": "func", "name": "lowest", "field": "low", "period": 9 }
},
"right": { "kind": "literal", "value": "2" }
},
"right": {
"kind": "bin_op", "op": "div",
"left": {
"kind": "bin_op", "op": "add",
"left": { "kind": "func", "name": "highest", "field": "high", "period": 26 },
"right": { "kind": "func", "name": "lowest", "field": "low", "period": 26 }
},
"right": { "kind": "literal", "value": "2" }
}
},
{
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">",
"right": {
"kind": "bin_op", "op": "div",
"left": {
"kind": "bin_op", "op": "add",
"left": { "kind": "func", "name": "highest", "field": "high", "period": 26 },
"right": { "kind": "func", "name": "lowest", "field": "low", "period": 26 }
},
"right": { "kind": "literal", "value": "2" }
}
},
{
"comment": "Chikou: close is above the price from 26 bars ago",
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">",
"right": { "kind": "field", "field": "close", "offset": 26 }
},
{
"comment": "Cloud colour: Span A (Tenkan+Kijun)/2 > Span B (52-period midpoint) — bullish cloud",
"kind": "compare",
"left": {
"kind": "bin_op", "op": "div",
"left": {
"kind": "bin_op", "op": "add",
"left": {
"kind": "bin_op", "op": "div",
"left": {
"kind": "bin_op", "op": "add",
"left": { "kind": "func", "name": "highest", "field": "high", "period": 9 },
"right": { "kind": "func", "name": "lowest", "field": "low", "period": 9 }
},
"right": { "kind": "literal", "value": "2" }
},
"right": {
"kind": "bin_op", "op": "div",
"left": {
"kind": "bin_op", "op": "add",
"left": { "kind": "func", "name": "highest", "field": "high", "period": 26 },
"right": { "kind": "func", "name": "lowest", "field": "low", "period": 26 }
},
"right": { "kind": "literal", "value": "2" }
}
},
"right": { "kind": "literal", "value": "2" }
},
"op": ">",
"right": {
"kind": "bin_op", "op": "div",
"left": {
"kind": "bin_op", "op": "add",
"left": { "kind": "func", "name": "highest", "field": "high", "period": 52 },
"right": { "kind": "func", "name": "lowest", "field": "low", "period": 52 }
},
"right": { "kind": "literal", "value": "2" }
}
}
]
},
"then": { "side": "buy", "quantity": "0.001" }
},
{
"comment": "Exit long: Tenkan crosses below Kijun",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "cross_under",
"left": {
"kind": "bin_op", "op": "div",
"left": {
"kind": "bin_op", "op": "add",
"left": { "kind": "func", "name": "highest", "field": "high", "period": 9 },
"right": { "kind": "func", "name": "lowest", "field": "low", "period": 9 }
},
"right": { "kind": "literal", "value": "2" }
},
"right": {
"kind": "bin_op", "op": "div",
"left": {
"kind": "bin_op", "op": "add",
"left": { "kind": "func", "name": "highest", "field": "high", "period": 26 },
"right": { "kind": "func", "name": "lowest", "field": "low", "period": 26 }
},
"right": { "kind": "literal", "value": "2" }
}
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
}
]
}

View File

@@ -0,0 +1,37 @@
# Momentum Cascade
Stack several momentum signals in sequence — each must confirm before the next is
checked. Enters only when trend, momentum, and price action all align.
## Versions
| Version | File | Notes |
|---------|---------|--------------------------------------------------|
| v2 | v2.json | EMA trend stack + ADX gate + EMA(20) crossover |
## Logic
**Entry cascade** (all three must be true simultaneously):
1. `EMA(50) > EMA(200)` — long-term uptrend
2. `ADX(14) > 25` — trending, not ranging
3. `close cross_over EMA(20)` — short-term price breakout trigger
**Exit**: close crosses below EMA(20).
## Indicators
- `EMA(20)` — short-term trend / entry trigger
- `EMA(50)` — medium-term trend filter
- `EMA(200)` — long-term trend filter
- `ADX(14)` — trend strength / choppiness filter
## Data requirements
- 1h candles recommended.
- Minimum warmup: ~200 candles for EMA(200).
## Known limitations
- Three simultaneous filters reduce trade frequency significantly; may underperform
in strongly trending markets that begin before EMA(50) > EMA(200) is confirmed.
- No stop-loss beyond the EMA(20) crossunder exit.

View File

@@ -0,0 +1,48 @@
{
"type": "rule_based",
"candle_interval": "1h",
"rules": [
{
"comment": "Entry: trend filter + ADX momentum + EMA(20) crossover",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "flat" },
{
"kind": "compare",
"left": { "kind": "func", "name": "ema", "field": "close", "period": 50 },
"op": ">",
"right": { "kind": "func", "name": "ema", "field": "close", "period": 200 }
},
{
"kind": "compare",
"left": { "kind": "func", "name": "adx", "field": "close", "period": 14 },
"op": ">",
"right": { "kind": "literal", "value": "25" }
},
{
"kind": "cross_over",
"left": { "kind": "field", "field": "close" },
"right": { "kind": "func", "name": "ema", "field": "close", "period": 20 }
}
]
},
"then": { "side": "buy", "quantity": "0.001" }
},
{
"comment": "Exit: close crosses below EMA(20)",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "cross_under",
"left": { "kind": "field", "field": "close" },
"right": { "kind": "func", "name": "ema", "field": "close", "period": 20 }
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
}
]
}

View File

@@ -0,0 +1,35 @@
# Money Line
A dynamic support/resistance line derived from volume-weighted price action.
The 20-period rolling VWAP acts as a "money line" — price crossing above it
with volume confirmation indicates institutional buying interest.
## Versions
| Version | File | Notes |
|---------|---------|--------------------------------------------------------|
| v2 | v2.json | VWAP(20) crossover with volume spike confirmation |
## Logic
**Money line**: `VWAP(20) = sum(close × volume, 20) / sum(volume, 20)`
**Entry**: close `cross_over` VWAP(20) AND volume > 1.5 × SMA(volume, 20).
**Exit**: close `cross_under` VWAP(20).
## Indicators
- Rolling VWAP (20-bar window) built from `apply_func(sum)` and `bin_op`
- `SMA(volume, 20)` — average volume for spike detection
## Data requirements
- 1h candles recommended (volume data required).
- Minimum warmup: 20 candles for VWAP and volume SMA.
## Known limitations
- Rolling VWAP resets every 20 bars; it is not a session VWAP (which resets at
the start of each trading day). Session VWAP requires a reset condition not
expressible in the DSL.
- Volume spikes (1.5× average) can trigger false entries during liquidation events.

View File

@@ -0,0 +1,74 @@
{
"type": "rule_based",
"candle_interval": "1h",
"rules": [
{
"comment": "Entry: close crosses above VWAP with volume confirmation",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "flat" },
{
"kind": "cross_over",
"left": { "kind": "field", "field": "close" },
"right": {
"kind": "bin_op", "op": "div",
"left": {
"kind": "apply_func", "name": "sum", "period": 20,
"input": {
"kind": "bin_op", "op": "mul",
"left": { "kind": "field", "field": "close" },
"right": { "kind": "field", "field": "volume" }
}
},
"right": {
"kind": "apply_func", "name": "sum", "period": 20,
"input": { "kind": "field", "field": "volume" }
}
}
},
{
"kind": "compare",
"left": { "kind": "field", "field": "volume" },
"op": ">",
"right": {
"kind": "bin_op", "op": "mul",
"left": { "kind": "literal", "value": "1.5" },
"right": { "kind": "func", "name": "sma", "field": "volume", "period": 20 }
}
}
]
},
"then": { "side": "buy", "quantity": "0.001" }
},
{
"comment": "Exit: close crosses below VWAP",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "cross_under",
"left": { "kind": "field", "field": "close" },
"right": {
"kind": "bin_op", "op": "div",
"left": {
"kind": "apply_func", "name": "sum", "period": 20,
"input": {
"kind": "bin_op", "op": "mul",
"left": { "kind": "field", "field": "close" },
"right": { "kind": "field", "field": "volume" }
}
},
"right": {
"kind": "apply_func", "name": "sum", "period": 20,
"input": { "kind": "field", "field": "volume" }
}
}
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
}
]
}

View File

@@ -1,6 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://swym.internal/strategy.schema.json",
"$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",
@@ -13,13 +13,13 @@
"candle_interval": {
"type": "string",
"enum": ["1m", "5m", "15m", "1h", "4h", "1d"],
"description": "Candle interval driving strategy evaluation."
"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 candle close fire simultaneously."
"description": "All rules whose 'when' is true on a primary candle close fire simultaneously."
}
},
"definitions": {
@@ -32,6 +32,11 @@
"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"],
@@ -159,7 +164,8 @@
"kind": { "const": "ema_crossover" },
"fast_period": { "type": "integer", "minimum": 1 },
"slow_period": { "type": "integer", "minimum": 1 },
"direction": { "type": "string", "enum": ["above", "below"] }
"direction": { "type": "string", "enum": ["above", "below"] },
"timeframe": { "$ref": "#/definitions/TimeframeInterval" }
}
},
"ConditionEmaTrend": {
@@ -171,7 +177,8 @@
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "ema_trend" },
"period": { "type": "integer", "minimum": 1 },
"direction": { "type": "string", "enum": ["above", "below"] }
"direction": { "type": "string", "enum": ["above", "below"] },
"timeframe": { "$ref": "#/definitions/TimeframeInterval" }
}
},
"ConditionRsi": {
@@ -189,7 +196,8 @@
"description": "RSI period. Defaults to 14."
},
"threshold": { "$ref": "#/definitions/DecimalString" },
"comparison": { "type": "string", "enum": ["above", "below"] }
"comparison": { "type": "string", "enum": ["above", "below"] },
"timeframe": { "$ref": "#/definitions/TimeframeInterval" }
}
},
"ConditionBollinger": {
@@ -209,7 +217,8 @@
"$ref": "#/definitions/DecimalString",
"description": "Number of standard deviations. Defaults to \"2\"."
},
"band": { "type": "string", "enum": ["above_upper", "below_lower"] }
"band": { "type": "string", "enum": ["above_upper", "below_lower"] },
"timeframe": { "$ref": "#/definitions/TimeframeInterval" }
}
},
"ConditionPriceLevel": {
@@ -221,7 +230,8 @@
"comment": { "$ref": "#/definitions/ConditionComment" },
"kind": { "const": "price_level" },
"price": { "$ref": "#/definitions/DecimalString" },
"direction": { "type": "string", "enum": ["above", "below"] }
"direction": { "type": "string", "enum": ["above", "below"] },
"timeframe": { "$ref": "#/definitions/TimeframeInterval" }
}
},
"ConditionCompare": {
@@ -284,7 +294,7 @@
}
},
"ExprField": {
"description": "An OHLCV candle field, optionally from N bars ago.",
"description": "An OHLCV candle field, optionally from N bars ago and/or from a different timeframe.",
"type": "object",
"required": ["kind", "field"],
"additionalProperties": false,
@@ -296,11 +306,12 @@
"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.",
"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,
@@ -325,7 +336,8 @@
"multiplier": {
"$ref": "#/definitions/DecimalString",
"description": "ATR multiplier for supertrend only (e.g. \"3.0\"). Omit for all other functions."
}
},
"timeframe": { "$ref": "#/definitions/TimeframeInterval" }
}
},
"ExprBinOp": {
@@ -354,7 +366,8 @@
"type": "integer",
"minimum": 0,
"default": 0
}
},
"timeframe": { "$ref": "#/definitions/TimeframeInterval" }
}
},
"ExprUnaryOp": {

View File

@@ -0,0 +1,37 @@
# Simple Trend + Regime Filter
A baseline trend-following strategy using an EMA crossover for signal generation,
with an optional ADX regime filter to avoid trading in ranging markets.
## Versions
| Version | File | Notes |
|---------|---------|------------------------------------------------------|
| v1 | v1.json | EMA(20/50) crossover only; no regime filter |
| v2 | v2.json | Adds ADX(14) > 25 gate — only trades trending regime |
## Logic
**Entry**: Fast EMA (20) crosses above slow EMA (50).
**Exit**: Fast EMA crosses back below slow EMA.
v2 adds a regime gate: ADX(14) must be above 25 (trending, not ranging) before
the crossover is accepted as a valid signal.
## Indicators
- `EMA(20)` — fast exponential moving average on close
- `EMA(50)` — slow exponential moving average on close
- `ADX(14)` — Average Directional Index (v2 only)
## Data requirements
- 1h candles recommended.
- Minimum warmup: ~50 candles for EMAs + 14 for ADX (v2).
## Known limitations
- The choppiness index (a more nuanced ranging filter) is not in the DSL. ADX > 25
is a reasonable proxy.
- EMA crossovers lag by nature; whipsaws occur in sideways markets even with ADX filter.
- No stop-loss; relies entirely on the exit crossover.

View File

@@ -0,0 +1,36 @@
{
"type": "rule_based",
"candle_interval": "1h",
"rules": [
{
"comment": "Enter long: fast EMA crosses above slow EMA",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "flat" },
{
"kind": "cross_over",
"left": { "kind": "func", "name": "ema", "field": "close", "period": 20 },
"right": { "kind": "func", "name": "ema", "field": "close", "period": 50 }
}
]
},
"then": { "side": "buy", "quantity": "0.001" }
},
{
"comment": "Exit long: fast EMA crosses below slow EMA",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "cross_under",
"left": { "kind": "func", "name": "ema", "field": "close", "period": 20 },
"right": { "kind": "func", "name": "ema", "field": "close", "period": 50 }
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
}
]
}

View File

@@ -0,0 +1,42 @@
{
"type": "rule_based",
"candle_interval": "1h",
"rules": [
{
"comment": "Enter long: trending regime + fast EMA crosses above slow EMA",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "flat" },
{
"kind": "compare",
"left": { "kind": "func", "name": "adx", "field": "close", "period": 14 },
"op": ">",
"right": { "kind": "literal", "value": "25" }
},
{
"kind": "cross_over",
"left": { "kind": "func", "name": "ema", "field": "close", "period": 20 },
"right": { "kind": "func", "name": "ema", "field": "close", "period": 50 }
}
]
},
"then": { "side": "buy", "quantity": "0.001" }
},
{
"comment": "Exit long: fast EMA crosses below slow EMA",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "cross_under",
"left": { "kind": "func", "name": "ema", "field": "close", "period": 20 },
"right": { "kind": "func", "name": "ema", "field": "close", "period": 50 }
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
}
]
}

View File

@@ -0,0 +1,39 @@
# Stochastic + Keltner Channel
A swing trading strategy combining Keltner Channels for trend direction with
Stochastic %K for entry timing. Enters a position when price is in a bullish
Keltner regime and the stochastic is emerging from oversold territory.
## Versions
| Version | File | Notes |
|---------|---------|-------------------------------------------------------------------|
| v2 | v2.json | Manual two-bar crossover simulation (prev < 20 AND curr > 20) |
| v3 | v3.json | Uses proper `cross_over` — cleaner and more expressive |
## Logic
**Keltner Channel**: `EMA(20) ± 2 × ATR(10)`
**Stochastic %K**: `(close lowest(low, 14)) / (highest(high, 14) lowest(low, 14)) × 100`
**Entry** (v2): close > Keltner upper AND stoch[1] < 20 AND stoch > 20.
**Entry** (v3): close > Keltner upper AND `cross_over(stoch_k, 20)`.
**Exit**: %K > 80 (overbought) OR close < EMA(20).
## Indicators
- `EMA(20)` — Keltner midline
- `ATR(10)` — Keltner band width
- `highest(high, 14)`, `lowest(low, 14)` — stochastic range
## Data requirements
- 4h candles recommended.
- Minimum warmup: ~30 candles for EMA/ATR components.
## Known limitations
- No trend filter; the bullish Keltner condition (close > upper band) is a
momentum signal, not a trend-direction filter. False entries occur near tops.
- The %K calculation uses raw (unstabilised) stochastic, not %D (3-bar SMA of %K).
Adding smoothing would reduce whipsaws.

View File

@@ -0,0 +1,117 @@
{
"type": "rule_based",
"candle_interval": "4h",
"rules": [
{
"comment": "Entry: bullish Keltner regime AND stochastic crosses up from oversold",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "flat" },
{
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">",
"right": {
"kind": "bin_op", "op": "add",
"left": { "kind": "func", "name": "ema", "field": "close", "period": 20 },
"right": {
"kind": "bin_op", "op": "mul",
"left": { "kind": "literal", "value": "2" },
"right": { "kind": "func", "name": "atr", "field": "close", "period": 10 }
}
}
},
{
"kind": "compare",
"left": {
"kind": "bin_op", "op": "mul",
"left": {
"kind": "bin_op", "op": "div",
"left": {
"kind": "bin_op", "op": "sub",
"left": { "kind": "field", "field": "close", "offset": 1 },
"right": { "kind": "func", "name": "lowest", "field": "low", "period": 14, "offset": 1 }
},
"right": {
"kind": "bin_op", "op": "sub",
"left": { "kind": "func", "name": "highest", "field": "high", "period": 14, "offset": 1 },
"right": { "kind": "func", "name": "lowest", "field": "low", "period": 14, "offset": 1 }
}
},
"right": { "kind": "literal", "value": "100" }
},
"op": "<",
"right": { "kind": "literal", "value": "20" }
},
{
"kind": "compare",
"left": {
"kind": "bin_op", "op": "mul",
"left": {
"kind": "bin_op", "op": "div",
"left": {
"kind": "bin_op", "op": "sub",
"left": { "kind": "field", "field": "close" },
"right": { "kind": "func", "name": "lowest", "field": "low", "period": 14 }
},
"right": {
"kind": "bin_op", "op": "sub",
"left": { "kind": "func", "name": "highest", "field": "high", "period": 14 },
"right": { "kind": "func", "name": "lowest", "field": "low", "period": 14 }
}
},
"right": { "kind": "literal", "value": "100" }
},
"op": ">",
"right": { "kind": "literal", "value": "20" }
}
]
},
"then": { "side": "buy", "quantity": "0.001" }
},
{
"comment": "Exit: stochastic overbought or price below EMA(20)",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "any_of",
"conditions": [
{
"kind": "compare",
"left": {
"kind": "bin_op", "op": "mul",
"left": {
"kind": "bin_op", "op": "div",
"left": {
"kind": "bin_op", "op": "sub",
"left": { "kind": "field", "field": "close" },
"right": { "kind": "func", "name": "lowest", "field": "low", "period": 14 }
},
"right": {
"kind": "bin_op", "op": "sub",
"left": { "kind": "func", "name": "highest", "field": "high", "period": 14 },
"right": { "kind": "func", "name": "lowest", "field": "low", "period": 14 }
}
},
"right": { "kind": "literal", "value": "100" }
},
"op": ">",
"right": { "kind": "literal", "value": "80" }
},
{
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": "<",
"right": { "kind": "func", "name": "ema", "field": "close", "period": 20 }
}
]
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
}
]
}

View File

@@ -0,0 +1,95 @@
{
"type": "rule_based",
"candle_interval": "4h",
"rules": [
{
"comment": "Entry: bullish Keltner regime AND stochastic %K crosses up through 20 (proper cross_over)",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "flat" },
{
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">",
"right": {
"kind": "bin_op", "op": "add",
"left": { "kind": "func", "name": "ema", "field": "close", "period": 20 },
"right": {
"kind": "bin_op", "op": "mul",
"left": { "kind": "literal", "value": "2" },
"right": { "kind": "func", "name": "atr", "field": "close", "period": 10 }
}
}
},
{
"comment": "Stochastic %K crosses up through 20: was below 20 last bar, now above 20",
"kind": "cross_over",
"left": {
"kind": "bin_op", "op": "mul",
"left": {
"kind": "bin_op", "op": "div",
"left": {
"kind": "bin_op", "op": "sub",
"left": { "kind": "field", "field": "close" },
"right": { "kind": "func", "name": "lowest", "field": "low", "period": 14 }
},
"right": {
"kind": "bin_op", "op": "sub",
"left": { "kind": "func", "name": "highest", "field": "high", "period": 14 },
"right": { "kind": "func", "name": "lowest", "field": "low", "period": 14 }
}
},
"right": { "kind": "literal", "value": "100" }
},
"right": { "kind": "literal", "value": "20" }
}
]
},
"then": { "side": "buy", "quantity": "0.001" }
},
{
"comment": "Exit: stochastic overbought (> 80) or price below EMA(20)",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "any_of",
"conditions": [
{
"kind": "compare",
"left": {
"kind": "bin_op", "op": "mul",
"left": {
"kind": "bin_op", "op": "div",
"left": {
"kind": "bin_op", "op": "sub",
"left": { "kind": "field", "field": "close" },
"right": { "kind": "func", "name": "lowest", "field": "low", "period": 14 }
},
"right": {
"kind": "bin_op", "op": "sub",
"left": { "kind": "func", "name": "highest", "field": "high", "period": 14 },
"right": { "kind": "func", "name": "lowest", "field": "low", "period": 14 }
}
},
"right": { "kind": "literal", "value": "100" }
},
"op": ">",
"right": { "kind": "literal", "value": "80" }
},
{
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": "<",
"right": { "kind": "func", "name": "ema", "field": "close", "period": 20 }
}
]
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
}
]
}

View File

@@ -0,0 +1,42 @@
# Supertrend Fusion
Inspired by the SuperTrend Fusion indicator (TradingView: Qe9aWBlF), which fuses
trend, momentum, and a choppiness filter to reduce false Supertrend reversals.
## Versions
| Version | File | Notes |
|---------|---------|------------------------------------------------------------|
| v1 | v1.json | SuperTrend + Average Force + ADX — full three-layer filter |
## Logic
**Three-layer filter**:
1. **Trend** — Supertrend(10, 3.0) bullish flip (`close cross_over supertrend line`)
2. **Momentum (Average Force)** — EMA(3) of Stochastic %K(14) on close prices.
Measures where close sits within its recent 14-bar range, smoothed by EMA(3). > 50 = bullish.
(An open approximation of the original proprietary Average Force calculation.)
3. **Choppiness filter** — ADX(14) > 20. Confirms trending conditions. The original
indicator uses Choppiness Index; ADX is the DSL-native equivalent.
**Entry**: all three conditions true simultaneously.
**Exit**: Supertrend flips bearish (close crosses below line).
## Indicators
- `Supertrend(10, 3.0)` — stateful ATR-based trailing line
- `lowest(close, 14)`, `highest(close, 14)` — stochastic range (on close, not high/low)
- `apply_func(ema, period=3)` — smooths raw %K into Average Force
- `ADX(14)` — trend strength
## Data requirements
- 1h candles recommended.
- Minimum warmup: ~30 candles.
## Known limitations
- The Average Force calculation uses close-based stochastic (highest/lowest of close,
not high/low). This is a faithful approximation of the original but differs slightly.
- ADX > 20 is a softer filter than ADX > 25; raises entry frequency slightly vs a
stricter threshold.

View File

@@ -0,0 +1,67 @@
{
"type": "rule_based",
"candle_interval": "1h",
"rules": [
{
"comment": "Entry: SuperTrend bullish flip + positive momentum + trending regime",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "flat" },
{
"kind": "cross_over",
"left": { "kind": "field", "field": "close" },
"right": { "kind": "func", "name": "supertrend", "field": "close", "period": 10, "multiplier": "3.0" }
},
{
"kind": "compare",
"left": {
"kind": "apply_func", "name": "ema", "period": 3,
"input": {
"kind": "bin_op", "op": "mul",
"left": {
"kind": "bin_op", "op": "div",
"left": {
"kind": "bin_op", "op": "sub",
"left": { "kind": "field", "field": "close" },
"right": { "kind": "func", "name": "lowest", "field": "close", "period": 14 }
},
"right": {
"kind": "bin_op", "op": "sub",
"left": { "kind": "func", "name": "highest", "field": "close", "period": 14 },
"right": { "kind": "func", "name": "lowest", "field": "close", "period": 14 }
}
},
"right": { "kind": "literal", "value": "100" }
}
},
"op": ">",
"right": { "kind": "literal", "value": "50" }
},
{
"kind": "compare",
"left": { "kind": "func", "name": "adx", "field": "close", "period": 14 },
"op": ">",
"right": { "kind": "literal", "value": "20" }
}
]
},
"then": { "side": "buy", "quantity": "0.001" }
},
{
"comment": "Exit: SuperTrend bearish flip",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "cross_under",
"left": { "kind": "field", "field": "close" },
"right": { "kind": "func", "name": "supertrend", "field": "close", "period": 10, "multiplier": "3.0" }
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
}
]
}

View File

@@ -0,0 +1,37 @@
# Supertrend
A popular trend-following indicator that uses ATR to define a trailing stop-like
line. When price crosses the line upward the trend is bullish; when it crosses
downward the trend is bearish.
## Versions
| Version | File | Notes |
|---------|---------|----------------------------------------------|
| v2 | v2.json | Pure Supertrend crossover; no regime filter |
| v3 | v3.json | Adds ADX(14) > 25 to avoid ranging markets |
## Logic
**Calculation**: Supertrend(10, 3.0) — ATR period 10, multiplier 3.0.
**Entry** (v2): close crosses above the supertrend line (bullish flip).
**Entry** (v3): same PLUS ADX(14) > 25 (trending regime confirmed).
**Exit** (both): close crosses below the supertrend line (bearish flip).
## Indicators
- `Supertrend(10, 3.0)` — stateful ATR-based trailing line
- `ADX(14)` — Average Directional Index (v3 only)
## Data requirements
- 1h candles recommended.
- Minimum warmup: ~25 candles for ATR(10) + supertrend initialisation.
## Known limitations
- Supertrend alone has no volume or trend-strength confirmation; it can whipsaw
in low-volatility sideways markets. The ADX gate in v3 mitigates this.
- The `multiplier` parameter (3.0) is hardcoded; a looser multiplier (e.g. 2.0)
produces more signals with higher false-positive rate.

View File

@@ -0,0 +1,36 @@
{
"type": "rule_based",
"candle_interval": "1h",
"rules": [
{
"comment": "Entry: close crosses above supertrend (bullish flip)",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "flat" },
{
"kind": "cross_over",
"left": { "kind": "field", "field": "close" },
"right": { "kind": "func", "name": "supertrend", "field": "close", "period": 10, "multiplier": "3.0" }
}
]
},
"then": { "side": "buy", "quantity": "0.001" }
},
{
"comment": "Exit: close crosses below supertrend (bearish flip)",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "cross_under",
"left": { "kind": "field", "field": "close" },
"right": { "kind": "func", "name": "supertrend", "field": "close", "period": 10, "multiplier": "3.0" }
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
}
]
}

View File

@@ -0,0 +1,42 @@
{
"type": "rule_based",
"candle_interval": "1h",
"rules": [
{
"comment": "Entry: trending regime (ADX > 25) + close crosses above supertrend",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "flat" },
{
"kind": "compare",
"left": { "kind": "func", "name": "adx", "field": "close", "period": 14 },
"op": ">",
"right": { "kind": "literal", "value": "25" }
},
{
"kind": "cross_over",
"left": { "kind": "field", "field": "close" },
"right": { "kind": "func", "name": "supertrend", "field": "close", "period": 10, "multiplier": "3.0" }
}
]
},
"then": { "side": "buy", "quantity": "0.001" }
},
{
"comment": "Exit: close crosses below supertrend (bearish flip)",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "cross_under",
"left": { "kind": "field", "field": "close" },
"right": { "kind": "func", "name": "supertrend", "field": "close", "period": 10, "multiplier": "3.0" }
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
}
]
}

View File

@@ -0,0 +1,35 @@
# Trend Detector
Based on the Hull Moving Average (HMA) by Alan Hull. HMA reduces lag while
maintaining smoothness, making it more responsive to trend changes than
standard WMA or EMA.
## Versions
| Version | File | Notes |
|---------|---------|--------------------------------------------------|
| v2 | v2.json | HMA(16) slope + price position entry/exit |
## Logic
**HMA(16)** = `WMA(2 × WMA(close, 8) WMA(close, 16), 4)``sqrt(16) = 4`
**Entry**: HMA rising (HMA > HMA[1]) AND close > HMA.
**Exit**: HMA declining (HMA < HMA[1]) OR close < HMA.
## Indicators
- `WMA(8)`, `WMA(16)` — weighted moving averages
- `apply_func(wma, period=4)` — outer WMA for HMA calculation
## Data requirements
- 1h candles recommended.
- Minimum warmup: ~20 candles for WMA components.
## Known limitations
- No regime filter (ADX or equivalent). HMA is reactive enough that false
signals occur in ranging markets.
- Period 16 is relatively short; a HMA(20) or HMA(25) would trade less frequently
but with more conviction.

View File

@@ -0,0 +1,121 @@
{
"type": "rule_based",
"candle_interval": "1h",
"rules": [
{
"comment": "Entry: HMA(16) rising and close above HMA",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "flat" },
{
"kind": "compare",
"left": {
"kind": "apply_func", "name": "wma", "period": 4,
"input": {
"kind": "bin_op", "op": "sub",
"left": {
"kind": "bin_op", "op": "mul",
"left": { "kind": "literal", "value": "2" },
"right": { "kind": "func", "name": "wma", "field": "close", "period": 8 }
},
"right": { "kind": "func", "name": "wma", "field": "close", "period": 16 }
}
},
"op": ">",
"right": {
"kind": "apply_func", "name": "wma", "period": 4, "offset": 1,
"input": {
"kind": "bin_op", "op": "sub",
"left": {
"kind": "bin_op", "op": "mul",
"left": { "kind": "literal", "value": "2" },
"right": { "kind": "func", "name": "wma", "field": "close", "period": 8 }
},
"right": { "kind": "func", "name": "wma", "field": "close", "period": 16 }
}
}
},
{
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": ">",
"right": {
"kind": "apply_func", "name": "wma", "period": 4,
"input": {
"kind": "bin_op", "op": "sub",
"left": {
"kind": "bin_op", "op": "mul",
"left": { "kind": "literal", "value": "2" },
"right": { "kind": "func", "name": "wma", "field": "close", "period": 8 }
},
"right": { "kind": "func", "name": "wma", "field": "close", "period": 16 }
}
}
}
]
},
"then": { "side": "buy", "quantity": "0.001" }
},
{
"comment": "Exit: HMA(16) declining or close below HMA",
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "long" },
{
"kind": "any_of",
"conditions": [
{
"kind": "compare",
"left": {
"kind": "apply_func", "name": "wma", "period": 4,
"input": {
"kind": "bin_op", "op": "sub",
"left": {
"kind": "bin_op", "op": "mul",
"left": { "kind": "literal", "value": "2" },
"right": { "kind": "func", "name": "wma", "field": "close", "period": 8 }
},
"right": { "kind": "func", "name": "wma", "field": "close", "period": 16 }
}
},
"op": "<",
"right": {
"kind": "apply_func", "name": "wma", "period": 4, "offset": 1,
"input": {
"kind": "bin_op", "op": "sub",
"left": {
"kind": "bin_op", "op": "mul",
"left": { "kind": "literal", "value": "2" },
"right": { "kind": "func", "name": "wma", "field": "close", "period": 8 }
},
"right": { "kind": "func", "name": "wma", "field": "close", "period": 16 }
}
}
},
{
"kind": "compare",
"left": { "kind": "field", "field": "close" },
"op": "<",
"right": {
"kind": "apply_func", "name": "wma", "period": 4,
"input": {
"kind": "bin_op", "op": "sub",
"left": {
"kind": "bin_op", "op": "mul",
"left": { "kind": "literal", "value": "2" },
"right": { "kind": "func", "name": "wma", "field": "close", "period": 8 }
},
"right": { "kind": "func", "name": "wma", "field": "close", "period": 16 }
}
}
}
]
}
]
},
"then": { "side": "sell", "quantity": "0.001" }
}
]
}

View File

@@ -111,6 +111,14 @@ where
InstrumentData: InstrumentDataState<MarketEventKind = DataKind>
+ InFlightRequestRecorder<ExchangeIndex, InstrumentIndex>,
{
/// Mutable access to a single instrument's data, identified by `InstrumentIndex`.
///
/// Used by callers that need to set per-event side-channel fields (e.g.
/// `next_candle_interval_hint`) between individual `process_chunk` calls.
pub fn instrument_data_mut(&mut self, idx: &InstrumentIndex) -> &mut InstrumentData {
&mut self.state.instruments.instrument_index_mut(idx).data
}
/// Begin a chunked backtest run, returning a [`RunState`] to pass to [`Self::process_chunk`].
pub fn begin_run(&self) -> RunState {
RunState {

View File

@@ -1,3 +1,5 @@
use std::collections::HashSet;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
@@ -97,11 +99,17 @@ pub enum Condition {
fast_period: usize,
slow_period: usize,
direction: CrossoverDirection,
/// Optional timeframe for the EMA states. When absent, uses the primary candle interval.
#[serde(default, skip_serializing_if = "Option::is_none")]
timeframe: Option<String>,
},
/// True when price is above (or below) a single EMA.
EmaTrend {
period: usize,
direction: TrendDirection,
/// Optional timeframe for the EMA state. When absent, uses the primary candle interval.
#[serde(default, skip_serializing_if = "Option::is_none")]
timeframe: Option<String>,
},
/// True when RSI crosses a threshold.
Rsi {
@@ -109,6 +117,9 @@ pub enum Condition {
period: usize,
threshold: Decimal,
comparison: Comparison,
/// Optional timeframe for the price series. When absent, uses the primary candle interval.
#[serde(default, skip_serializing_if = "Option::is_none")]
timeframe: Option<String>,
},
/// True when price breaks above the upper or below the lower Bollinger Band.
Bollinger {
@@ -117,11 +128,17 @@ pub enum Condition {
#[serde(default = "default_num_std_dev")]
num_std_dev: Decimal,
band: BollingerBand,
/// Optional timeframe for the price series. When absent, uses the primary candle interval.
#[serde(default, skip_serializing_if = "Option::is_none")]
timeframe: Option<String>,
},
/// True when price is above or below a fixed level.
PriceLevel {
price: Decimal,
direction: TrendDirection,
/// Optional timeframe for the price lookup. When absent, uses the primary candle interval.
#[serde(default, skip_serializing_if = "Option::is_none")]
timeframe: Option<String>,
},
/// True when the current position matches the required state.
Position {
@@ -331,6 +348,10 @@ pub enum Expr {
field: CandleField,
#[serde(default, skip_serializing_if = "is_zero")]
offset: usize,
/// Optional timeframe to read from. When absent, uses the primary candle interval.
/// Example: `"timeframe": "1h"` reads the hourly candle history.
#[serde(default, skip_serializing_if = "Option::is_none")]
timeframe: Option<String>,
},
/// A rolling-window function applied to a candle field.
/// `offset` shifts the window back: 0 = window ending on the current bar.
@@ -345,6 +366,10 @@ pub enum Expr {
/// Optional multiplier, used by `Supertrend` (ATR multiplier, default 3.0).
#[serde(default = "default_multiplier", skip_serializing_if = "Option::is_none")]
multiplier: Option<Decimal>,
/// Optional timeframe to compute the function on. When absent, uses the primary candle interval.
/// Example: `"timeframe": "1d"` computes SMA(200) on the daily candle history.
#[serde(default, skip_serializing_if = "Option::is_none")]
timeframe: Option<String>,
},
/// Arithmetic combination of two sub-expressions.
BinOp {
@@ -369,6 +394,10 @@ pub enum Expr {
period: usize,
#[serde(default, skip_serializing_if = "is_zero")]
offset: usize,
/// Optional timeframe for the output series. When absent, uses the primary candle interval.
/// Note: the `input` sub-expression's timeframe is resolved independently.
#[serde(default, skip_serializing_if = "Option::is_none")]
timeframe: Option<String>,
},
/// Unary math operation on a sub-expression.
UnaryOp {
@@ -395,6 +424,197 @@ pub enum Expr {
},
}
// --- Utility functions ---
/// Parse a candle interval string into seconds.
/// Supports: `"1m"`, `"5m"`, `"15m"`, `"1h"`, `"4h"`, `"1d"`.
pub fn parse_interval_secs(interval: &str) -> Option<u64> {
match interval {
"1m" => Some(60),
"5m" => Some(300),
"15m" => Some(900),
"1h" => Some(3_600),
"4h" => Some(14_400),
"1d" => Some(86_400),
_ => None,
}
}
/// Walk the full rule tree and collect all additional timeframe strings referenced by
/// expressions or legacy conditions. The primary `candle_interval` is NOT included.
///
/// Used by the executor to initialise per-timeframe data structures and by the API to
/// validate candle data coverage before accepting a backtest request.
pub fn collect_timeframes(params: &RuleBasedParams) -> HashSet<String> {
let mut set = HashSet::new();
for rule in &params.rules {
collect_from_condition(&rule.when, &mut set);
}
// Remove the primary interval if it appears — callers only care about *additional* ones.
set.remove(&params.candle_interval);
set
}
fn collect_from_condition(cond: &Condition, out: &mut HashSet<String>) {
match cond {
Condition::EmaCrossover { timeframe, .. }
| Condition::EmaTrend { timeframe, .. }
| Condition::Rsi { timeframe, .. }
| Condition::Bollinger { timeframe, .. }
| Condition::PriceLevel { timeframe, .. } => {
if let Some(tf) = timeframe { out.insert(tf.clone()); }
}
Condition::Compare { left, right, .. } => {
collect_from_expr(left, out);
collect_from_expr(right, out);
}
Condition::CrossOver { left, right } | Condition::CrossUnder { left, right } => {
collect_from_expr(left, out);
collect_from_expr(right, out);
}
Condition::EventCount { condition, .. } => collect_from_condition(condition, out),
Condition::AllOf { conditions } | Condition::AnyOf { conditions } => {
for c in conditions { collect_from_condition(c, out); }
}
Condition::Not { condition } => collect_from_condition(condition, out),
Condition::Position { .. } => {}
}
}
fn collect_from_expr(expr: &Expr, out: &mut HashSet<String>) {
match expr {
Expr::Field { timeframe, .. } | Expr::Func { timeframe, .. } => {
if let Some(tf) = timeframe { out.insert(tf.clone()); }
}
Expr::ApplyFunc { timeframe, input, .. } => {
if let Some(tf) = timeframe { out.insert(tf.clone()); }
collect_from_expr(input, out);
}
Expr::BinOp { left, right, .. } => {
collect_from_expr(left, out);
collect_from_expr(right, out);
}
Expr::UnaryOp { operand, .. } => collect_from_expr(operand, out),
Expr::BarsSince { condition, .. } => collect_from_condition(condition, out),
Expr::Literal { .. } => {}
}
}
/// Compute the minimum warmup duration (in seconds) required before a strategy
/// can produce valid indicator values for all referenced timeframes.
///
/// Walks the rule tree to find, for each timeframe, the maximum `period` value
/// used by any function expression. Multiplies each by its interval duration to
/// get the candle history required. Returns the maximum across all timeframes.
///
/// Used by the backtest runner to pre-load candles from before `starts_at` so
/// that indicators are fully warmed up at the start of the backtest window.
///
/// Example: `SMA(200)` on `"4h"` → 200 × 14400 s = 2,880,000 s (~33 days).
pub fn compute_warmup_secs(config: &StrategyConfig) -> u64 {
let params = match config {
StrategyConfig::RuleBased(p) => p,
StrategyConfig::Default => return 0,
};
// Collect max period per timeframe string (None = primary interval).
let mut max_period: std::collections::HashMap<Option<String>, usize> =
std::collections::HashMap::new();
for rule in &params.rules {
warmup_from_condition(&rule.when, &mut max_period);
}
// Convert each (timeframe, max_period) pair to seconds and take the maximum.
max_period
.into_iter()
.filter_map(|(tf_opt, period)| {
let tf_str = tf_opt.as_deref().unwrap_or(&params.candle_interval);
let interval_secs = parse_interval_secs(tf_str)?;
Some(interval_secs * period as u64)
})
.max()
.unwrap_or(0)
}
fn warmup_from_condition(
cond: &Condition,
out: &mut std::collections::HashMap<Option<String>, usize>,
) {
match cond {
Condition::EmaCrossover { fast_period, slow_period, timeframe, .. } => {
let key = timeframe.clone();
let entry = out.entry(key).or_insert(0);
*entry = (*entry).max(*fast_period).max(*slow_period);
}
Condition::EmaTrend { period, timeframe, .. } => {
let entry = out.entry(timeframe.clone()).or_insert(0);
*entry = (*entry).max(*period);
}
Condition::Rsi { period, timeframe, .. } => {
let entry = out.entry(timeframe.clone()).or_insert(0);
*entry = (*entry).max(*period);
}
Condition::Bollinger { period, timeframe, .. } => {
let entry = out.entry(timeframe.clone()).or_insert(0);
*entry = (*entry).max(*period);
}
Condition::PriceLevel { timeframe, .. } => {
out.entry(timeframe.clone()).or_insert(0);
}
Condition::Compare { left, right, .. } => {
warmup_from_expr(left, out);
warmup_from_expr(right, out);
}
Condition::CrossOver { left, right } | Condition::CrossUnder { left, right } => {
warmup_from_expr(left, out);
warmup_from_expr(right, out);
}
Condition::EventCount { condition, period, .. } => {
warmup_from_condition(condition, out);
// EventCount scans back `period` bars.
out.entry(None).and_modify(|v| *v = (*v).max(*period)).or_insert(*period);
}
Condition::AllOf { conditions } | Condition::AnyOf { conditions } => {
for c in conditions { warmup_from_condition(c, out); }
}
Condition::Not { condition } => warmup_from_condition(condition, out),
Condition::Position { .. } => {}
}
}
fn warmup_from_expr(
expr: &Expr,
out: &mut std::collections::HashMap<Option<String>, usize>,
) {
match expr {
Expr::Field { timeframe, .. } => {
out.entry(timeframe.clone()).or_insert(0);
}
Expr::Func { name, period, timeframe, .. } => {
// ADX needs approximately 2 × period candles to stabilise.
let effective = if matches!(name, FuncName::Adx) { period * 2 } else { *period };
let entry = out.entry(timeframe.clone()).or_insert(0);
*entry = (*entry).max(effective);
}
Expr::ApplyFunc { period, input, timeframe, .. } => {
let entry = out.entry(timeframe.clone()).or_insert(0);
*entry = (*entry).max(*period);
warmup_from_expr(input, out);
}
Expr::BinOp { left, right, .. } => {
warmup_from_expr(left, out);
warmup_from_expr(right, out);
}
Expr::UnaryOp { operand, .. } => warmup_from_expr(operand, out),
Expr::BarsSince { condition, period } => {
warmup_from_condition(condition, out);
out.entry(None).and_modify(|v| *v = (*v).max(*period)).or_insert(*period);
}
Expr::Literal { .. } => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -464,9 +684,11 @@ mod tests {
period: 20,
offset: 0,
multiplier: None,
timeframe: None,
}),
period: 4,
offset: 0,
timeframe: None,
};
let json = serde_json::to_string(&orig).unwrap();
let back: Expr = serde_json::from_str(&json).unwrap();
@@ -476,6 +698,80 @@ mod tests {
}
}
#[test]
fn parse_interval_secs_known_values() {
assert_eq!(super::parse_interval_secs("1m"), Some(60));
assert_eq!(super::parse_interval_secs("5m"), Some(300));
assert_eq!(super::parse_interval_secs("15m"), Some(900));
assert_eq!(super::parse_interval_secs("1h"), Some(3_600));
assert_eq!(super::parse_interval_secs("4h"), Some(14_400));
assert_eq!(super::parse_interval_secs("1d"), Some(86_400));
assert_eq!(super::parse_interval_secs("2h"), None);
}
#[test]
fn collect_timeframes_extracts_additional_timeframes() {
use serde_json::json;
let params: RuleBasedParams = serde_json::from_value(json!({
"candle_interval": "5m",
"rules": [{
"when": {
"kind": "all_of",
"conditions": [
{ "kind": "position", "state": "flat" },
{
"kind": "compare",
"left": { "kind": "field", "field": "close", "timeframe": "1d" },
"op": ">",
"right": { "kind": "func", "name": "sma", "field": "close", "period": 200, "timeframe": "1d" }
},
{
"kind": "compare",
"left": { "kind": "func", "name": "sma", "field": "close", "period": 20, "timeframe": "1h" },
"op": ">",
"right": { "kind": "func", "name": "sma", "field": "close", "period": 20, "offset": 5, "timeframe": "1h" }
},
{
"kind": "cross_over",
"left": { "kind": "field", "field": "close" },
"right": { "kind": "func", "name": "sma", "field": "close", "period": 20 }
}
]
},
"then": { "side": "buy", "quantity": "0.001" }
}]
})).unwrap();
let tfs = super::collect_timeframes(&params);
assert!(tfs.contains("1d"), "expected 1d: {tfs:?}");
assert!(tfs.contains("1h"), "expected 1h: {tfs:?}");
assert!(!tfs.contains("5m"), "primary should be excluded: {tfs:?}");
assert_eq!(tfs.len(), 2);
}
#[test]
fn timeframe_field_round_trips_correctly() {
let expr: Expr = serde_json::from_str(
r#"{"kind":"func","name":"sma","field":"close","period":200,"timeframe":"1d"}"#,
).unwrap();
match &expr {
Expr::Func { timeframe: Some(tf), .. } => assert_eq!(tf, "1d"),
_ => panic!("expected Func with timeframe"),
}
// Round-trip: serialize back and verify timeframe is preserved.
let out = serde_json::to_string(&expr).unwrap();
assert!(out.contains("\"timeframe\":\"1d\""), "timeframe must appear: {out}");
}
#[test]
fn timeframe_none_does_not_appear_in_output() {
let expr: Expr = serde_json::from_str(
r#"{"kind":"func","name":"sma","field":"close","period":20}"#,
).unwrap();
let out = serde_json::to_string(&expr).unwrap();
assert!(!out.contains("timeframe"), "absent timeframe must not appear: {out}");
}
/// Verifies that optional/defaulted fields are omitted during serialization so that
/// a config stored in the DB (without those fields) produces the same JSON — and the
/// same content hash — after a round-trip through serde.

View File

@@ -261,6 +261,33 @@ pub async fn load_candles_page(
Ok(rows)
}
/// Load all candles for an instrument+interval within a time range, ordered by open_time.
pub async fn load_candles_range(
pool: &PgPool,
instrument_id: i32,
interval: &str,
from: DateTime<Utc>,
to: DateTime<Utc>,
) -> Result<Vec<MarketCandleRow>, DalError> {
let rows = sqlx::query_as::<_, MarketCandleRow>(
"SELECT id, instrument_id, interval, open_time, close_time,
open, high, low, close, volume, trade_count
FROM market_candles
WHERE instrument_id = $1
AND interval = $2
AND open_time >= $3
AND open_time < $4
ORDER BY open_time",
)
.bind(instrument_id)
.bind(interval)
.bind(from)
.bind(to)
.fetch_all(pool)
.await?;
Ok(rows)
}
/// Return the min open_time and max close_time for candles of a given interval, or None.
pub async fn candle_data_range(
pool: &PgPool,

View File

@@ -7,15 +7,22 @@ pub fn strategies() -> Vec<StrategySeed> {
vec![
hodl(),
simple_trend(),
simple_trend_v2(),
buy_2_factors_reversion(),
buy_2_factors_reversion_v2(),
ichimoku(),
ichimoku_v2(),
bull_market_support_band(),
bull_market_support_band_v2(),
momentum_cascade(),
gaussian_channel(),
gaussian_channel_v3(),
supertrend(),
supertrend_v3(),
money_line(),
trend_detector(),
stochastic_keltner(),
stochastic_keltner_v3(),
hull_suite(),
supertrend_fusion(),
emmanuel_ma(),
@@ -26,6 +33,10 @@ pub fn strategies() -> Vec<StrategySeed> {
emmanuel_ma_v6(),
emmanuel_ma_v7(),
emmanuel_ma_v8(),
emmanuel_mtf_v1(),
emmanuel_mtf_v2(),
emmanuel_mtf_v3(),
emmanuel_mtf_v4(),
]
}
@@ -951,9 +962,9 @@ entry bar volume to exceed its 20-bar average.
**Exit 2** (structural): 20 SMA crosses below 200 SMA (trend inverted).
**Exit 3** (hard stop): close drops more than 4% below the 20 SMA.",
config: serde_json::from_str(
include_str!("../../../../assets/strategy/emmanuel-ma-v2.json"),
include_str!("../../../../assets/strategy/emmanuel-ma/v2.json"),
)
.expect("assets/strategy/emmanuel-ma-v2.json is valid JSON"),
.expect("assets/strategy/emmanuel-ma/v2.json is valid JSON"),
}
}
@@ -977,9 +988,170 @@ the pullback). Exit unchanged from v7.
**Exit 2** (structural): 20 SMA crosses below 200 SMA (trend inverted).
**Exit 3** (hard stop): close drops more than 4% below the 20 SMA.",
config: serde_json::from_str(
include_str!("../../../../assets/strategy/emmanuel-ma-v8.json"),
include_str!("../../../../assets/strategy/emmanuel-ma/v8.json"),
)
.expect("assets/strategy/emmanuel-ma-v8.json is valid JSON"),
.expect("assets/strategy/emmanuel-ma/v8.json is valid JSON"),
}
}
fn emmanuel_mtf_v2() -> StrategySeed {
StrategySeed {
name: "Emmanuel MTF",
version: 2,
description: "\
## Emmanuel MTF — Multi-Timeframe SMA Pullback v2
Fixes the fatal bottleneck in v1 identified by condition audit: the not-first-touch \
filter (condition 11) had a 7.0% pass rate and misread the transcript. Emmanuel says \
to skip the first touch AFTER a parabolic run — not to require proof of a prior \
impulse. v2 replaces it with a not-parabolic guard: entry is blocked when close is \
already >3% above the 15m 20 SMA, which is the actual case Emmanuel describes avoiding.
Also adds ADX(14) > 20 on the 4h timeframe to compensate for the removed selectivity, \
and widens the pullback-evidence tolerance from 0.3% to 0.5% (was the next tightest \
filter at 46.9% pass rate in v1).
**4h (trend context):** Bullish regime — 20/200 SMA stack, rising 20 SMA, price above \
20 SMA, not extended, ADX > 20 (trending market).
**15m (entry timing):** Rising 20 SMA, close just above it (≤ 0.5%), not parabolic \
(< 3% above SMA), price rising, recent low touched the SMA within 0.5%.
**Entry**: 12-condition all_of — 4h trend stack + ADX + 15m timing + not-parabolic guard.
**Exit 1** (trailing): close crosses below the 15m 20 SMA.
**Exit 2** (structural): 4h 20 SMA crosses below 4h 200 SMA (macro trend broken).
**Exit 3** (hard stop): close drops more than 2% below the 15m 20 SMA.",
config: serde_json::from_str(
include_str!("../../../../assets/strategy/emmanuel-mtf/v2.json"),
)
.expect("assets/strategy/emmanuel-mtf/v2.json is valid JSON"),
}
}
fn emmanuel_mtf_v3() -> StrategySeed {
StrategySeed {
name: "Emmanuel MTF",
version: 3,
description: "\
## Emmanuel MTF — Multi-Timeframe SMA Pullback v3
Driven by v2's condition audit (179 trades, 19% win rate, profit factor 0.586 over 360 days). \
Root cause: no upside exit (all exits were downside), ADX filter too loose (71.9% pass), and \
cross_under exit fired on single-bar noise. To break even at 19% win rate requires ~4.3× average \
win/loss; the strategy needs either a higher win rate or banked profit targets.
**Changes from v2:**
1. **Take-profit exit** (new): sell when close is >1.5% above 15m 20 SMA. The most impactful \
change — v2 had no upside exit so every winner had to survive a full reversal.
2. **Trailing stop tightened**: replaced cross_under with a compare requiring close 0.1% below \
SMA. Single-bar noise trips the cross_under; a small threshold absorbs it.
3. **Bullish entry candle** (new): requires close > open on the 15m bar at entry — proxy for \
Emmanuel's candle-structure read at the MA touch.
4. **4h SMA rising check tightened**: offset 5 → 2 (20 hours → 8 hours). An MA that was rising \
20 hours ago can be turning flat now; this requires momentum to be current.
5. **ADX threshold raised**: 20 → 25. v2 audit showed ADX>20 passing 71.9% of bars — barely \
selective. >25 focuses on genuinely trending regimes.
**4h (trend context):** 20/200 SMA stack, recently rising 20 SMA (8h window), price above 20 SMA, \
not extended, ADX > 25.
**15m (entry timing):** Rising 20 SMA, bullish candle, close just above (≤ 0.5%), not parabolic, \
price rising, recent low touched SMA within 0.5%.
**Entry**: 13-condition all_of.
**Exit 1** (trailing stop): close drops 0.1% below 15m 20 SMA.
**Exit 2** (take-profit): close rises 1.5% above 15m 20 SMA.
**Exit 3** (structural): 4h 20 SMA crosses below 4h 200 SMA.
**Exit 4** (hard stop): close drops 2% below 15m 20 SMA.",
config: serde_json::from_str(
include_str!("../../../../assets/strategy/emmanuel-mtf/v3.json"),
)
.expect("assets/strategy/emmanuel-mtf/v3.json is valid JSON"),
}
}
fn emmanuel_mtf_v4() -> StrategySeed {
StrategySeed {
name: "Emmanuel MTF",
version: 4,
description: "\
## Emmanuel MTF — Multi-Timeframe SMA Pullback v4
Driven by v3 audit (127 trades, 21.26% win rate, profit factor 0.686). Take-profit \
fired only 11 times (8.7% of exits) — most trades never reached +1.5% before reversing. \
The 15m entry conditions pass at ~50% (coin-flips) because the 15m itself may be in a \
local downtrend even when the 4h is bullish.
**Changes from v3:**
1. **15m SMA stack** (new): requires SMA(20,15m) > SMA(200,15m). Forces triple alignment — \
4h bull regime AND 15m local bull regime AND pullback to 15m 20 SMA. Without this, entries \
happen when the 15m 20 SMA is acting as resistance in a local downtrend.
2. **Entry proximity tightened**: 0.5% → 0.3%. Entries closer to the SMA mean smaller losses \
when the trade fails and better risk/reward toward the take-profit target.
3. **Take-profit lowered**: 1.5% → 1.0% above 15m 20 SMA. v3 take-profit only caught 8.7% \
of trades; lowering captures more exits before reversal while still representing meaningful gain.
**4h (trend context):** 20/200 SMA stack, rising 20 SMA (8h window), price above 20 SMA, \
not extended, ADX > 25.
**15m (entry timing):** 15m 20 SMA > 15m 200 SMA (local bull), rising 20 SMA, bullish \
candle, close just above (≤ 0.3%), not parabolic, price rising, recent low touched SMA.
**Entry**: 14-condition all_of.
**Exit 1** (trailing stop): close drops 0.1% below 15m 20 SMA.
**Exit 2** (take-profit): close rises 1.0% above 15m 20 SMA.
**Exit 3** (structural): 4h 20 SMA crosses below 4h 200 SMA.
**Exit 4** (hard stop): close drops 2% below 15m 20 SMA.",
config: serde_json::from_str(
include_str!("../../../../assets/strategy/emmanuel-mtf/v4.json"),
)
.expect("assets/strategy/emmanuel-mtf/v4.json is valid JSON"),
}
}
fn emmanuel_mtf_v1() -> StrategySeed {
StrategySeed {
name: "Emmanuel MTF",
version: 1,
description: "\
## Emmanuel MTF — Multi-Timeframe SMA Pullback Strategy
Inspired by Emmanuel Malyarovich's approach using only two indicators — the 20 SMA \
and 200 SMA — applied across multiple timeframes. The higher timeframe establishes \
trend direction; the lower timeframe times entries at pullbacks to the 20 SMA.
**4h (trend context):** Confirms a bullish regime — close above the 200 SMA, 20 SMA \
stacked above the 200 and rising, price above the 20 SMA but not overextended from it.
**15m (entry timing):** Waits for price to retrace to the 15m 20 SMA and bounce. \
Requires the MA to be rising, price to be in a tight proximity zone just above it, \
evidence of an actual pullback (low touched the MA recently), and evidence of a prior \
impulsive move (recent high was meaningfully above the MA — avoids first-touch traps \
after exhaustion moves).
**Entry**: 11-condition all_of — 4h trend stack (close > 200 SMA, 20 SMA > 200 SMA, \
rising 20 SMA, price > 20 SMA, not overextended < 5%) + 15m entry (rising 20 SMA, \
close ≥ 20 SMA, proximity within 0.5%, price rising, pullback touch within 0.3%, \
prior impulsive move > 1.5%).
**Exit 1** (trailing): close crosses below the 15m 20 SMA.
**Exit 2** (structural): 4h 20 SMA crosses below 4h 200 SMA (macro trend broken).
**Exit 3** (hard stop): close drops more than 2% below the 15m 20 SMA.",
config: serde_json::from_str(
include_str!("../../../../assets/strategy/emmanuel-mtf/v1.json"),
)
.expect("assets/strategy/emmanuel-mtf/v1.json is valid JSON"),
}
}
@@ -1001,9 +1173,9 @@ the 50 SMA is slow enough to avoid whipsaw without it.
**Exit 2** (structural): 20 SMA crosses below 200 SMA (trend inverted).
**Exit 3** (hard stop): close drops more than 4% below the 20 SMA.",
config: serde_json::from_str(
include_str!("../../../../assets/strategy/emmanuel-ma-v7.json"),
include_str!("../../../../assets/strategy/emmanuel-ma/v7.json"),
)
.expect("assets/strategy/emmanuel-ma-v7.json is valid JSON"),
.expect("assets/strategy/emmanuel-ma/v7.json is valid JSON"),
}
}
@@ -1028,9 +1200,9 @@ Exit unchanged from v5: 50 SMA trailing with 3-bar minimum hold, hard stop at 4%
**Exit 2** (structural): 20 SMA crosses below 200 SMA (trend inverted).
**Exit 3** (hard stop): close drops more than 4% below the 20 SMA.",
config: serde_json::from_str(
include_str!("../../../../assets/strategy/emmanuel-ma-v6.json"),
include_str!("../../../../assets/strategy/emmanuel-ma/v6.json"),
)
.expect("assets/strategy/emmanuel-ma-v6.json is valid JSON"),
.expect("assets/strategy/emmanuel-ma/v6.json is valid JSON"),
}
}
@@ -1053,9 +1225,9 @@ can fire. Hard stop (4% below 20 SMA) has no minimum hold.
**Exit 2** (structural): 20 SMA crosses below 200 SMA (trend inverted).
**Exit 3** (hard stop): close drops more than 4% below the 20 SMA.",
config: serde_json::from_str(
include_str!("../../../../assets/strategy/emmanuel-ma-v5.json"),
include_str!("../../../../assets/strategy/emmanuel-ma/v5.json"),
)
.expect("assets/strategy/emmanuel-ma-v5.json is valid JSON"),
.expect("assets/strategy/emmanuel-ma/v5.json is valid JSON"),
}
}
@@ -1078,9 +1250,9 @@ recency guard relaxed to > 0 bars.
**Exit 2** (structural): 20 SMA crosses below 200 SMA (trend inverted).
**Exit 3** (hard stop): close drops more than 4% below the 20 SMA.",
config: serde_json::from_str(
include_str!("../../../../assets/strategy/emmanuel-ma-v4.json"),
include_str!("../../../../assets/strategy/emmanuel-ma/v4.json"),
)
.expect("assets/strategy/emmanuel-ma-v4.json is valid JSON"),
.expect("assets/strategy/emmanuel-ma/v4.json is valid JSON"),
}
}
@@ -1103,9 +1275,9 @@ volume filter softened to 0.8× average, recency guard relaxed to > 0 bars.
**Exit 2** (structural): 20 SMA crosses below 200 SMA (trend inverted).
**Exit 3** (hard stop): close drops more than 4% below the 20 SMA.",
config: serde_json::from_str(
include_str!("../../../../assets/strategy/emmanuel-ma-v3.json"),
include_str!("../../../../assets/strategy/emmanuel-ma/v3.json"),
)
.expect("assets/strategy/emmanuel-ma-v3.json is valid JSON"),
.expect("assets/strategy/emmanuel-ma/v3.json is valid JSON"),
}
}
@@ -1130,8 +1302,134 @@ this is not the first test of the MA (prior touch within 20 bars).
**Exit 2** (structural): 20 SMA crosses below 200 SMA (trend inverted).
**Exit 3** (hard stop): close drops more than 4% below the 20 SMA.",
config: serde_json::from_str(
include_str!("../../../../assets/strategy/emmanuel-ma-v1.json"),
include_str!("../../../../assets/strategy/emmanuel-ma/v1.json"),
)
.expect("assets/strategy/emmanuel-ma-v1.json is valid JSON"),
.expect("assets/strategy/emmanuel-ma/v1.json is valid JSON"),
}
}
// ---------------------------------------------------------------------------
// Revised strategy seeds (v2/v3 — use assets/strategy/<name>/ JSON files)
// ---------------------------------------------------------------------------
fn simple_trend_v2() -> StrategySeed {
StrategySeed {
name: "simple trend + regime filter",
version: 2,
description: "\
## Simple Trend + Regime Filter v2
Adds an ADX(14) > 25 regime gate to v1's EMA(20/50) crossover signal.
Only takes trend signals when the market is confirmed trending (ADX > 25),
filtering out the whipsaw crossovers that occur in ranging conditions.",
config: serde_json::from_str(
include_str!("../../../../assets/strategy/simple-trend/v2.json"),
)
.expect("assets/strategy/simple-trend/v2.json is valid JSON"),
}
}
fn buy_2_factors_reversion_v2() -> StrategySeed {
StrategySeed {
name: "buy 2 factors, reversion entry",
version: 2,
description: "\
## Buy 2 Factors, Reversion Entry v2
Completes the originally-intended 3-factor gate by adding ADX(14) > 20 as Factor 2.
**Entry**: RSI(14) < 30 AND close > SMA(200) AND ADX(14) > 20.
**Exit**: RSI(14) > 70 (unchanged from v1).",
config: serde_json::from_str(
include_str!("../../../../assets/strategy/buy-2-factors/v2.json"),
)
.expect("assets/strategy/buy-2-factors/v2.json is valid JSON"),
}
}
fn ichimoku_v2() -> StrategySeed {
StrategySeed {
name: "ichimoku",
version: 2,
description: "\
## Ichimoku Kinko Hyo v2
Adds two additional entry filters to v1's core TK crossover:
1. **Chikou Span**: current close > close 26 bars ago — lagging line confirms upward momentum.
2. **Cloud colour**: Senkou Span A > Span B at current bar — bullish cloud at time of entry.
Exit unchanged from v1 (Tenkan crosses below Kijun).",
config: serde_json::from_str(
include_str!("../../../../assets/strategy/ichimoku/v2.json"),
)
.expect("assets/strategy/ichimoku/v2.json is valid JSON"),
}
}
fn bull_market_support_band_v2() -> StrategySeed {
StrategySeed {
name: "bull market support band",
version: 2,
description: "\
## Bull Market Support Band v2
Adds explicit bull market confirmation using `event_count`: requires at least
35 of the last 50 daily closes to be above SMA(100) before a band bounce entry
is accepted. This prevents entries during bear market dead-cat bounces.",
config: serde_json::from_str(
include_str!("../../../../assets/strategy/bull-market-support-band/v2.json"),
)
.expect("assets/strategy/bull-market-support-band/v2.json is valid JSON"),
}
}
fn supertrend_v3() -> StrategySeed {
StrategySeed {
name: "supertrend",
version: 3,
description: "\
## Supertrend v3
Adds ADX(14) > 25 regime gate to v2's Supertrend crossover signal.
Avoids entering on Supertrend flips that occur during sideways, choppy markets.",
config: serde_json::from_str(
include_str!("../../../../assets/strategy/supertrend/v3.json"),
)
.expect("assets/strategy/supertrend/v3.json is valid JSON"),
}
}
fn gaussian_channel_v3() -> StrategySeed {
StrategySeed {
name: "gaussian channel",
version: 3,
description: "\
## Gaussian Channel v3
Replaces the persistent `compare(close, >, upper_band)` entry condition with a
`cross_over` transition — the signal fires only on the bar where price first
breaks above the upper band. This avoids repeated re-entries while price remains
above the band and makes entries more precise.",
config: serde_json::from_str(
include_str!("../../../../assets/strategy/gaussian-channel/v3.json"),
)
.expect("assets/strategy/gaussian-channel/v3.json is valid JSON"),
}
}
fn stochastic_keltner_v3() -> StrategySeed {
StrategySeed {
name: "stochastic + keltner",
version: 3,
description: "\
## Stochastic + Keltner Channel v3
Replaces the manual two-bar offset crossover simulation (`stoch[1] < 20 AND stoch > 20`)
with a proper `cross_over(stoch_k, literal(20))`. The semantics are identical but
the expression is cleaner and uses the intended DSL primitive.",
config: serde_json::from_str(
include_str!("../../../../assets/strategy/stochastic-keltner/v3.json"),
)
.expect("assets/strategy/stochastic-keltner/v3.json is valid JSON"),
}
}

View File

@@ -12,6 +12,7 @@
"dependencies": {
"@tanstack/react-query": "^5.90.21",
"bootstrap": "^5.3.8",
"lightweight-charts": "^5.1.0",
"marked": "^17.0.3",
"react": "^19.2.0",
"react-bootstrap": "^2.10.10",

View File

@@ -17,6 +17,9 @@ importers:
bootstrap:
specifier: ^5.3.8
version: 5.3.8(@popperjs/core@2.11.8)
lightweight-charts:
specifier: ^5.1.0
version: 5.1.0
marked:
specifier: ^17.0.3
version: 17.0.3
@@ -817,6 +820,9 @@ packages:
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
fancy-canvas@2.1.0:
resolution: {integrity: sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -1028,6 +1034,9 @@ packages:
resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==}
engines: {node: '>= 12.0.0'}
lightweight-charts@5.1.0:
resolution: {integrity: sha512-jEAYR4ODYeyNZcWUigsoLTl52rbPmgXnvd5FLIv/ZoA/2sSDw63YKnef8n4yhzum7W926yHeFwlm7ididKb7YQ==}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -2125,6 +2134,8 @@ snapshots:
eventemitter3@5.0.4: {}
fancy-canvas@2.1.0: {}
fast-deep-equal@3.1.3: {}
fast-json-stable-stringify@2.1.0: {}
@@ -2275,6 +2286,10 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.31.1
lightningcss-win32-x64-msvc: 1.31.1
lightweight-charts@5.1.0:
dependencies:
fancy-canvas: 2.1.0
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0

View File

@@ -11,6 +11,7 @@ import type {
Exchange,
PaperRun,
PaperRunPositionsResponse,
PaperRunCandlesResponse,
DataRange,
CandleCoverageEntry,
BackfillCandlesRequest,
@@ -175,3 +176,8 @@ export async function backfillCandles(
});
return json(res);
}
export async function listPaperRunCandles(id: string): Promise<PaperRunCandlesResponse> {
const res = await fetch(`/api/v1/paper-runs/${id}/candles`);
return json(res);
}

View File

@@ -8,6 +8,7 @@ import type {
PaperRun,
DataRange,
PaperRunPositionsResponse,
PaperRunCandlesResponse,
CandleCoverageEntry,
BackfillCandlesRequest,
CreateStrategyRequest,
@@ -170,6 +171,15 @@ export function usePaperRunPositions(id: string | undefined, samples?: number) {
});
}
export function usePaperRunCandles(id: string | undefined, candleInterval: string | null) {
return useQuery<PaperRunCandlesResponse>({
queryKey: ['paper-run-candles', id],
queryFn: () => api.listPaperRunCandles(id!),
enabled: !!id && !!candleInterval,
staleTime: 60_000,
});
}
export function useCreatePaperRun() {
const queryClient = useQueryClient();
return useMutation({

View File

@@ -0,0 +1,271 @@
import { useEffect, useRef } from 'react';
import {
createChart,
createSeriesMarkers,
CandlestickSeries,
LineSeries,
ColorType,
LineStyle,
type IChartApi,
type CandlestickData,
type LineData,
type SeriesMarker,
type UTCTimestamp,
} from 'lightweight-charts';
import type { CandleEntry, PaperRunPosition } from '../types/api';
import type { IndicatorSpec } from '../utils/extractSmaSpecs';
const INDICATOR_COLORS = ['#2962ff', '#ff6d00', '#6a1b9a', '#e53935', '#00897b', '#795548'];
const SUPERTREND_COLOR = '#e040fb';
function computeSma(candles: CandleEntry[], field: string, period: number): LineData[] {
const result: LineData[] = [];
const key = field as keyof CandleEntry;
for (let i = period - 1; i < candles.length; i++) {
let sum = 0;
for (let j = i - period + 1; j <= i; j++) {
sum += Number(candles[j][key]);
}
result.push({
time: (new Date(candles[i].open_time).getTime() / 1000) as UTCTimestamp,
value: sum / period,
});
}
return result;
}
function computeEma(candles: CandleEntry[], field: string, period: number): LineData[] {
const result: LineData[] = [];
const key = field as keyof CandleEntry;
const k = 2 / (period + 1);
let ema = 0;
let seeded = false;
for (let i = 0; i < candles.length; i++) {
const val = Number(candles[i][key]);
if (!seeded) {
if (i < period - 1) continue;
// Seed with SMA of first `period` values
let sum = 0;
for (let j = i - period + 1; j <= i; j++) sum += Number(candles[j][key]);
ema = sum / period;
seeded = true;
} else {
ema = val * k + ema * (1 - k);
}
result.push({
time: (new Date(candles[i].open_time).getTime() / 1000) as UTCTimestamp,
value: ema,
});
}
return result;
}
function computeSupertrend(candles: CandleEntry[], period: number, multiplier: number): LineData[] {
if (candles.length < period + 1) return [];
// True Range
const tr: number[] = candles.map((c, i) => {
if (i === 0) return c.high - c.low;
return Math.max(
c.high - c.low,
Math.abs(c.high - candles[i - 1].close),
Math.abs(c.low - candles[i - 1].close),
);
});
// ATR via Wilder's smoothing (seed with SMA, then RMA)
const atr: number[] = new Array(candles.length).fill(NaN);
let sum = 0;
for (let i = 0; i < period; i++) sum += tr[i];
atr[period - 1] = sum / period;
for (let i = period; i < candles.length; i++) {
atr[i] = (atr[i - 1] * (period - 1) + tr[i]) / period;
}
const upperBand: number[] = new Array(candles.length).fill(NaN);
const lowerBand: number[] = new Array(candles.length).fill(NaN);
// trend: 1 = bearish (on upper band), -1 = bullish (on lower band)
const trend: number[] = new Array(candles.length).fill(0);
const result: LineData[] = [];
for (let i = period - 1; i < candles.length; i++) {
if (isNaN(atr[i])) continue;
const hl2 = (candles[i].high + candles[i].low) / 2;
const basicUpper = hl2 + multiplier * atr[i];
const basicLower = hl2 - multiplier * atr[i];
if (i === period - 1) {
upperBand[i] = basicUpper;
lowerBand[i] = basicLower;
trend[i] = 1; // start bearish until we see a flip
} else {
// Upper band can only decrease; reset if prev close broke above it
upperBand[i] = (basicUpper < upperBand[i - 1] || candles[i - 1].close > upperBand[i - 1])
? basicUpper : upperBand[i - 1];
// Lower band can only increase; reset if prev close broke below it
lowerBand[i] = (basicLower > lowerBand[i - 1] || candles[i - 1].close < lowerBand[i - 1])
? basicLower : lowerBand[i - 1];
if (trend[i - 1] === -1 && candles[i].close < lowerBand[i]) {
trend[i] = 1; // flip to bearish
} else if (trend[i - 1] === 1 && candles[i].close > upperBand[i]) {
trend[i] = -1; // flip to bullish
} else {
trend[i] = trend[i - 1];
}
}
result.push({
time: (new Date(candles[i].open_time).getTime() / 1000) as UTCTimestamp,
value: trend[i] === 1 ? upperBand[i] : lowerBand[i],
});
}
return result;
}
function buildMarkers(positions: PaperRunPosition[]): SeriesMarker<UTCTimestamp>[] {
const markers: SeriesMarker<UTCTimestamp>[] = [];
for (const pos of positions) {
const entryTime = (new Date(pos.time_enter).getTime() / 1000) as UTCTimestamp;
const exitTime = (new Date(pos.time_exit).getTime() / 1000) as UTCTimestamp;
const entryPrice = parseFloat(pos.price_entry);
const exitPrice = parseFloat(pos.price_exit);
const profitable = parseFloat(pos.pnl) >= 0;
markers.push({
time: entryTime,
position: 'atPriceBottom',
shape: 'arrowUp',
color: '#198754',
price: entryPrice,
size: 1,
});
markers.push({
time: exitTime,
position: 'atPriceTop',
shape: 'arrowDown',
color: profitable ? '#198754' : '#dc3545',
price: exitPrice,
size: 1,
});
}
markers.sort((a, b) => (a.time as number) - (b.time as number));
return markers;
}
interface Props {
candles: CandleEntry[];
interval: string;
indicatorSpecs?: IndicatorSpec[];
positions?: PaperRunPosition[];
}
export default function CandlestickChart({
candles,
interval,
indicatorSpecs = [],
positions = [],
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
useEffect(() => {
if (!containerRef.current || candles.length === 0) return;
const chart = createChart(containerRef.current, {
layout: {
background: { type: ColorType.Solid, color: '#ffffff' },
textColor: '#333',
attributionLogo: false,
},
autoSize: true,
height: 400,
grid: {
vertLines: { color: '#e1e1e1' },
horzLines: { color: '#e1e1e1' },
},
timeScale: {
timeVisible: true,
secondsVisible: false,
},
});
const candleSeries = chart.addSeries(CandlestickSeries, {
upColor: '#198754',
downColor: '#dc3545',
borderDownColor: '#dc3545',
borderUpColor: '#198754',
wickDownColor: '#dc3545',
wickUpColor: '#198754',
});
const data: CandlestickData[] = candles.map((c) => ({
time: (new Date(c.open_time).getTime() / 1000) as UTCTimestamp,
open: c.open,
high: c.high,
low: c.low,
close: c.close,
}));
candleSeries.setData(data);
if (positions.length > 0) {
createSeriesMarkers(candleSeries, buildMarkers(positions));
}
// Render indicators — shared color index across SMA/EMA so colors don't repeat
let colorIdx = 0;
for (const spec of indicatorSpecs) {
if (spec.kind === 'supertrend') {
const stData = computeSupertrend(candles, spec.period, spec.multiplier);
if (stData.length === 0) continue;
chart.addSeries(LineSeries, {
color: SUPERTREND_COLOR,
lineWidth: 2,
lineStyle: LineStyle.Solid,
priceLineVisible: false,
lastValueVisible: true,
title: `ST(${spec.period},${spec.multiplier})`,
}).setData(stData);
continue;
}
const color = INDICATOR_COLORS[colorIdx % INDICATOR_COLORS.length];
colorIdx++;
if (spec.kind === 'sma') {
const lineData = computeSma(candles, spec.field, spec.period);
if (lineData.length === 0) continue;
chart.addSeries(LineSeries, {
color,
lineWidth: 1,
lineStyle: LineStyle.Solid,
priceLineVisible: false,
lastValueVisible: true,
title: `SMA(${spec.period})`,
}).setData(lineData);
} else if (spec.kind === 'ema') {
const lineData = computeEma(candles, spec.field, spec.period);
if (lineData.length === 0) continue;
chart.addSeries(LineSeries, {
color,
lineWidth: 1,
lineStyle: LineStyle.Dashed,
priceLineVisible: false,
lastValueVisible: true,
title: `EMA(${spec.period})`,
}).setData(lineData);
}
}
chart.timeScale().fitContent();
chartRef.current = chart;
return () => {
chart.remove();
chartRef.current = null;
};
}, [candles, interval, indicatorSpecs, positions]);
return <div ref={containerRef} style={{ height: 400 }} />;
}

View File

@@ -30,6 +30,49 @@ function strategyLabel(s: Strategy): string {
return s.hash.slice(0, 16);
}
// ---------------------------------------------------------------------------
// Multi-timeframe helpers
// ---------------------------------------------------------------------------
function collectTimeframesFromExpr(expr: unknown, out: Set<string>): void {
if (typeof expr !== 'object' || expr === null) return;
const e = expr as Record<string, unknown>;
if (typeof e.timeframe === 'string') out.add(e.timeframe);
collectTimeframesFromExpr(e.left, out);
collectTimeframesFromExpr(e.right, out);
collectTimeframesFromExpr(e.input, out);
collectTimeframesFromExpr(e.operand, out);
}
function collectTimeframesFromCondition(cond: unknown, out: Set<string>): void {
if (typeof cond !== 'object' || cond === null) return;
const c = cond as Record<string, unknown>;
// Legacy shorthand conditions (ema_crossover, ema_trend, rsi, bollinger, price_level)
if (typeof c.timeframe === 'string') out.add(c.timeframe);
// all_of / any_of
if (Array.isArray(c.conditions)) {
(c.conditions as unknown[]).forEach((sub) => collectTimeframesFromCondition(sub, out));
}
// not / event_count / bars_since inner condition
if (c.condition !== undefined) collectTimeframesFromCondition(c.condition, out);
// compare / cross_over / cross_under: walk left/right expressions
collectTimeframesFromExpr(c.left, out);
collectTimeframesFromExpr(c.right, out);
}
/** Returns sorted list of additional timeframes referenced in the strategy's rules,
* excluding the primary candle interval. */
function collectAdditionalTimeframes(strategy: Strategy, primaryInterval: string): string[] {
const cfg = strategy.config as Record<string, unknown>;
const rules = Array.isArray(cfg.rules) ? (cfg.rules as unknown[]) : [];
const set = new Set<string>();
for (const rule of rules) {
collectTimeframesFromCondition((rule as Record<string, unknown>).when, set);
}
set.delete(primaryInterval);
return [...set].sort();
}
export default function AddPaperRunPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@@ -230,6 +273,12 @@ export default function AddPaperRunPage() {
setCandleInterval(interval);
}, [selectedStrategy]);
/** Additional timeframes the selected strategy references beyond the primary interval. */
const additionalTimeframes = useMemo(() => {
if (!selectedStrategy || !candleInterval) return [];
return collectAdditionalTimeframes(selectedStrategy, candleInterval);
}, [selectedStrategy, candleInterval]);
function handleExchangeChange(name: string) {
setExchange(name);
setPair('');
@@ -503,6 +552,69 @@ export default function AddPaperRunPage() {
)}
</div>
)}
{/* Additional timeframes referenced by the selected strategy */}
{additionalTimeframes.length > 0 && (
<div className="mt-2 border-top pt-2">
<Form.Text className="d-block mb-1" style={{ fontWeight: 600 }}>
Additional timeframes required:
</Form.Text>
{additionalTimeframes.map((tf) => {
const tfCov = candleCoverage.data?.find((c) => c.interval === tf);
return (
<div key={tf} className="mb-2">
{candleCoverage.isLoading ? (
<Form.Text className="text-muted">
Checking {tf} coverage
</Form.Text>
) : tfCov ? (
<Form.Text className="text-muted">
{tf}: {new Date(tfCov.first_open).toLocaleString()} {' '}
{new Date(tfCov.last_close).toLocaleString()}{' '}
({tfCov.count.toLocaleString()} candles)
</Form.Text>
) : (
<div>
<Form.Text className="text-warning d-block">
No {tf} candle data. Backfill from Binance:
</Form.Text>
<Button
size="sm"
variant="outline-primary"
className="mt-1"
disabled={
backfillMutation.isPending || !startsAt || !finishesAt
}
onClick={() =>
backfillMutation.mutate({
exchange,
symbol: nameExchange!,
interval: tf,
from: new Date(startsAt).toISOString(),
to: new Date(finishesAt).toISOString(),
})
}
>
{backfillMutation.isPending ? (
<>
<Spinner
size="sm"
animation="border"
className="me-1"
/>
Backfilling
</>
) : (
`Backfill ${tf} candles`
)}
</Button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
)}
</Form.Group>

View File

@@ -308,33 +308,46 @@ export default function AddStrategyPage() {
{/* Quick reference */}
<Col lg={4} xl={5}>
<Card className="mb-3">
<Card.Header>Condition Reference</Card.Header>
<Card.Header>DSL Reference</Card.Header>
<Card.Body style={{ fontSize: '0.8em' }}>
<p className="mb-1 fw-semibold">Position</p>
<pre className="mb-2">{`{ "kind": "position", "state": "flat"|"long"|"short" }`}</pre>
<p className="mb-1 fw-semibold">Compare</p>
<pre className="mb-2">{`{ "kind": "compare",
"left": <expr>, "op": ">"|"<"|">="|"<="|"==",
"right": <expr> }`}</pre>
<p className="mb-1 fw-semibold">Cross over / under</p>
<pre className="mb-2">{`{ "kind": "cross_over", "left": <expr>, "right": <expr> }
{ "kind": "cross_under", "left": <expr>, "right": <expr> }`}</pre>
<p className="mb-1 fw-semibold">Logic</p>
<pre className="mb-2">{`{ "kind": "all_of", "conditions": [...] }
{ "kind": "any_of", "conditions": [...] }
{ "kind": "not", "condition": { ... } }`}</pre>
<p className="mb-1 fw-semibold">Conditions</p>
<pre className="mb-2">{`{ "kind": "position", "state": "flat"|"long"|"short" }
{ "kind": "compare", "left": <expr>,
"op": ">"|"<"|">="|"<="|"==", "right": <expr> }
{ "kind": "cross_over"|"cross_under",
"left": <expr>, "right": <expr> }
{ "kind": "event_count", "condition": <cond>,
"period": 20, "op": ">=", "count": 1 }
{ "kind": "all_of"|"any_of", "conditions": [...] }
{ "kind": "not", "condition": <cond> }`}</pre>
<p className="mb-1 fw-semibold">Expressions</p>
<pre className="mb-0">{`{ "kind": "literal", "value": "100.5" }
{ "kind": "field", "field": "close", "offset": 0 }
{ "kind": "func", "name": "sma"|"ema"|"highest"
|"lowest"|"rsi"|"std_dev",
"field": "close", "period": 20, "offset": 0 }
<pre className="mb-2">{`{ "kind": "literal", "value": "100.5" }
{ "kind": "field",
"field": "open"|"high"|"low"|"close"|"volume",
"offset": 0, "timeframe": "1h" }
{ "kind": "func",
"name": "sma"|"ema"|"wma"|"rsi"
|"highest"|"lowest"|"std_dev"|"sum"
|"atr"|"adx"|"supertrend",
"field": "close", "period": 20,
"multiplier": "3.0", // supertrend only
"offset": 0, "timeframe": "1d" }
{ "kind": "apply_func", "name": "ema",
"input": <expr>, "period": 20, "offset": 0 }
{ "kind": "bin_op", "op": "add"|"sub"|"mul"|"div",
"left": <expr>, "right": <expr> }`}</pre>
"left": <expr>, "right": <expr> }
{ "kind": "unary_op", "op": "abs"|"neg"|"sqrt"|"log",
"operand": <expr> }
{ "kind": "bars_since", "condition": <cond>, "period": 20 }`}</pre>
<p className="mb-1 fw-semibold">Multi-timeframe</p>
<pre className="mb-0">{`// field and func accept an optional "timeframe" to
// read from a different candle interval. Evaluation
// is gated on the primary (candle_interval) close.
{ "kind": "func", "name": "sma", "field": "close",
"period": 200, "timeframe": "1d" }
// Each extra timeframe needs candle data backfilled.`}</pre>
</Card.Body>
</Card>
@@ -357,7 +370,13 @@ export default function AddStrategyPage() {
<li>Nested functions (EMA of RSI)</li>
<li>ta.atr, ta.macd, ta.stoch</li>
<li>Stop/limit orders</li>
<li>Multi-timeframe (request.security)</li>
<li>
Multi-timeframe (<code>request.security</code>)
<br />
<span className="text-muted" style={{ fontSize: '0.9em' }}>
Use <code>"timeframe"</code> field in DSL directly
</span>
</li>
<li>Dynamic position sizing</li>
</ul>
</Card.Body>

View File

@@ -1,4 +1,5 @@
import { useParams } from 'react-router-dom';
import { useMemo } from 'react';
import {
Accordion,
Alert,
@@ -10,9 +11,11 @@ import {
Row,
Spinner,
} from 'react-bootstrap';
import { usePaperRun, useCancelPaperRun, usePaperRunPositions } from '../api/hooks';
import { usePaperRun, useCancelPaperRun, usePaperRunPositions, usePaperRunCandles } from '../api/hooks';
import type { PaperRun } from '../types/api';
import EquityCurveChart from '../components/EquityCurveChart';
import CandlestickChart from '../components/CandlestickChart';
import { extractIndicatorSpecs } from '../utils/extractSmaSpecs';
import MetricLabel from '../components/MetricLabel';
import AppBreadcrumb from '../components/AppBreadcrumb';
import mdPnl from '../content/metrics/pnl.md?raw';
@@ -179,7 +182,16 @@ export default function PaperRunDetailPage() {
const { data: run, isLoading, isError, error } = usePaperRun(id!);
const cancelMutation = useCancelPaperRun();
const isComplete = run?.status === 'complete';
const { data: positionsData } = usePaperRunPositions(isComplete ? id : undefined, 500);
const { data: positionsData } = usePaperRunPositions(isComplete ? id : undefined, 5000);
const { data: candlesData } = usePaperRunCandles(isComplete ? id : undefined, run?.candle_interval ?? null);
// Extract indicator specs from the strategy config, keeping only those whose timeframe
// matches the chart's candle interval (null timeframe = primary = matches too).
const indicatorSpecs = useMemo(() => {
const cfg = run?.config as Record<string, unknown> | undefined;
const all = extractIndicatorSpecs(cfg?.strategy);
return all.filter((s) => s.timeframe === null || s.timeframe === run?.candle_interval);
}, [run?.config, run?.candle_interval]);
if (isLoading) {
return (
@@ -425,6 +437,26 @@ export default function PaperRunDetailPage() {
</Card.Body>
</Card>
{/* Candlestick chart (candle-mode backtests only) */}
{candlesData && candlesData.candles.length > 0 && (
<Card className="mb-3">
<Card.Header>
Price Chart
<small className="text-muted ms-2">
{candlesData.total.toLocaleString()} {candlesData.interval} candles
</small>
</Card.Header>
<Card.Body className="p-0">
<CandlestickChart
candles={candlesData.candles}
interval={candlesData.interval}
indicatorSpecs={indicatorSpecs}
positions={positionsData?.positions}
/>
</Card.Body>
</Card>
)}
{/* Equity curve */}
{positionsData && positionsData.positions.length > 0 && (
<Card className="mb-3">

View File

@@ -171,3 +171,19 @@ export interface BackfillCandlesResponse {
from: string;
to: string;
}
export interface CandleEntry {
open_time: string;
close_time: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
}
export interface PaperRunCandlesResponse {
interval: string;
total: number;
candles: CandleEntry[];
}

View File

@@ -0,0 +1,125 @@
export interface SmaSpec {
kind: 'sma';
field: string;
period: number;
timeframe: string | null;
}
export interface EmaSpec {
kind: 'ema';
field: string;
period: number;
timeframe: string | null;
}
export interface SupertrendSpec {
kind: 'supertrend';
period: number;
multiplier: number;
timeframe: string | null;
}
export type IndicatorSpec = SmaSpec | EmaSpec | SupertrendSpec;
function walkExpr(expr: unknown, specs: IndicatorSpec[]): void {
if (!expr || typeof expr !== 'object') return;
const e = expr as Record<string, unknown>;
if (e.kind === 'func') {
if (e.name === 'sma' || e.name === 'ema') {
const field = typeof e.field === 'string' ? e.field : 'close';
const period = typeof e.period === 'number' ? e.period : 0;
const timeframe = typeof e.timeframe === 'string' ? e.timeframe : null;
if (period > 0) {
specs.push({ kind: e.name as 'sma' | 'ema', field, period, timeframe });
}
return;
}
if (e.name === 'supertrend') {
const period = typeof e.period === 'number' ? e.period : 0;
const multiplier = typeof e.multiplier === 'string' ? parseFloat(e.multiplier) : 3.0;
const timeframe = typeof e.timeframe === 'string' ? e.timeframe : null;
if (period > 0) {
specs.push({ kind: 'supertrend', period, multiplier: isNaN(multiplier) ? 3.0 : multiplier, timeframe });
}
return;
}
return;
}
if (e.kind === 'bin_op' || e.kind === 'apply_func') {
walkExpr(e.left, specs);
walkExpr(e.right, specs);
walkExpr(e.inner, specs);
walkExpr(e.input, specs);
return;
}
// Field or literal — no indicators inside
}
function walkCondition(cond: unknown, specs: IndicatorSpec[]): void {
if (!cond || typeof cond !== 'object') return;
const c = cond as Record<string, unknown>;
switch (c.kind) {
case 'all_of':
case 'any_of':
if (Array.isArray(c.conditions)) {
c.conditions.forEach((child) => walkCondition(child, specs));
}
break;
case 'not':
walkCondition(c.condition, specs);
break;
case 'compare':
case 'cross_over':
case 'cross_under':
walkExpr(c.left, specs);
walkExpr(c.right, specs);
break;
case 'event_count':
walkCondition(c.condition, specs);
break;
case 'ema_crossover':
case 'ema_trend':
case 'rsi':
case 'bollinger':
case 'position':
case 'price_level':
break;
}
}
export function extractIndicatorSpecs(strategyConfig: unknown): IndicatorSpec[] {
if (!strategyConfig || typeof strategyConfig !== 'object') return [];
const cfg = strategyConfig as Record<string, unknown>;
if (cfg.type !== 'rule_based') return [];
const rules = Array.isArray(cfg.rules) ? cfg.rules : [];
const raw: IndicatorSpec[] = [];
for (const rule of rules) {
if (rule && typeof rule === 'object') {
const r = rule as Record<string, unknown>;
walkCondition(r.when, raw);
}
}
// Deduplicate
const seen = new Set<string>();
return raw.filter((s) => {
const key = s.kind === 'supertrend'
? `supertrend:${s.period}:${s.multiplier}:${s.timeframe ?? ''}`
: `${s.kind}:${s.field}:${s.period}:${s.timeframe ?? ''}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
/** @deprecated Use extractIndicatorSpecs instead. */
export function extractSmaSpecs(strategyConfig: unknown): SmaSpec[] {
return extractIndicatorSpecs(strategyConfig).filter((s): s is SmaSpec => s.kind === 'sma');
}

View File

@@ -1,6 +1,6 @@
This document provides guidance on how to translate a trading strategy into the Swym rules-based strategy DSL.
Swym requires a strategy to be expressed as a single valid JSON object. The machine-readable schema lives at [`docs/strategy.schema.json`](./strategy.schema.json) (JSON Schema draft-07). Use the schema to validate a strategy before submitting it — see [Validation](#validation) below.
Swym requires a strategy to be expressed as a single valid JSON object. The machine-readable schema lives at [`assets/strategy/schema.json`](../assets/strategy/schema.json) (JSON Schema draft-07). Use the schema to validate a strategy before submitting it — see [Validation](#validation) below.
No prose, no markdown fences — just the JSON.
@@ -128,6 +128,81 @@ All expressions have a "kind" discriminator.
### Unary math
{ "kind": "unary_op", "op": "abs"|"sqrt"|"neg"|"log", "operand": <Expr> }
## Multi-timeframe expressions
`field`, `func`, and `apply_func` accept an optional `"timeframe"` key that reads from a different
candle interval than the primary `candle_interval`. The strategy is evaluated once per primary candle
close; additional timeframes are read-only context updated in parallel.
Timeframe values follow the same enum as `candle_interval`: `"1m"` | `"5m"` | `"15m"` | `"1h"` | `"4h"` | `"1d"`.
When `"timeframe"` is absent or null the expression uses the primary interval (backward-compatible).
{ "kind": "field", "field": "close", "timeframe": "1d" } // daily close
{ "kind": "func", "name": "sma", "field": "close", "period": 200, "timeframe": "1d" } // daily 200 SMA
{ "kind": "func", "name": "ema", "field": "close", "period": 20, "timeframe": "1h" } // hourly 20 EMA
The legacy shorthand conditions also accept `"timeframe"`:
{ "kind": "ema_trend", "period": 50, "direction": "above", "timeframe": "1d" }
{ "kind": "ema_crossover", "fast_period": 9, "slow_period": 21, "direction": "above", "timeframe": "1h" }
{ "kind": "rsi", "period": 14, "threshold": "30", "comparison": "below", "timeframe": "4h" }
{ "kind": "bollinger", "period": 20, "band": "below_lower", "timeframe": "1h" }
{ "kind": "price_level", "price": "50000", "direction": "above", "timeframe": "1d" }
### Data requirements
Each additional timeframe must have candle data backfilled for the backtest window.
Use `POST /api/v1/market-candles/backfill` (or the Backfill button in the dashboard).
The API rejects backtest creation if any referenced timeframe lacks coverage.
### Example: daily trend filter + 4h entry
{
"type": "rule_based",
"candle_interval": "4h",
"rules": [
{
"comment": "Long: daily uptrend + 4h pullback to 20 SMA",
"when": { "kind": "all_of", "conditions": [
{ "kind": "position", "state": "flat" },
{
"comment": "Daily: close > 200 SMA (bullish regime)",
"kind": "compare",
"left": { "kind": "field", "field": "close", "timeframe": "1d" },
"op": ">",
"right": { "kind": "func", "name": "sma", "field": "close", "period": 200, "timeframe": "1d" }
},
{
"comment": "4h: price crosses above 20 SMA (primary entry trigger)",
"kind": "cross_over",
"left": { "kind": "field", "field": "close" },
"right": { "kind": "func", "name": "sma", "field": "close", "period": 20 }
}
]},
"then": { "side": "buy", "quantity": "0.001" }
},
{
"comment": "Exit: close crosses below 20 SMA",
"when": { "kind": "all_of", "conditions": [
{ "kind": "position", "state": "long" },
{ "kind": "cross_under",
"left": { "kind": "field", "field": "close" },
"right": { "kind": "func", "name": "sma", "field": "close", "period": 20 } }
]},
"then": { "side": "sell", "quantity": "0.001" }
}
]
}
### Constraints
- `"timeframe"` is optional on every node; absent means primary interval.
- Each referenced timeframe needs backfilled candle data for the backtest range.
- The primary `candle_interval` gates evaluation — the strategy runs only on primary closes.
- Warm-up counts (see table below) apply to each timeframe's candle history independently.
- `apply_func` outer `"timeframe"` controls the output series; the inner `input` resolves its own
timeframe independently.
## FuncName values
| name | description | field used? |
@@ -262,18 +337,19 @@ Use a candle_interval and backtesting window long enough to cover warm-up.
4. Decimal values in "field": use snake_case: "open", "high", "low", "close", "volume".
5. "kind" is required on every Condition and Expr node.
6. Do not invent new kind/name/op/field values — only use the ones listed above.
"timeframe" is an optional field on field/func/apply_func and legacy conditions; valid values are the same enum as candle_interval.
7. The strategy must have at least one buy rule and at least one sell rule.
8. A condition returning false (e.g. during warm-up) never fires an order — this is safe.
## Validation
The JSON Schema at [`docs/strategy.schema.json`](./strategy.schema.json) enforces all structural constraints above (required fields, enum values, string-typed decimals, `additionalProperties: false` on every node). Validate a strategy JSON before submitting it to catch mistakes early.
The JSON Schema at [`assets/strategy/schema.json`](./strategy.schema.json) enforces all structural constraints above (required fields, enum values, string-typed decimals, `additionalProperties: false` on every node). Validate a strategy JSON before submitting it to catch mistakes early.
### Command-line (check-jsonschema)
```bash
pip install check-jsonschema # one-time install
check-jsonschema --schemafile docs/strategy.schema.json my-strategy.json
check-jsonschema --schemafile assets/strategy/schema.json my-strategy.json
```
### Python (jsonschema)
@@ -281,7 +357,7 @@ check-jsonschema --schemafile docs/strategy.schema.json my-strategy.json
```python
import json, jsonschema
schema = json.load(open("docs/strategy.schema.json"))
schema = json.load(open("assets/strategy/schema.json"))
strategy = json.load(open("my-strategy.json"))
jsonschema.validate(strategy, schema) # raises ValidationError on failure
@@ -296,7 +372,7 @@ Add to `.vscode/settings.json` to get inline validation and autocomplete for any
"json.schemas": [
{
"fileMatch": ["**/strategy*.json", "**/rules*.json"],
"url": "./docs/strategy.schema.json"
"url": "./assets/strategy/schema.json"
}
]
}
@@ -306,7 +382,7 @@ Add to `.vscode/settings.json` to get inline validation and autocomplete for any
When asking an LLM (Claude, GPT-4, etc.) to translate a strategy, include the schema as context:
1. Paste the full contents of `docs/strategy.schema.json` into your prompt, or reference this document.
1. Paste the full contents of `assets/strategy/schema.json` into your prompt, or reference this document.
2. Ask the LLM to produce a strategy JSON that is valid against the schema.
3. Validate the output with one of the methods above before submitting a backtest.

View File

@@ -12,9 +12,9 @@ use crate::error::ApiError;
use crate::state::AppState;
use swym_dal::models::paper_run::{PaperRunRow, PaperRunStatus};
use swym_dal::models::paper_run_position::PaperRunPositionRow;
use swym_dal::models::strategy_config::StrategyConfig;
use swym_dal::models::strategy_config::{StrategyConfig, collect_timeframes};
use swym_dal::models::condition_audit::ConditionAuditRow;
use swym_dal::repo::{condition_audit, paper_run, paper_run_position, strategy};
use swym_dal::repo::{condition_audit, instrument, market_event, paper_run, paper_run_position, strategy};
use swym_dal::strategy_hash::{compute_strategy_hash, normalize_strategy};
// -- Request / Response types --
@@ -223,6 +223,52 @@ pub async fn create_paper_run(
data_end = range.1,
)));
}
// For rule-based strategies, also validate every additional timeframe
// referenced by expressions in the rule tree.
if let StrategyConfig::RuleBased(ref params) = run_config.strategy {
let all_timeframes = collect_timeframes(params);
let mut additional: Vec<&str> = all_timeframes
.iter()
.map(String::as_str)
.filter(|&tf| tf != interval.as_str())
.collect();
additional.sort_unstable();
for tf in additional {
if !VALID_CANDLE_INTERVALS.contains(&tf) {
return Err(ApiError::BadRequest(format!(
"strategy references unknown timeframe '{tf}'; valid values: {}",
VALID_CANDLE_INTERVALS.join(", ")
)));
}
let tf_range = swym_dal::repo::market_event::candle_data_range(
&state.pool,
instrument.id,
tf,
)
.await?
.ok_or_else(|| {
ApiError::BadRequest(format!(
"strategy references timeframe '{tf}' but no {tf} candle data is available \
for {name_exchange} on {exchange_name}; \
run a backfill first via POST /api/v1/market-candles/backfill"
))
})?;
if req.starts_at < tf_range.0 || req.finishes_at > tf_range.1 {
return Err(ApiError::BadRequest(format!(
"requested range {starts} {ends} is outside available {tf} candle data \
{data_start} {data_end}",
starts = req.starts_at,
ends = req.finishes_at,
data_start = tf_range.0,
data_end = tf_range.1,
)));
}
}
}
} else {
// Tick replay mode: verify trade data is available.
let range = swym_dal::repo::market_event::trade_data_range(&state.pool, instrument.id)
@@ -461,3 +507,85 @@ pub async fn get_condition_audit_detail(
rows,
}))
}
/// GET /api/v1/paper-runs/{id}/candles
#[derive(Debug, Serialize)]
pub struct CandlesResponse {
pub interval: String,
pub total: usize,
pub candles: Vec<CandleEntry>,
}
#[derive(Debug, Serialize)]
pub struct CandleEntry {
pub open_time: DateTime<Utc>,
pub close_time: DateTime<Utc>,
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
pub volume: f64,
}
pub async fn list_paper_run_candles(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<CandlesResponse>, ApiError> {
let run = paper_run::get_by_id(&state.pool, id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("paper run {id} not found")))?;
let interval = run.candle_interval.as_ref().ok_or_else(|| {
ApiError::BadRequest("this run uses tick data (no candle_interval set)".into())
})?;
let run_config: swym_dal::models::run_config::RunConfig =
serde_json::from_value(run.config.clone())
.map_err(|e| ApiError::BadRequest(format!("invalid run config: {e}")))?;
let exchange_name = run_config
.instrument
.get("exchange")
.and_then(|v| v.as_str())
.ok_or_else(|| ApiError::BadRequest("config missing instrument.exchange".into()))?;
let name_exchange = run_config
.instrument
.get("name_exchange")
.and_then(|v| v.as_str())
.ok_or_else(|| ApiError::BadRequest("config missing instrument.name_exchange".into()))?;
let instrument_row = instrument::find_by_exchange_and_name(&state.pool, exchange_name, name_exchange)
.await?
.ok_or_else(|| {
ApiError::NotFound(format!("instrument {name_exchange} on {exchange_name} not found"))
})?;
let rows = market_event::load_candles_range(
&state.pool,
instrument_row.id,
interval,
run.starts_at,
run.finishes_at,
)
.await?;
let candles: Vec<CandleEntry> = rows
.into_iter()
.map(|r| CandleEntry {
open_time: r.open_time,
close_time: r.close_time,
open: r.open,
high: r.high,
low: r.low,
close: r.close,
volume: r.volume,
})
.collect();
let total = candles.len();
Ok(Json(CandlesResponse {
interval: interval.clone(),
total,
candles,
}))
}

View File

@@ -58,6 +58,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.route("/api/v1/paper-runs/{id}", get(handlers::paper_runs::get_paper_run))
.route("/api/v1/paper-runs/{id}/cancel", post(handlers::paper_runs::cancel_paper_run))
.route("/api/v1/paper-runs/{id}/positions", get(handlers::paper_runs::list_paper_run_positions))
.route("/api/v1/paper-runs/{id}/candles", get(handlers::paper_runs::list_paper_run_candles))
.route("/api/v1/paper-runs/{id}/condition-audit", get(handlers::paper_runs::get_condition_audit_summary))
.route("/api/v1/paper-runs/{id}/condition-audit/detail", get(handlers::paper_runs::get_condition_audit_detail))
.route("/api/v1/market-subscriptions", post(handlers::market_subscriptions::create_subscriptions))

View File

@@ -60,8 +60,12 @@ pub async fn execute_run(
.await?;
let strategy = SwymStrategy::from_config(&strategy_config);
let ema_periods = strategy.ema_periods();
let ema_regs = strategy.ema_registrations();
let candle_interval_secs = strategy.candle_interval_secs();
let additional_secs: Vec<u64> = strategy.all_interval_secs()
.into_iter()
.filter(|&s| Some(s) != candle_interval_secs)
.collect();
let system_args = SystemArgs::new(
&instruments,
@@ -72,13 +76,13 @@ pub async fn execute_run(
market_stream,
DefaultGlobalData::default(),
move |_| {
let mut data = if let Some(secs) = candle_interval_secs {
SwymInstrumentData::with_candle_aggregator(secs)
let mut data = if let Some(primary) = candle_interval_secs {
SwymInstrumentData::with_multi_timeframes(primary, &additional_secs)
} else {
SwymInstrumentData::default()
};
for &period in &ema_periods {
data.register_ema(period);
for &(secs, period) in &ema_regs {
data.register_ema(secs, period);
}
data
},
@@ -237,6 +241,20 @@ pub async fn execute_backtest(
if let Some(interval) = candle_interval {
// --- Candle-based backtest path ---
// Compute the indicator warmup period needed by this strategy. Long-period indicators
// (e.g. SMA(200) on 4h = ~33 days) require candle history accumulated before `starts_at`
// or their first N evaluations will report insufficient data. Pre-loading warmup candles
// eliminates the dead zone at the start of the backtest window.
let warmup_secs =
swym_dal::models::strategy_config::compute_warmup_secs(&strategy_config);
let warmup_duration = chrono::Duration::seconds(warmup_secs as i64);
let data_starts_at = starts_at - warmup_duration;
if warmup_secs > 0 {
info!(%run_id, warmup_secs, %data_starts_at, "pre-loading indicator warmup candles");
}
let first_candle_page = swym_dal::repo::market_event::load_candles_page(
pool,
instrument_row.id,
@@ -256,22 +274,91 @@ pub async fn execute_backtest(
}
let first_event_time = first_candle_page[0].open_time;
info!(interval, "beginning chunked candle backtest replay");
let primary_secs = strategy.candle_interval_secs().unwrap_or(0);
let ema_regs = strategy.ema_registrations();
let additional_intervals: Vec<&'static str> = strategy.additional_intervals();
let additional_secs_list: Vec<u64> = strategy.all_interval_secs()
.into_iter()
.filter(|&s| s != primary_secs)
.collect();
// Load all additional-interval candles upfront (they are infrequent: ≤8760 per year
// for 1h, ≤365 for 1d). Load from `data_starts_at` (= starts_at - warmup) so the
// full warmup history is available before engine start.
let mut add_candles: Vec<(u64, swym_dal::models::market_event::MarketCandleRow)> = Vec::new();
for (&add_secs, add_interval) in additional_secs_list.iter().zip(additional_intervals.iter()) {
let rows = load_all_candles(
pool, instrument_row.id, add_interval, data_starts_at, finishes_at,
).await?;
if rows.is_empty() {
warn!(%run_id, add_interval, "no candle data for additional timeframe — expressions referencing it will return None");
}
for row in rows {
add_candles.push((add_secs, row));
}
}
add_candles.sort_by_key(|(_, row)| row.close_time);
info!(interval, additional = ?additional_intervals, "beginning chunked candle backtest replay");
// Initialise shared per-instrument data. This is passed to new_with_init below so the
// engine starts with pre-warmed indicator histories built from the warmup candles above.
let mut warmup_instrument_data = SwymInstrumentData::default();
warmup_instrument_data.init_timeframes(primary_secs, &additional_secs_list);
for &(secs, period) in &ema_regs {
warmup_instrument_data.register_ema(secs, period);
}
// Pre-process warmup candles (those before starts_at) through instrument data only.
// This populates candle histories and EMA states without generating any orders.
// We process all additional-interval warmup candles first, then primary warmup candles,
// interleaved in time order.
if warmup_secs > 0 {
// Collect primary warmup candles.
let primary_warmup = load_all_candles(
pool, instrument_row.id, interval, data_starts_at, starts_at,
).await?;
// Collect additional warmup candles (those with close_time < starts_at).
let add_warmup_end = add_candles.partition_point(|(_, row)| row.close_time < starts_at);
// Build a merged time-sorted sequence for warmup.
let mut warmup_tagged: Vec<(u64, swym_dal::models::market_event::MarketCandleRow)> =
Vec::with_capacity(primary_warmup.len() + add_warmup_end);
for row in primary_warmup {
warmup_tagged.push((primary_secs, row));
}
for (add_secs, row) in &add_candles[..add_warmup_end] {
warmup_tagged.push((*add_secs, row.clone()));
}
warmup_tagged.sort_by_key(|(_, row)| row.close_time);
for (hint_secs, row) in warmup_tagged {
let close = Decimal::from_f64_retain(row.close).unwrap_or_default();
let open = Decimal::from_f64_retain(row.open).unwrap_or(close);
let high = Decimal::from_f64_retain(row.high).unwrap_or(close);
let low = Decimal::from_f64_retain(row.low).unwrap_or(close);
let vol = Decimal::from_f64_retain(row.volume).unwrap_or_default();
warmup_instrument_data.process_candle_warmup(
hint_secs, row.open_time, open, high, low, close, vol,
);
}
// Remove the warmup slice from add_candles so the main loop only sees candles >= starts_at.
add_candles.drain(..add_warmup_end);
info!(%run_id, warmup_secs, "indicator warmup complete");
}
let warmed_data = warmup_instrument_data;
let ema_periods = strategy.ema_periods();
let mut engine = BacktestEngine::<SwymInstrumentData>::new_with_init(
instruments,
balances,
config.execution.fees_percent,
risk_free_return,
first_event_time,
|_| {
let mut data = SwymInstrumentData::default();
for &period in &ema_periods {
data.register_ema(period);
}
data
},
move |_| warmed_data.clone(),
);
let mut run_state = engine.begin_run();
@@ -282,6 +369,8 @@ pub async fn execute_backtest(
let mut data_start: Option<DateTime<Utc>> = None;
let mut data_end: Option<DateTime<Utc>> = None;
let mut page = first_candle_page;
// Cursor into `add_candles` — advances as we interleave additional-interval candles.
let mut add_cursor: usize = 0;
loop {
if page.is_empty() {
@@ -296,14 +385,49 @@ pub async fn execute_backtest(
total_candle_count += page.len();
let fetched = page.len();
let mut events: Vec<MarketStreamEvent<InstrumentIndex, DataKind>> =
Vec::with_capacity(page.len() * 2);
// Build a merged, time-sorted sequence of (interval_hint, event) pairs.
// Additional-interval candles that close at or before this chunk's last close_time
// are included. Primary candles carry their hint; secondary carry theirs.
let chunk_end = page.last().map(|c| c.close_time).unwrap_or(finishes_at);
// Collect additional-interval events up to chunk_end.
let add_start = add_cursor;
while add_cursor < add_candles.len() && add_candles[add_cursor].1.close_time <= chunk_end {
add_cursor += 1;
}
// Compute total event count: each primary candle → 2 events (L1 + Candle),
// each additional candle → 1 event (Candle only; primary L1 sets the price).
let add_count = add_cursor - add_start;
let mut tagged: Vec<(u64, MarketStreamEvent<InstrumentIndex, DataKind>)> =
Vec::with_capacity(page.len() * 2 + add_count);
// Additional-interval candle events (no preceding L1 — primary price is authoritative).
for (add_secs, add_row) in &add_candles[add_start..add_cursor] {
tagged.push((*add_secs, MarketStreamEvent::Item(MarketEvent {
time_exchange: add_row.close_time,
time_received: add_row.close_time,
exchange: exchange_id,
instrument: instrument_index,
kind: DataKind::Candle(Candle {
close_time: add_row.close_time,
open: add_row.open,
high: add_row.high,
low: add_row.low,
close: add_row.close,
volume: add_row.volume,
trade_count: add_row.trade_count as u64,
}),
})));
}
// Primary-interval events (OrderBookL1 + Candle pair).
for candle in &page {
let close = Decimal::from_f64_retain(candle.close).unwrap_or_default();
let level = barter_data::books::Level { price: close, amount: Decimal::ONE };
events.push(MarketStreamEvent::Item(MarketEvent {
// L1 carries primary_secs hint but is not a Candle event — hint will be reset
// just before the actual Candle event below.
tagged.push((primary_secs, MarketStreamEvent::Item(MarketEvent {
time_exchange: candle.close_time,
time_received: candle.close_time,
exchange: exchange_id,
@@ -313,9 +437,8 @@ pub async fn execute_backtest(
best_bid: Some(level),
best_ask: Some(level),
}),
}));
events.push(MarketStreamEvent::Item(MarketEvent {
})));
tagged.push((primary_secs, MarketStreamEvent::Item(MarketEvent {
time_exchange: candle.close_time,
time_received: candle.close_time,
exchange: exchange_id,
@@ -329,13 +452,27 @@ pub async fn execute_backtest(
volume: candle.volume,
trade_count: candle.trade_count as u64,
}),
}));
})));
}
drop(page);
// Stable sort by event timestamp so additional-interval candles are interleaved
// at the correct chronological position relative to primary candles.
tagged.sort_by_key(|(_, ev)| match ev {
MarketStreamEvent::Item(m) => m.time_exchange,
_ => DateTime::<Utc>::MAX_UTC,
});
// Process events one at a time, setting `next_candle_interval_hint` before
// each DataKind::Candle so the processor routes it to the correct history.
let mut chunk_positions = Vec::new();
engine.process_chunk(&events, &strategy, &mut run_state, &mut chunk_positions);
drop(events);
for (hint_secs, event) in &tagged {
if matches!(event, MarketStreamEvent::Item(m) if matches!(m.kind, DataKind::Candle(_))) {
engine.instrument_data_mut(&instrument_index).next_candle_interval_hint = Some(*hint_secs);
}
engine.process_chunk(std::slice::from_ref(event), &strategy, &mut run_state, &mut chunk_positions);
}
drop(tagged);
if !chunk_positions.is_empty() {
let insert_positions: Vec<InsertPosition> = chunk_positions
@@ -671,6 +808,37 @@ pub async fn execute_backtest(
Ok(result)
}
/// Load all candles for a given instrument/interval/time range in a single paginated sweep.
///
/// Used to prefetch additional-timeframe (e.g. 1h, 1d) candles before the main backtest loop.
/// These intervals have far fewer rows than the primary interval so loading them upfront is safe.
async fn load_all_candles(
pool: &PgPool,
instrument_id: i32,
interval: &str,
from: DateTime<Utc>,
to: DateTime<Utc>,
) -> Result<Vec<swym_dal::models::market_event::MarketCandleRow>, Box<dyn std::error::Error + Send + Sync>>
{
const PAGE: i64 = 50_000;
let mut all = Vec::new();
let mut last_id: i64 = 0;
loop {
let page = swym_dal::repo::market_event::load_candles_page(
pool, instrument_id, interval, from, to, last_id, PAGE,
).await?;
let fetched = page.len();
if let Some(last) = page.last() {
last_id = last.id;
}
all.extend(page);
if (fetched as i64) < PAGE {
break;
}
}
Ok(all)
}
/// Extract exchange name and instrument exchange name from a `RunConfig.instrument` JSON blob.
fn extract_instrument_identifiers(
instrument: &serde_json::Value,

View File

@@ -145,45 +145,54 @@ impl CandleAggregator {
}
}
/// Maximum number of prices retained for window-based indicators (RSI, Bollinger Bands).
/// EMA computation uses [`RunningEma`] and is not subject to this limit.
/// Maximum number of candles/prices retained per timeframe.
const MAX_PRICE_HISTORY: usize = 500;
/// Custom instrument data that wraps [`DefaultInstrumentMarketData`] and adds
/// `last_order_time` for rate-limiting, a rolling price window for window-based
/// indicators (RSI, Bollinger), and stateful [`RunningEma`] instances for EMA-based
/// signals.
/// per-timeframe candle histories, price windows, EMA states, and aggregators.
///
/// Supports two evaluation modes controlled by `candle_aggregator`:
/// - **Tick mode** (`candle_aggregator = None`): every trade updates indicators; compiled
/// strategies use `interval_secs` throttling. Existing behaviour.
/// - **Candle mode** (`candle_aggregator = Some`): indicators update only on candle closes;
/// `candle_ready` is `true` for exactly the one `generate_algo_orders` call following
/// each candle close. Used by `RuleStrategy` and candle-based backtests.
/// ## Single-timeframe mode (backward compatible)
///
/// For candle-based backtests `DataKind::Candle` events arrive pre-formed from the DB;
/// the aggregator is not used (leave `candle_aggregator = None` for that path).
/// When only the primary timeframe is used (all strategies before multi-timeframe
/// support), behaviour is identical to before: one entry in each HashMap keyed by
/// `primary_interval_secs`. The `next_candle_interval_hint` field is unused.
///
/// ## Multi-timeframe mode
///
/// Additional timeframes are tracked in parallel. Candle histories for secondary
/// timeframes are updated when their candles close but do not trigger `candle_ready`.
/// The runner sets `next_candle_interval_hint` before each `DataKind::Candle` event
/// so the processor routes it to the correct per-timeframe history.
///
/// During live mode, multiple [`CandleAggregator`] instances run in parallel
/// (one per timeframe). Trades are fed to all aggregators simultaneously.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct SwymInstrumentData {
pub inner: DefaultInstrumentMarketData,
pub last_order_time: Option<DateTime<Utc>>,
pub last_event_time: Option<DateTime<Utc>>,
/// Rolling window of prices (most recent last), capped at [`MAX_PRICE_HISTORY`].
/// Used for window-based indicators (RSI, Bollinger Bands).
pub trade_prices: VecDeque<Decimal>,
/// Stateful running EMAs, keyed by period. Updated incrementally on each price;
/// never re-seeded or windowed. Correct over arbitrarily long price series.
pub ema_states: HashMap<usize, RunningEma>,
/// Present in live candle mode; aggregates raw trades into fixed-interval candles.
pub candle_aggregator: Option<CandleAggregator>,
/// Set to `true` for the single `generate_algo_orders` call immediately after a
/// candle closes (either from the aggregator or a `DataKind::Candle` backtest event).
/// Cleared at the start of the next `process()` call.
/// The primary interval in seconds — the one that drives `candle_ready` and
/// strategy evaluation cadence. `None` in tick mode.
pub primary_interval_secs: Option<u64>,
/// Set by the backtest runner before each `DataKind::Candle` event to route
/// the candle to the correct per-timeframe history. When `None`, routes to
/// the primary timeframe (or the only timeframe for single-TF backtests).
pub next_candle_interval_hint: Option<u64>,
/// Rolling price windows per interval_secs (most recent last), capped at
/// [`MAX_PRICE_HISTORY`]. Used for RSI and Bollinger Band computations.
pub trade_prices: HashMap<u64, VecDeque<Decimal>>,
/// Running EMAs keyed by (interval_secs, period). Updated incrementally on
/// each candle close for the given timeframe.
pub ema_states: HashMap<(u64, usize), RunningEma>,
/// Candle aggregators for live mode, keyed by interval_secs.
pub candle_aggregators: HashMap<u64, CandleAggregator>,
/// Set to `true` for the single `generate_algo_orders` call immediately after
/// the PRIMARY candle closes. Cleared at the start of the next `process()` call.
pub candle_ready: bool,
/// Rolling history of completed candles (most recent last), capped at [`MAX_PRICE_HISTORY`].
/// Populated on every candle close; used by expression-based conditions
/// ([`Condition::Compare`], [`Condition::CrossOver`], [`Condition::CrossUnder`]).
pub candle_history: VecDeque<CandleRecord>,
/// Candle histories per interval_secs (most recent last), capped at
/// [`MAX_PRICE_HISTORY`]. The primary timeframe and all additional timeframes
/// each have their own entry.
pub candle_histories: HashMap<u64, VecDeque<CandleRecord>>,
}
impl Default for SwymInstrumentData {
@@ -192,11 +201,13 @@ impl Default for SwymInstrumentData {
inner: DefaultInstrumentMarketData::default(),
last_order_time: None,
last_event_time: None,
trade_prices: VecDeque::new(),
primary_interval_secs: None,
next_candle_interval_hint: None,
trade_prices: HashMap::new(),
ema_states: HashMap::new(),
candle_aggregator: None,
candle_aggregators: HashMap::new(),
candle_ready: false,
candle_history: VecDeque::new(),
candle_histories: HashMap::new(),
}
}
}
@@ -218,44 +229,70 @@ impl<InstrumentKey> Processor<&barter_data::event::MarketEvent<InstrumentKey, Da
&mut self,
event: &barter_data::event::MarketEvent<InstrumentKey, DataKind>,
) -> Self::Audit {
// Clear the candle flag at the start of each event — it is only valid
// for the single generate_algo_orders call that immediately follows a close.
// Clear the candle flag — valid only for the single generate_algo_orders
// call immediately following a primary candle close.
self.candle_ready = false;
self.last_event_time = Some(event.time_exchange);
match &event.kind {
DataKind::Trade(trade) => {
if let Some(agg) = &mut self.candle_aggregator {
// Candle-aggregation mode: only push_price on candle close.
if let Some(completed) = agg.push_trade(trade.price, trade.amount, event.time_exchange) {
if self.candle_aggregators.is_empty() {
// Tick mode: update primary timeframe on every trade.
if let Some(price) = Decimal::from_f64_retain(trade.price) {
let primary = self.primary_interval_secs.unwrap_or(0);
self.push_price_for(primary, price);
}
self.inner.process(event);
} else {
// Candle-aggregation mode: feed all aggregators.
let primary = self.primary_interval_secs;
// Collect (interval_secs, completed) without borrowing self.
let completions: Vec<(u64, CompletedCandle)> = self
.candle_aggregators
.iter_mut()
.filter_map(|(&secs, agg)| {
agg.push_trade(trade.price, trade.amount, event.time_exchange)
.map(|c| (secs, c))
})
.collect();
for (secs, completed) in completions {
if let Some(close) = Decimal::from_f64_retain(completed.close) {
self.push_price(close);
self.push_price_for(secs, close);
if let (Some(open), Some(high), Some(low), Some(volume)) = (
Decimal::from_f64_retain(completed.open),
Decimal::from_f64_retain(completed.high),
Decimal::from_f64_retain(completed.low),
Decimal::from_f64_retain(completed.volume),
) {
self.push_candle(CandleRecord { open, high, low, close, volume, open_time: Some(completed.open_time) });
self.push_candle_for(secs, CandleRecord {
open, high, low, close, volume,
open_time: Some(completed.open_time),
});
}
// Only the primary timeframe triggers evaluation.
if primary == Some(secs) {
self.candle_ready = true;
}
self.candle_ready = true;
}
}
// Always update inner from the raw trade for up-to-date price/L1.
self.inner.process(event);
} else {
// Tick mode: update indicators and inner state on every trade.
if let Some(price) = Decimal::from_f64_retain(trade.price) {
self.push_price(price);
}
self.inner.process(event);
}
}
DataKind::Candle(candle) => {
// Pre-formed candle event (candle-based backtest replay).
// Update indicators with the close price and signal a candle close.
// The runner sets `next_candle_interval_hint` to indicate which
// timeframe this candle belongs to.
let hint_secs = self.next_candle_interval_hint
.or(self.primary_interval_secs)
.unwrap_or(0);
let is_primary = self.primary_interval_secs
.map(|p| p == hint_secs)
.unwrap_or(true); // single-TF backward compat
if let Some(close) = Decimal::from_f64_retain(candle.close) {
self.push_price(close);
self.push_price_for(hint_secs, close);
if let (Some(open), Some(high), Some(low), Some(volume)) = (
Decimal::from_f64_retain(candle.open),
@@ -263,21 +300,25 @@ impl<InstrumentKey> Processor<&barter_data::event::MarketEvent<InstrumentKey, Da
Decimal::from_f64_retain(candle.low),
Decimal::from_f64_retain(candle.volume),
) {
self.push_candle(CandleRecord { open, high, low, close, volume, open_time: Some(event.time_exchange) });
self.push_candle_for(hint_secs, CandleRecord {
open, high, low, close, volume,
open_time: Some(event.time_exchange),
});
}
self.inner.last_traded_price =
Some(Timed::new(close, event.time_exchange));
if is_primary {
self.inner.last_traded_price =
Some(Timed::new(close, event.time_exchange));
// Synthesize a symmetric L1 using close as both bid and ask.
let level = Level { price: close, amount: Decimal::ONE };
self.inner.l1 = OrderBookL1 {
last_update_time: event.time_exchange,
best_bid: Some(level),
best_ask: Some(level),
};
let level = Level { price: close, amount: Decimal::ONE };
self.inner.l1 = OrderBookL1 {
last_update_time: event.time_exchange,
best_bid: Some(level),
best_ask: Some(level),
};
self.candle_ready = true;
self.candle_ready = true;
}
}
}
_ => {
@@ -316,40 +357,90 @@ impl InFlightRequestRecorder<ExchangeIndex, InstrumentIndex> for SwymInstrumentD
}
impl SwymInstrumentData {
/// Create an instance configured for live candle-aggregation mode.
///
/// Trades will be accumulated into candles of `interval_secs` duration.
/// `candle_ready` is set on each candle close so that candle-native strategies
/// know when to evaluate their signals.
pub fn with_candle_aggregator(interval_secs: u64) -> Self {
Self {
candle_aggregator: Some(CandleAggregator::new(interval_secs)),
/// Create an instance for multi-timeframe live mode.
/// The primary timeframe drives `candle_ready`; additional timeframes are tracked
/// in parallel.
pub fn with_multi_timeframes(primary_secs: u64, additional: &[u64]) -> Self {
let mut s = Self {
primary_interval_secs: Some(primary_secs),
..Self::default()
};
for &secs in std::iter::once(&primary_secs).chain(additional.iter()) {
s.candle_aggregators.insert(secs, CandleAggregator::new(secs));
s.candle_histories.insert(secs, VecDeque::new());
s.trade_prices.insert(secs, VecDeque::new());
}
s
}
/// Initialise per-timeframe candle history and price history entries for a
/// candle-based backtest (no aggregators needed).
pub fn init_timeframes(&mut self, primary_secs: u64, additional: &[u64]) {
self.primary_interval_secs = Some(primary_secs);
for &secs in std::iter::once(&primary_secs).chain(additional.iter()) {
self.candle_histories.entry(secs).or_insert_with(VecDeque::new);
self.trade_prices.entry(secs).or_insert_with(VecDeque::new);
}
}
fn push_price(&mut self, price: Decimal) {
// Update window-based indicators buffer.
self.trade_prices.push_back(price);
if self.trade_prices.len() > MAX_PRICE_HISTORY {
self.trade_prices.pop_front();
/// Ensure a [`RunningEma`] for `(interval_secs, period)` exists and is being updated.
/// Call during strategy initialisation for each (timeframe, period) pair needed.
pub fn register_ema(&mut self, interval_secs: u64, period: usize) {
self.ema_states
.entry((interval_secs, period))
.or_insert_with(|| RunningEma::new(period));
}
/// Return the candle history for a given interval_secs, or `None` if not initialised.
pub fn candle_history(&self, interval_secs: u64) -> Option<&VecDeque<CandleRecord>> {
self.candle_histories.get(&interval_secs)
}
/// Return the price history for a given interval_secs, or `None` if not initialised.
pub fn trade_price_history(&self, interval_secs: u64) -> Option<&VecDeque<Decimal>> {
self.trade_prices.get(&interval_secs)
}
fn push_price_for(&mut self, interval_secs: u64, price: Decimal) {
let prices = self.trade_prices.entry(interval_secs).or_insert_with(VecDeque::new);
prices.push_back(price);
if prices.len() > MAX_PRICE_HISTORY {
prices.pop_front();
}
// Update all registered running EMAs.
for ema in self.ema_states.values_mut() {
ema.update(price);
// Update all registered EMAs for this timeframe.
for ((secs, _period), ema) in self.ema_states.iter_mut() {
if *secs == interval_secs {
ema.update(price);
}
}
}
/// Ensure a [`RunningEma`] for the given period exists and is being updated.
/// Call this during strategy initialisation for each EMA period needed.
pub fn register_ema(&mut self, period: usize) {
self.ema_states.entry(period).or_insert_with(|| RunningEma::new(period));
fn push_candle_for(&mut self, interval_secs: u64, candle: CandleRecord) {
let history = self.candle_histories.entry(interval_secs).or_insert_with(VecDeque::new);
history.push_back(candle);
if history.len() > MAX_PRICE_HISTORY {
history.pop_front();
}
}
fn push_candle(&mut self, candle: CandleRecord) {
self.candle_history.push_back(candle);
if self.candle_history.len() > MAX_PRICE_HISTORY {
self.candle_history.pop_front();
}
/// Feed a single historical candle into the indicator state without triggering
/// `candle_ready` or updating barter's internal `last_traded_price` / L1 book.
///
/// Called by the backtest runner during the warmup pre-pass (candles before
/// `starts_at`) so that long-period indicators (e.g. SMA(200) on 4h) are fully
/// populated before the first bar of the user-requested backtest window.
#[allow(clippy::too_many_arguments)]
pub fn process_candle_warmup(
&mut self,
interval_secs: u64,
open_time: DateTime<Utc>,
open: Decimal,
high: Decimal,
low: Decimal,
close: Decimal,
volume: Decimal,
) {
self.push_price_for(interval_secs, close);
self.push_candle_for(interval_secs, CandleRecord { open, high, low, close, volume, open_time: Some(open_time) });
}
}

View File

@@ -37,7 +37,7 @@ use swym_dal::models::strategy_config::{ActionSide, Rule};
use crate::strategy::{
SwymState,
audit::{AuditLog, BarAudit},
signal::{EvalCtx, evaluate, evaluate_with_audit},
signal::{EvalCtx, collect_ema_registrations, evaluate, evaluate_with_audit},
};
const STRATEGY_NAME: &str = "rule_based";
@@ -50,8 +50,11 @@ const STRATEGY_NAME: &str = "rule_based";
#[derive(Debug, Clone)]
pub struct RuleStrategy {
pub id: StrategyId,
/// Candle interval in seconds, used to initialise [`CandleAggregator`] in live mode.
/// Primary candle interval in seconds; drives `candle_ready` and strategy cadence.
pub interval_secs: u64,
/// Additional timeframes (interval_secs) referenced by expressions in the rule tree.
/// These are tracked in parallel but do not gate strategy evaluation.
pub additional_interval_secs: Vec<u64>,
pub rules: Vec<Rule>,
/// When `Some`, every candle-close evaluation is recorded here for condition auditing.
/// Uses `Arc<Mutex<>>` for interior mutability through `&self` in `generate_algo_orders`.
@@ -62,34 +65,32 @@ pub struct RuleStrategy {
}
impl RuleStrategy {
pub fn new(rules: Vec<Rule>, interval_secs: u64) -> Self {
pub fn new(rules: Vec<Rule>, interval_secs: u64, additional_interval_secs: Vec<u64>) -> Self {
Self {
id: StrategyId::new(STRATEGY_NAME),
interval_secs,
additional_interval_secs,
rules,
audit_log: None,
bar_counter: Arc::new(AtomicU32::new(0)),
}
}
/// All EMA periods referenced anywhere in the rule tree.
/// All (interval_secs, period) EMA pairs referenced anywhere in the rule tree.
///
/// The executor uses this to pre-register [`RunningEma`] instances before the
/// first candle arrives so that crossover detection has a valid `prev` value.
pub fn ema_periods(&self) -> Vec<usize> {
let mut periods = Vec::new();
/// The executor uses this to pre-register [`RunningEma`] instances keyed by timeframe
/// before the first candle arrives so that crossover detection has a valid `prev` value.
pub fn ema_registrations(&self) -> Vec<(u64, usize)> {
let mut regs = Vec::new();
for rule in &self.rules {
collect_ema_periods_from_rule(rule, &mut periods);
collect_ema_registrations(&rule.when, self.interval_secs, &mut regs);
}
periods.sort_unstable();
periods.dedup();
periods
regs.sort_unstable();
regs.dedup();
regs
}
}
fn collect_ema_periods_from_rule(rule: &Rule, out: &mut Vec<usize>) {
crate::strategy::signal::collect_ema_periods(&rule.when, out);
}
impl AlgoStrategy for RuleStrategy {
type State = SwymState;
@@ -127,6 +128,7 @@ impl AlgoStrategy for RuleStrategy {
.current
.as_ref()
.is_some_and(|p| p.side == Side::Sell),
primary_interval_secs: instrument_state.data.primary_interval_secs.unwrap_or(0),
};
for (rule_index, rule) in self.rules.iter().enumerate() {

View File

@@ -23,7 +23,7 @@ fn apply_count_op(op: CmpOp, fired: usize, count: usize) -> bool {
}
}
use crate::strategy::{indicators, instrument_data::{CandleRecord, SwymInstrumentData}};
use crate::strategy::{indicators, indicators::RunningEma, instrument_data::{CandleRecord, SwymInstrumentData}};
/// Evaluation context for a single instrument at candle-close time.
///
@@ -34,6 +34,35 @@ pub struct EvalCtx<'a> {
pub is_flat: bool,
pub is_long: bool,
pub is_short: bool,
/// The primary candle interval in seconds. Used to resolve expressions that
/// omit the `timeframe` field (they default to the primary timeframe).
pub primary_interval_secs: u64,
}
impl<'a> EvalCtx<'a> {
/// Resolve an optional timeframe string to an interval in seconds.
/// `None` → primary timeframe. Unknown string → 0 (history lookup returns `None`).
fn resolve_interval(&self, timeframe: &Option<String>) -> u64 {
match timeframe {
None => self.primary_interval_secs,
Some(s) => parse_interval_secs(s).unwrap_or(0),
}
}
/// Return the candle history for the given timeframe (primary if `None`).
fn resolve_candle_history(&self, timeframe: &Option<String>) -> Option<&VecDeque<CandleRecord>> {
self.data.candle_history(self.resolve_interval(timeframe))
}
/// Return the trade-price history for the given timeframe (primary if `None`).
fn resolve_trade_prices(&self, timeframe: &Option<String>) -> Option<&VecDeque<Decimal>> {
self.data.trade_price_history(self.resolve_interval(timeframe))
}
/// Return the running EMA state for the given (timeframe, period) pair.
fn resolve_ema_state(&self, timeframe: &Option<String>, period: usize) -> Option<&RunningEma> {
self.data.ema_states.get(&(self.resolve_interval(timeframe), period))
}
}
/// Recursively evaluate a [`Condition`] tree against the current instrument state.
@@ -47,9 +76,9 @@ pub fn evaluate(cond: &Condition, ctx: &EvalCtx<'_>) -> bool {
PositionState::Long => ctx.is_long,
PositionState::Short => ctx.is_short,
},
Condition::EmaCrossover { fast_period, slow_period, direction } => {
let Some(fast) = ctx.data.ema_states.get(fast_period) else { return false; };
let Some(slow) = ctx.data.ema_states.get(slow_period) else { return false; };
Condition::EmaCrossover { fast_period, slow_period, direction, timeframe } => {
let Some(fast) = ctx.resolve_ema_state(timeframe, *fast_period) else { return false; };
let Some(slow) = ctx.resolve_ema_state(timeframe, *slow_period) else { return false; };
match direction {
CrossoverDirection::Above => {
indicators::ema_crossed_above(fast, slow).unwrap_or(false)
@@ -59,8 +88,8 @@ pub fn evaluate(cond: &Condition, ctx: &EvalCtx<'_>) -> bool {
}
}
}
Condition::EmaTrend { period, direction } => {
let Some(ema) = ctx.data.ema_states.get(period) else { return false; };
Condition::EmaTrend { period, direction, timeframe } => {
let Some(ema) = ctx.resolve_ema_state(timeframe, *period) else { return false; };
let Some(current_ema) = ema.current else { return false; };
let Some(price) = ctx.data.price() else { return false; };
match direction {
@@ -68,8 +97,9 @@ pub fn evaluate(cond: &Condition, ctx: &EvalCtx<'_>) -> bool {
TrendDirection::Below => price < current_ema,
}
}
Condition::Rsi { period, threshold, comparison } => {
let Some(value) = indicators::rsi(&ctx.data.trade_prices, *period) else {
Condition::Rsi { period, threshold, comparison, timeframe } => {
let Some(prices) = ctx.resolve_trade_prices(timeframe) else { return false; };
let Some(value) = indicators::rsi(prices, *period) else {
return false;
};
match comparison {
@@ -77,9 +107,10 @@ pub fn evaluate(cond: &Condition, ctx: &EvalCtx<'_>) -> bool {
Comparison::Below => value < *threshold,
}
}
Condition::Bollinger { period, num_std_dev, band } => {
Condition::Bollinger { period, num_std_dev, band, timeframe } => {
let Some(prices) = ctx.resolve_trade_prices(timeframe) else { return false; };
let Some((_sma, upper, lower)) =
indicators::bollinger_bands(&ctx.data.trade_prices, *period, *num_std_dev)
indicators::bollinger_bands(prices, *period, *num_std_dev)
else {
return false;
};
@@ -89,7 +120,7 @@ pub fn evaluate(cond: &Condition, ctx: &EvalCtx<'_>) -> bool {
BollingerBand::BelowLower => price < lower,
}
}
Condition::PriceLevel { price: level, direction } => {
Condition::PriceLevel { price: level, direction, timeframe: _ } => {
let Some(price) = ctx.data.price() else { return false; };
match direction {
TrendDirection::Above => price > *level,
@@ -122,7 +153,8 @@ pub fn evaluate(cond: &Condition, ctx: &EvalCtx<'_>) -> bool {
l_prev >= r_prev && l_now < r_now
}
Condition::EventCount { condition, period, op, count } => {
if ctx.data.candle_history.len() < *period {
let history_len = ctx.resolve_candle_history(&None).map_or(0, |h| h.len());
if history_len < *period {
return false; // warm-up guard
}
let fired = (1..=*period)
@@ -317,9 +349,9 @@ pub fn evaluate_with_audit(
});
result
}
Condition::EmaCrossover { fast_period, slow_period, direction } => {
let fast = ctx.data.ema_states.get(fast_period);
let slow = ctx.data.ema_states.get(slow_period);
Condition::EmaCrossover { fast_period, slow_period, direction, timeframe } => {
let fast = ctx.resolve_ema_state(timeframe, *fast_period);
let slow = ctx.resolve_ema_state(timeframe, *slow_period);
let insufficient = fast.is_none() || slow.is_none()
|| fast.and_then(|e| e.current).is_none()
|| slow.and_then(|e| e.current).is_none();
@@ -342,8 +374,8 @@ pub fn evaluate_with_audit(
});
result
}
Condition::EmaTrend { period, direction } => {
let ema = ctx.data.ema_states.get(period);
Condition::EmaTrend { period, direction, timeframe } => {
let ema = ctx.resolve_ema_state(timeframe, *period);
let ema_val = ema.and_then(|e| e.current);
let price = ctx.data.price();
let insufficient = ema_val.is_none() || price.is_none();
@@ -365,8 +397,9 @@ pub fn evaluate_with_audit(
});
result
}
Condition::Rsi { period, threshold, comparison } => {
let rsi_val = indicators::rsi(&ctx.data.trade_prices, *period);
Condition::Rsi { period, threshold, comparison, timeframe } => {
let prices = ctx.resolve_trade_prices(timeframe);
let rsi_val = prices.and_then(|p| indicators::rsi(p, *period));
let insufficient = rsi_val.is_none();
let result = match rsi_val {
Some(v) => match comparison {
@@ -386,8 +419,9 @@ pub fn evaluate_with_audit(
});
result
}
Condition::Bollinger { period, num_std_dev, band } => {
let bands = indicators::bollinger_bands(&ctx.data.trade_prices, *period, *num_std_dev);
Condition::Bollinger { period, num_std_dev, band, timeframe } => {
let prices = ctx.resolve_trade_prices(timeframe);
let bands = prices.and_then(|p| indicators::bollinger_bands(p, *period, *num_std_dev));
let price = ctx.data.price();
let insufficient = bands.is_none() || price.is_none();
let result = match (&bands, price) {
@@ -412,7 +446,7 @@ pub fn evaluate_with_audit(
});
result
}
Condition::PriceLevel { price: level, direction } => {
Condition::PriceLevel { price: level, direction, timeframe: _ } => {
let price = ctx.data.price();
let result = match price {
Some(p) => match direction {
@@ -433,7 +467,8 @@ pub fn evaluate_with_audit(
result
}
Condition::EventCount { condition, period, op, count } => {
let insufficient = ctx.data.candle_history.len() < *period;
let history_len = ctx.resolve_candle_history(&None).map_or(0, |h| h.len());
let insufficient = history_len < *period;
let fired = if insufficient {
0
} else {
@@ -470,14 +505,15 @@ pub fn eval_expr(expr: &Expr, ctx: &EvalCtx<'_>) -> Option<Decimal> {
fn eval_expr_at_offset(expr: &Expr, ctx: &EvalCtx<'_>, extra: usize) -> Option<Decimal> {
match expr {
Expr::Literal { value } => Some(*value),
Expr::Field { field, offset } => {
Expr::Field { field, offset, timeframe } => {
let total = offset + extra;
let history = &ctx.data.candle_history;
let history = ctx.resolve_candle_history(timeframe)?;
let idx = history.len().checked_sub(1 + total)?;
Some(get_candle_field(history.get(idx)?, *field))
}
Expr::Func { name, field, period, offset, multiplier } => {
eval_func(*name, *field, *period, offset + extra, *multiplier, ctx)
Expr::Func { name, field, period, offset, multiplier, timeframe } => {
let history = ctx.resolve_candle_history(timeframe)?;
eval_func(*name, *field, *period, offset + extra, *multiplier, history)
}
Expr::BinOp { op, left, right } => {
let l = eval_expr_at_offset(left, ctx, extra)?;
@@ -489,7 +525,7 @@ fn eval_expr_at_offset(expr: &Expr, ctx: &EvalCtx<'_>, extra: usize) -> Option<D
ArithOp::Div => if r.is_zero() { None } else { Some(l / r) },
}
}
Expr::ApplyFunc { name, input, period, offset } => {
Expr::ApplyFunc { name, input, period, offset, timeframe: _ } => {
eval_apply_func(*name, input, *period, offset + extra, ctx)
}
Expr::UnaryOp { op, operand } => {
@@ -531,9 +567,8 @@ fn eval_func(
period: usize,
offset: usize,
multiplier: Option<Decimal>,
ctx: &EvalCtx<'_>,
history: &VecDeque<CandleRecord>,
) -> Option<Decimal> {
let history = &ctx.data.candle_history;
match name {
// --- Single-field rolling functions ---
@@ -834,27 +869,6 @@ fn eval_apply_func(
}
}
/// Collect all EMA periods referenced anywhere in a condition tree.
///
/// Used to pre-register [`RunningEma`] instances before the first market event.
pub fn collect_ema_periods(cond: &Condition, out: &mut Vec<usize>) {
match cond {
Condition::AllOf { conditions } => {
conditions.iter().for_each(|sub| collect_ema_periods(sub, out));
}
Condition::AnyOf { conditions } => {
conditions.iter().for_each(|sub| collect_ema_periods(sub, out));
}
Condition::Not { condition } => collect_ema_periods(condition, out),
Condition::EmaCrossover { fast_period, slow_period, .. } => {
out.push(*fast_period);
out.push(*slow_period);
}
Condition::EmaTrend { period, .. } => out.push(*period),
Condition::EventCount { condition, .. } => collect_ema_periods(condition, out),
_ => {}
}
}
/// Parse a candle interval string (e.g. `"1m"`, `"5m"`) into seconds.
///
@@ -871,24 +885,74 @@ pub fn parse_interval_secs(interval: &str) -> Option<u64> {
}
}
/// Convert an interval in seconds back to the canonical string form used by the DB.
///
/// Returns `None` for unrecognised values.
pub fn interval_secs_to_str(secs: u64) -> Option<&'static str> {
match secs {
60 => Some("1m"),
300 => Some("5m"),
900 => Some("15m"),
3_600 => Some("1h"),
14_400 => Some("4h"),
86_400 => Some("1d"),
_ => None,
}
}
/// Collect all (interval_secs, period) EMA registration pairs referenced in a condition tree.
///
/// Used to pre-register [`RunningEma`] instances keyed by timeframe before the first market event.
/// `primary_secs` is substituted for expressions that omit the `timeframe` field.
pub fn collect_ema_registrations(cond: &Condition, primary_secs: u64, out: &mut Vec<(u64, usize)>) {
match cond {
Condition::AllOf { conditions } => {
conditions.iter().for_each(|sub| collect_ema_registrations(sub, primary_secs, out));
}
Condition::AnyOf { conditions } => {
conditions.iter().for_each(|sub| collect_ema_registrations(sub, primary_secs, out));
}
Condition::Not { condition } => collect_ema_registrations(condition, primary_secs, out),
Condition::EmaCrossover { fast_period, slow_period, timeframe, .. } => {
let secs = timeframe.as_ref()
.and_then(|tf| parse_interval_secs(tf))
.unwrap_or(primary_secs);
out.push((secs, *fast_period));
out.push((secs, *slow_period));
}
Condition::EmaTrend { period, timeframe, .. } => {
let secs = timeframe.as_ref()
.and_then(|tf| parse_interval_secs(tf))
.unwrap_or(primary_secs);
out.push((secs, *period));
}
Condition::EventCount { condition, .. } => {
collect_ema_registrations(condition, primary_secs, out);
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
use swym_dal::models::strategy_config::{Condition, CrossoverDirection, PositionState};
use swym_dal::models::strategy_config::{Condition, PositionState};
use crate::strategy::instrument_data::CandleRecord;
const TEST_INTERVAL: u64 = 300; // 5m sentinel used by test helpers
fn flat_ctx(data: &SwymInstrumentData) -> EvalCtx<'_> {
EvalCtx { data, is_flat: true, is_long: false, is_short: false }
EvalCtx { data, is_flat: true, is_long: false, is_short: false, primary_interval_secs: TEST_INTERVAL }
}
fn long_ctx(data: &SwymInstrumentData) -> EvalCtx<'_> {
EvalCtx { data, is_flat: false, is_long: true, is_short: false }
EvalCtx { data, is_flat: false, is_long: true, is_short: false, primary_interval_secs: TEST_INTERVAL }
}
fn make_candle(close: f64) -> CandleRecord {
let c = Decimal::from_f64_retain(close).unwrap();
CandleRecord { open: c, high: c, low: c, close: c, volume: dec!(100) }
CandleRecord { open: c, high: c, low: c, close: c, volume: dec!(100), open_time: None }
}
fn make_candle_ohlcv(open: f64, high: f64, low: f64, close: f64, volume: f64) -> CandleRecord {
@@ -898,6 +962,7 @@ mod tests {
low: Decimal::from_f64_retain(low).unwrap(),
close: Decimal::from_f64_retain(close).unwrap(),
volume: Decimal::from_f64_retain(volume).unwrap(),
open_time: None,
}
}
@@ -952,24 +1017,6 @@ mod tests {
assert_eq!(parse_interval_secs("2m"), None);
}
#[test]
fn collect_ema_periods_nested() {
let cond = Condition::AllOf {
conditions: vec![
Condition::EmaCrossover {
fast_period: 9,
slow_period: 21,
direction: CrossoverDirection::Above,
},
Condition::EmaTrend { period: 50, direction: TrendDirection::Above },
],
};
let mut periods = Vec::new();
collect_ema_periods(&cond, &mut periods);
periods.sort_unstable();
periods.dedup();
assert_eq!(periods, vec![9, 21, 50]);
}
#[test]
fn price_level_above_returns_false_when_no_price() {
@@ -977,6 +1024,7 @@ mod tests {
let cond = Condition::PriceLevel {
price: dec!(50000),
direction: TrendDirection::Above,
timeframe: None,
};
assert!(!evaluate(&cond, &flat_ctx(&data)));
}
@@ -985,9 +1033,8 @@ mod tests {
fn data_with_candles(candles: Vec<CandleRecord>) -> SwymInstrumentData {
let mut data = SwymInstrumentData::default();
for c in candles {
data.candle_history.push_back(c);
}
data.primary_interval_secs = Some(TEST_INTERVAL);
data.candle_histories.insert(TEST_INTERVAL, candles.into_iter().collect());
data
}
@@ -1003,6 +1050,7 @@ mod tests {
period: 3,
offset: 0,
multiplier: None,
timeframe: None,
};
let result = eval_expr(&expr, &ctx).unwrap();
assert_eq!(result, dec!(10));
@@ -1021,6 +1069,7 @@ mod tests {
period: 3,
offset: 0,
multiplier: None,
timeframe: None,
};
let result = eval_expr(&expr, &ctx).unwrap();
// (1 + 4 + 9) / 6 = 14/6
@@ -1038,6 +1087,7 @@ mod tests {
period: 5,
offset: 0,
multiplier: None,
timeframe: None,
};
let result = eval_expr(&expr, &ctx).unwrap();
assert_eq!(result, dec!(15)); // 1+2+3+4+5
@@ -1055,6 +1105,7 @@ mod tests {
period: 14,
offset: 0,
multiplier: None,
timeframe: None,
};
let result = eval_expr(&expr, &ctx).unwrap();
assert_eq!(result, dec!(0));
@@ -1075,6 +1126,7 @@ mod tests {
period: 14,
offset: 0,
multiplier: None,
timeframe: None,
};
let result = eval_expr(&expr, &ctx).unwrap();
assert_eq!(result, dec!(2));
@@ -1092,12 +1144,14 @@ mod tests {
period: 3,
offset: 0,
multiplier: None,
timeframe: None,
};
let expr = Expr::ApplyFunc {
name: FuncName::Ema,
input: Box::new(inner),
period: 3,
offset: 0,
timeframe: None,
};
let result = eval_expr(&expr, &ctx);
assert!(result.is_some(), "expected Some for EMA of SMA with sufficient history");
@@ -1115,12 +1169,14 @@ mod tests {
period: 3,
offset: 0,
multiplier: None,
timeframe: None,
};
let expr = Expr::ApplyFunc {
name: FuncName::Atr, // not valid inside ApplyFunc
input: Box::new(inner),
period: 3,
offset: 0,
timeframe: None,
};
assert!(eval_expr(&expr, &ctx).is_none());
}
@@ -1162,6 +1218,7 @@ mod tests {
period: 10,
offset: 0,
multiplier: Some(dec!(3.0)),
timeframe: None,
};
assert!(eval_expr(&expr, &ctx).is_some());
}
@@ -1180,6 +1237,7 @@ mod tests {
period: 14,
offset: 0,
multiplier: None,
timeframe: None,
};
let result = eval_expr(&expr, &ctx);
assert!(result.is_some());
@@ -1222,9 +1280,10 @@ mod tests {
period: 3,
offset: 0,
multiplier: None,
timeframe: None,
};
let cross_under = Condition::CrossUnder {
left: Expr::Field { field: CandleField::Close, offset: 0 },
left: Expr::Field { field: CandleField::Close, offset: 0, timeframe: None },
right: sma3,
};
@@ -1251,9 +1310,10 @@ mod tests {
period: 5,
offset: 0,
multiplier: None,
timeframe: None,
};
let cross_under = Condition::CrossUnder {
left: Expr::Field { field: CandleField::Close, offset: 0 },
left: Expr::Field { field: CandleField::Close, offset: 0, timeframe: None },
right: sma5,
};
// Should have fired zero times (price always rising, never crosses under SMA).
@@ -1278,7 +1338,7 @@ mod tests {
// "close < 60" was true exactly at the dip bar (offset=4 from end)
let below_60 = Condition::Compare {
left: Expr::Field { field: CandleField::Close, offset: 0 },
left: Expr::Field { field: CandleField::Close, offset: 0, timeframe: None },
op: CmpOp::Lt,
right: Expr::Literal { value: dec!(60) },
};
@@ -1299,7 +1359,7 @@ mod tests {
// "close < 50" never true
let below_50 = Condition::Compare {
left: Expr::Field { field: CandleField::Close, offset: 0 },
left: Expr::Field { field: CandleField::Close, offset: 0, timeframe: None },
op: CmpOp::Lt,
right: Expr::Literal { value: dec!(50) },
};

View File

@@ -2,7 +2,7 @@ use super::{
SwymState,
audit::AuditLog,
rule_strategy::RuleStrategy,
signal::parse_interval_secs,
signal::{interval_secs_to_str, parse_interval_secs},
};
use barter::{
engine::Engine,
@@ -21,7 +21,7 @@ use barter_instrument::{
exchange::{ExchangeId, ExchangeIndex},
instrument::InstrumentIndex,
};
use swym_dal::models::strategy_config::StrategyConfig;
use swym_dal::models::strategy_config::{StrategyConfig, collect_timeframes};
/// Enum-dispatched strategy so the monomorphic Engine can use any configured strategy variant.
#[derive(Debug, Clone)]
@@ -35,17 +35,46 @@ impl SwymStrategy {
match config {
StrategyConfig::Default => Self::Default(DefaultStrategy::default()),
StrategyConfig::RuleBased(params) => {
let interval_secs = parse_interval_secs(&params.candle_interval)
.unwrap_or(60);
Self::RuleBased(RuleStrategy::new(params.rules.clone(), interval_secs))
let primary_secs = parse_interval_secs(&params.candle_interval).unwrap_or(60);
// Walk the rule tree to discover additional timeframes referenced by expressions.
let additional_set = collect_timeframes(params);
let mut additional: Vec<u64> = additional_set
.iter()
.filter_map(|tf| parse_interval_secs(tf))
.collect();
additional.sort_unstable();
Self::RuleBased(RuleStrategy::new(params.rules.clone(), primary_secs, additional))
}
}
}
/// EMA periods this strategy requires, pre-registered before the first market event.
pub fn ema_periods(&self) -> Vec<usize> {
/// All (interval_secs, period) EMA pairs this strategy requires.
pub fn ema_registrations(&self) -> Vec<(u64, usize)> {
match self {
Self::RuleBased(s) => s.ema_periods(),
Self::RuleBased(s) => s.ema_registrations(),
_ => vec![],
}
}
/// All candle interval_secs this strategy references (primary + additional).
pub fn all_interval_secs(&self) -> Vec<u64> {
match self {
Self::RuleBased(s) => {
let mut all = vec![s.interval_secs];
all.extend_from_slice(&s.additional_interval_secs);
all
}
_ => vec![],
}
}
/// Additional timeframe interval strings needed beyond the primary candle interval.
/// Used by the backtest runner to load and interleave multi-interval candle data.
pub fn additional_intervals(&self) -> Vec<&'static str> {
match self {
Self::RuleBased(s) => s.additional_interval_secs.iter()
.filter_map(|&secs| interval_secs_to_str(secs))
.collect(),
_ => vec![],
}
}