Compare commits
10 Commits
fa82c20aee
...
c4f5d60ba9
| Author | SHA1 | Date | |
|---|---|---|---|
|
c4f5d60ba9
|
|||
|
197ac5e659
|
|||
|
e479351d82
|
|||
|
d0706a5d8f
|
|||
|
e2cbd53354
|
|||
|
60cc34d2a2
|
|||
|
ce97e37c29
|
|||
|
86889ac9de
|
|||
|
8da26ebbdb
|
|||
|
5ca566e669
|
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
|
||||
|
||||
37
assets/strategy/bull-market-support-band/readme.md
Normal file
37
assets/strategy/bull-market-support-band/readme.md
Normal 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.
|
||||
56
assets/strategy/bull-market-support-band/v1.json
Normal file
56
assets/strategy/bull-market-support-band/v1.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
69
assets/strategy/bull-market-support-band/v2.json
Normal file
69
assets/strategy/bull-market-support-band/v2.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
44
assets/strategy/buy-2-factors/readme.md
Normal file
44
assets/strategy/buy-2-factors/readme.md
Normal 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.
|
||||
44
assets/strategy/buy-2-factors/v1.json
Normal file
44
assets/strategy/buy-2-factors/v1.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
50
assets/strategy/buy-2-factors/v2.json
Normal file
50
assets/strategy/buy-2-factors/v2.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
35
assets/strategy/emmanuel-mtf/readme.md
Normal file
35
assets/strategy/emmanuel-mtf/readme.md
Normal 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 v1–v8) 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%.
|
||||
329
assets/strategy/emmanuel-mtf/v1.json
Normal file
329
assets/strategy/emmanuel-mtf/v1.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
337
assets/strategy/emmanuel-mtf/v2.json
Normal file
337
assets/strategy/emmanuel-mtf/v2.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
396
assets/strategy/emmanuel-mtf/v3.json
Normal file
396
assets/strategy/emmanuel-mtf/v3.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
413
assets/strategy/emmanuel-mtf/v4.json
Normal file
413
assets/strategy/emmanuel-mtf/v4.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
42
assets/strategy/gaussian-channel/readme.md
Normal file
42
assets/strategy/gaussian-channel/readme.md
Normal 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.
|
||||
125
assets/strategy/gaussian-channel/v2.json
Normal file
125
assets/strategy/gaussian-channel/v2.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
123
assets/strategy/gaussian-channel/v3.json
Normal file
123
assets/strategy/gaussian-channel/v3.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
30
assets/strategy/hodl/readme.md
Normal file
30
assets/strategy/hodl/readme.md
Normal 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.
|
||||
11
assets/strategy/hodl/v1.json
Normal file
11
assets/strategy/hodl/v1.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
38
assets/strategy/hull-suite/readme.md
Normal file
38
assets/strategy/hull-suite/readme.md
Normal 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.
|
||||
129
assets/strategy/hull-suite/v2.json
Normal file
129
assets/strategy/hull-suite/v2.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
40
assets/strategy/ichimoku/readme.md
Normal file
40
assets/strategy/ichimoku/readme.md
Normal 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.
|
||||
82
assets/strategy/ichimoku/v1.json
Normal file
82
assets/strategy/ichimoku/v1.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
128
assets/strategy/ichimoku/v2.json
Normal file
128
assets/strategy/ichimoku/v2.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
37
assets/strategy/momentum-cascade/readme.md
Normal file
37
assets/strategy/momentum-cascade/readme.md
Normal 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.
|
||||
48
assets/strategy/momentum-cascade/v2.json
Normal file
48
assets/strategy/momentum-cascade/v2.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
35
assets/strategy/money-line/readme.md
Normal file
35
assets/strategy/money-line/readme.md
Normal 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.
|
||||
74
assets/strategy/money-line/v2.json
Normal file
74
assets/strategy/money-line/v2.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": {
|
||||
37
assets/strategy/simple-trend/readme.md
Normal file
37
assets/strategy/simple-trend/readme.md
Normal 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.
|
||||
36
assets/strategy/simple-trend/v1.json
Normal file
36
assets/strategy/simple-trend/v1.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
42
assets/strategy/simple-trend/v2.json
Normal file
42
assets/strategy/simple-trend/v2.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
39
assets/strategy/stochastic-keltner/readme.md
Normal file
39
assets/strategy/stochastic-keltner/readme.md
Normal 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.
|
||||
117
assets/strategy/stochastic-keltner/v2.json
Normal file
117
assets/strategy/stochastic-keltner/v2.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
95
assets/strategy/stochastic-keltner/v3.json
Normal file
95
assets/strategy/stochastic-keltner/v3.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
42
assets/strategy/supertrend-fusion/readme.md
Normal file
42
assets/strategy/supertrend-fusion/readme.md
Normal 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.
|
||||
67
assets/strategy/supertrend-fusion/v1.json
Normal file
67
assets/strategy/supertrend-fusion/v1.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
37
assets/strategy/supertrend/readme.md
Normal file
37
assets/strategy/supertrend/readme.md
Normal 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.
|
||||
36
assets/strategy/supertrend/v2.json
Normal file
36
assets/strategy/supertrend/v2.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
42
assets/strategy/supertrend/v3.json
Normal file
42
assets/strategy/supertrend/v3.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
35
assets/strategy/trend-detector/readme.md
Normal file
35
assets/strategy/trend-detector/readme.md
Normal 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.
|
||||
121
assets/strategy/trend-detector/v2.json
Normal file
121
assets/strategy/trend-detector/v2.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ¶ms.rules {
|
||||
collect_from_condition(&rule.when, &mut set);
|
||||
}
|
||||
// Remove the primary interval if it appears — callers only care about *additional* ones.
|
||||
set.remove(¶ms.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 ¶ms.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(¶ms.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(¶ms);
|
||||
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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
15
dashboard/pnpm-lock.yaml
generated
15
dashboard/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
271
dashboard/src/components/CandlestickChart.tsx
Normal file
271
dashboard/src/components/CandlestickChart.tsx
Normal 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 }} />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
125
dashboard/src/utils/extractSmaSpecs.ts
Normal file
125
dashboard/src/utils/extractSmaSpecs.ts
Normal 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');
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) },
|
||||
};
|
||||
|
||||
@@ -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(¶ms.candle_interval)
|
||||
.unwrap_or(60);
|
||||
Self::RuleBased(RuleStrategy::new(params.rules.clone(), interval_secs))
|
||||
let primary_secs = parse_interval_secs(¶ms.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![],
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user