# Plan: Multi-Timeframe Support for Rule-Based Strategies

## Context

Review of the Emmanuel MA source transcript (`assets/reference/transcript-0aCaq-_N3nI.txt`) revealed that the strategy is fundamentally multi-timeframe: establish trend direction on higher timeframes (daily/hourly), then find entries at the 20 SMA on lower timeframes (15m/5m/1m). Our engine evaluates a single candle interval per backtest, so we cannot express this workflow. Adding multi-timeframe (MTF) support is the key missing capability.

### What the transcript reveals vs what we implemented

| Aspect | Emmanuel's actual method | Our v1–v8 implementations |
|---|---|---|
| Timeframes | Daily trend → 15m/5m/1m entry | Single 4h or 1h |
| 200 SMA | Flat S/R level + target | Regime filter (close > 200 SMA) |
| Entry trigger | Consolidation/basing into 20 SMA | Cross_over or proximity check |
| Not-first-touch | After parabolic/extended moves only | General cross_under recency check |
| Instrument | Gapping stocks (trapped-trader dynamics) | 24/7 crypto (no gaps) |

### What this plan addresses

Multi-timeframe expression references in the DSL and execution engine. This enables strategies like: "daily close > daily 200 SMA AND hourly 20 SMA rising AND 5m close crosses above 5m 20 SMA". Consolidation detection and gap dynamics are already expressible with the existing DSL (range contraction, open vs prev close) and don't need new primitives.

## Design

### Core concept

The **primary timeframe** (`candle_interval`) drives evaluation cadence — the strategy evaluates on each primary candle close. **Additional timeframes** are read-only context maintained in parallel. Expressions reference other timeframes via an optional `timeframe` field.

### DSL extension

Add `timeframe: Option<String>` to three `Expr` variants that access candle history:

```json
{ "kind": "func", "name": "sma", "field": "close", "period": 200, "timeframe": "1d" }
{ "kind": "field", "field": "close", "timeframe": "1h" }
```

When `timeframe` is absent/null, the expression uses the primary candle history (backward compatible). The legacy shorthand conditions (`EmaCrossover`, `EmaTrend`, `Rsi`, `Bollinger`, `PriceLevel`) also get `timeframe: Option<String>`.

### Timeframe discovery

A `collect_timeframes(params: &RuleBasedParams) -> HashSet<String>` function walks the rule tree and returns all referenced timeframes. Used by:
- The API to validate candle data coverage for all timeframes at backtest creation
- The executor to initialize per-timeframe data structures

### Data structure changes

`SwymInstrumentData` replaces single-timeframe fields with per-timeframe maps:

| Current | New |
|---|---|
| `candle_history: VecDeque<CandleRecord>` | `candle_histories: HashMap<u64, VecDeque<CandleRecord>>` |
| `candle_aggregator: Option<CandleAggregator>` | `candle_aggregators: HashMap<u64, CandleAggregator>` |
| `ema_states: HashMap<usize, RunningEma>` | `ema_states: HashMap<(u64, usize), RunningEma>` |
| `trade_prices: VecDeque<Decimal>` | `trade_prices: HashMap<u64, VecDeque<Decimal>>` |

`candle_ready: bool` stays as a single flag, gated only on the primary timeframe.

### Backtest candle interleaving

The runner loads candle pages from ALL required intervals, merge-sorts by `close_time`, and feeds them to the engine. A `next_candle_interval_hint: Option<u64>` side-channel on `SwymInstrumentData` tells the `DataKind::Candle` handler which history to update. Only primary-timeframe candle closes set `candle_ready = true`.

### Expression evaluation

`EvalCtx` gains `primary_interval_secs: u64`. A helper `candle_history(&self, timeframe: &Option<String>)` resolves the correct `VecDeque<CandleRecord>` from the per-timeframe map. All `eval_expr`, `eval_func`, and legacy condition evaluators use this helper instead of directly accessing a single candle history.

## Files to modify

| File | Changes |
|---|---|
| `crates/swym-dal/src/models/strategy_config.rs` | Add `timeframe: Option<String>` to `Expr::Field`, `Expr::Func`, `Expr::ApplyFunc`, and legacy condition variants. Add `collect_timeframes()` utility. Add `parse_interval_secs()` helper. |
| `services/paper-executor/src/strategy/instrument_data.rs` | Replace single-timeframe fields with `HashMap`-keyed per-timeframe storage. Add `primary_interval_secs`, `next_candle_interval_hint`. Update `Processor` impl to route candles to correct history. Add `with_multi_timeframes()` constructor. |
| `services/paper-executor/src/strategy/signal.rs` | Add `primary_interval_secs` to `EvalCtx`. Add `candle_history()`, `trade_prices()`, `ema_state()` resolver methods. Update all `eval_expr`, `eval_func`, and legacy condition branches to resolve via timeframe. |
| `services/paper-executor/src/strategy/rule_strategy.rs` | Store `primary_interval_secs` and `additional_intervals`. Pass `primary_interval_secs` into `EvalCtx`. Update `ema_periods()` to return `(u64, usize)` tuples. |
| `services/paper-executor/src/strategy/swym_strategy.rs` | Update `from_config` to extract additional timeframes. Add `all_interval_secs()` method. |
| `services/paper-executor/src/runner.rs` | Multi-interval candle loading with merge-sort interleaving. Set `next_candle_interval_hint` before processing each candle event. Initialize per-timeframe data structures. |
| `services/api/src/handlers/paper_runs.rs` | Validate candle data coverage for all inferred timeframes at backtest creation. |
| `dashboard/src/pages/AddStrategyPage.tsx` | Document the `timeframe` DSL field in the reference panel. |
| `dashboard/src/pages/AddPaperRunPage.tsx` | Check and display candle coverage for all inferred timeframes. |

## Implementation phases

### Phase 1: DSL extension (`strategy_config.rs`)

Add to `Expr::Field`, `Expr::Func`, `Expr::ApplyFunc`:
```rust
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeframe: Option<String>,
```

Add to legacy conditions (`EmaCrossover`, `EmaTrend`, `Rsi`, `Bollinger`, `PriceLevel`):
```rust
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeframe: Option<String>,
```

Add utility functions:
```rust
pub fn parse_interval_secs(interval: &str) -> Option<u64>  // "5m" -> 300, "1h" -> 3600, etc.
pub fn collect_timeframes(params: &RuleBasedParams) -> HashSet<String>  // walk tree, collect unique timeframes
```

Backward compatible: `skip_serializing_if` + `serde(default)` means existing JSON round-trips identically. Content hash stable.

### Phase 2: Multi-timeframe instrument data (`instrument_data.rs`)

Replace flat fields with per-timeframe maps. Key changes:
- `candle_histories: HashMap<u64, VecDeque<CandleRecord>>` (keyed by interval_secs)
- `candle_aggregators: HashMap<u64, CandleAggregator>` (for live mode)
- `ema_states: HashMap<(u64, usize), RunningEma>` (keyed by interval_secs + period)
- `trade_prices: HashMap<u64, VecDeque<Decimal>>`
- `primary_interval_secs: Option<u64>` — which timeframe gates `candle_ready`
- `next_candle_interval_hint: Option<u64>` — set by runner before each candle event

`DataKind::Candle` handler reads `next_candle_interval_hint` to route to correct history. Sets `candle_ready = true` only when hint matches primary. `DataKind::Trade` handler feeds all aggregators; only primary aggregator close sets `candle_ready`.

### Phase 3: Timeframe-aware evaluation (`signal.rs`)

Update `EvalCtx`:
```rust
pub struct EvalCtx<'a> {
    pub data: &'a SwymInstrumentData,
    pub is_flat: bool,
    pub is_long: bool,
    pub is_short: bool,
    pub primary_interval_secs: u64,
}
```

Add resolver methods:
```rust
fn candle_history(&self, timeframe: &Option<String>) -> Option<&VecDeque<CandleRecord>>
fn trade_prices(&self, timeframe: &Option<String>) -> Option<&VecDeque<Decimal>>
fn ema_state(&self, timeframe: &Option<String>, period: usize) -> Option<&RunningEma>
```

Update every branch in `eval_expr_at_offset`, `eval_func`, and legacy condition evaluators to use these resolvers instead of directly accessing `ctx.data.candle_history` / `ctx.data.ema_states` / `ctx.data.trade_prices`.

### Phase 4: Strategy and runner wiring

**`rule_strategy.rs`**: Store `primary_interval_secs` and `additional_intervals_secs: Vec<u64>`. Pass into `EvalCtx`. Update `ema_periods()` → `ema_registrations() -> Vec<(u64, usize)>`.

**`swym_strategy.rs`**: `from_config` calls `collect_timeframes` to discover additional intervals. Add `all_interval_secs()` returning primary + additional.

**`runner.rs`** (backtest path):
1. Get all required intervals from strategy.
2. For each interval, create an `IntervalCursor { interval_secs, last_id, buffer }`.
3. Merge-sort loop: pick candle with smallest `close_time` across all cursors, set `next_candle_interval_hint`, process event. Refill cursor buffers as pages exhaust.
4. Initialize `candle_histories` and `ema_states` for all intervals.

**`runner.rs`** (live path): Initialize multiple `CandleAggregator` instances. Trade processing feeds all aggregators.

### Phase 5: API validation (`paper_runs.rs`)

At backtest creation, after parsing the strategy config:
1. Call `collect_timeframes` to get all referenced intervals.
2. Validate each is in `VALID_CANDLE_INTERVALS`.
3. Check `candle_data_range` coverage for each interval.
4. Return error if any interval lacks sufficient data, listing which intervals are missing.

### Phase 6: Dashboard updates

**`AddStrategyPage.tsx`**: Add `timeframe` to the DSL reference docs, with examples.

**`AddPaperRunPage.tsx`**: When a rule-based strategy is selected, parse its JSON to infer all timeframes. Show per-timeframe coverage indicators. Offer backfill buttons for any missing intervals.

## Example strategy after implementation

```json
{
    "type": "rule_based",
    "candle_interval": "5m",
    "rules": [
        {
            "comment": "Long: daily uptrend + hourly confirmation + 5m pullback bounce",
            "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": "Hourly: 20 SMA rising (current > 5 bars ago)",
                        "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" }
                    },
                    {
                        "comment": "5m: close crosses above 20 SMA (entry trigger on primary TF)",
                        "kind": "cross_over",
                        "left": { "kind": "field", "field": "close" },
                        "right": { "kind": "func", "name": "sma", "field": "close", "period": 20 }
                    }
                ]
            },
            "then": { "side": "buy", "quantity": "0.001" }
        }
    ]
}
```

## Verification

1. `cargo check` — compiles with no errors
2. `cargo clippy` — no new warnings
3. Serde round-trip test: existing strategies without `timeframe` fields serialize/deserialize identically (hash stable)
4. Unit test: `collect_timeframes` correctly extracts `{"1d", "1h"}` from the example above
5. Unit test: multi-timeframe `EvalCtx` resolves correct candle history for each timeframe
6. Integration test: small 2-timeframe backtest (5m + 1h) verifies interleaving order and correct indicator values
7. Deploy to dev, run the example MTF strategy, verify it evaluates correctly
8. `pnpm tsc --noEmit` — dashboard type-checks
