# 03 · Indicator Signals

Indicator-based detectors: moving-average crossovers, momentum oscillators,
volatility/breakout bands, and volume/trend tools. All emit the shared
[`Signal`](01-architecture.md#22-signal-schema) and read from the memoised
[`IndicatorBook`](01-architecture.md#5-indicator-book-shared-memoised).

**Formulas are given exactly** so the engine and the backtester compute
identical numbers. Prefer incremental (O(1) per bar) updates for EMA/RSI/ATR.

---

## 0. Indicator formulas (build these first, once)

| Indicator | Formula |
|-----------|---------|
| **SMA(n)** | mean of last `n` closes |
| **EMA(n)** | `EMA_t = P_t·k + EMA_{t-1}·(1−k)`, `k = 2/(n+1)`; seed with SMA(n) |
| **WMA(n)** | weighted mean, weights `1..n` |
| **RSI(n)** | `RSI = 100 − 100/(1+RS)`, `RS = avgGain/avgLoss` using **Wilder smoothing** (`avg_t = (avg_{t-1}·(n−1)+cur)/n`), default `n=14` |
| **MACD** | `macd = EMA(12) − EMA(26)`; `signal = EMA(9) of macd`; `hist = macd − signal` |
| **Stochastic** | `%K = 100·(close − LL_n)/(HH_n − LL_n)`; `%D = SMA(3) of %K`; `n=14` |
| **Stoch-RSI** | Stochastic formula applied to RSI series |
| **ATR(n)** | Wilder average of `TR = max(high−low, |high−prevClose|, |low−prevClose|)`, `n=14` |
| **Bollinger** | `mid = SMA(20)`; `upper/lower = mid ± mult·σ(20)` (`mult=2`); `%B=(P−lower)/(upper−lower)`; `bandwidth=(upper−lower)/mid` |
| **Keltner** | `mid = EMA(20)`; `upper/lower = mid ± mult·ATR(10)` (`mult≈1.5–2`) |
| **Donchian(n)** | `upper = HH_n`, `lower = LL_n`, `mid = (upper+lower)/2` |
| **ADX/DMI(n)** | from `+DM/−DM`: `+DI = 100·EMA(+DM)/ATR`, `−DI = 100·EMA(−DM)/ATR`; `DX = 100·|+DI−−DI|/(+DI+−DI)`; `ADX = Wilder-EMA(DX)`, `n=14` |
| **CCI(n)** | `(TP − SMA(TP)) / (0.015·meanDev)`, `TP=(H+L+C)/3`, `n=20` |
| **Williams %R(n)** | `−100·(HH_n − close)/(HH_n − LL_n)`, `n=14` |
| **ROC(n)** | `100·(close − close_{t−n})/close_{t−n}` |
| **OBV** | running sum: `+volume` if close↑, `−volume` if close↓ |
| **VWAP** | `Σ(TP·vol)/Σ(vol)` over the session (typical price `TP`) |
| **MFI(n)** | RSI-style using `TP·vol` as "money flow", `n=14` |
| **Supertrend** | bands `= (H+L)/2 ± mult·ATR(n)`; flips trend on close crossing the active band; `n=10, mult=3` |
| **Parabolic SAR** | `SAR_t = SAR_{t-1} + AF·(EP − SAR_{t-1})`, `AF` steps 0.02→0.20 |
| **Ichimoku** | Tenkan=(HH9+LL9)/2, Kijun=(HH26+LL26)/2, SpanA=(Tenkan+Kijun)/2 (+26), SpanB=(HH52+LL52)/2 (+26), Chikou=close(−26) |

A **cross helper** removes repeated boilerplate:

```
crossUp(a, b)   = a[t-1] <= b[t-1] and a[t] > b[t]
crossDown(a, b) = a[t-1] >= b[t-1] and a[t] < b[t]
```

---

## 1. Moving-Average Signals

### 1.1 Golden Cross / Death Cross — trend reversal (the prompt's example)

**Golden Cross:** short MA crosses **above** long MA ⇒ **buy**. **Death Cross:**
short crosses **below** long ⇒ **sell/exit**. Classic pair: SMA(50) × SMA(200)
on the daily; intraday variants use 9×21 EMA.

```
detect_golden_cross(ind, params={fast:50, slow:200}):
  f = ind.sma(params.fast); s = ind.sma(params.slow)
  if crossUp(f, s):   side='long'
  elif crossDown(f, s): side='short'      // "death cross"
  else: return []
  entry = close; stop = recent swing (or entry − k·ATR); target = trailing
  strength = clamp(|f[t]-s[t]| / (k·ATR), 0, 1)     // separation ⇒ conviction
  emit Signal('ma.golden_cross' | 'ma.death_cross', side, evidence:{indicators:{fast:f[t],slow:s[t]}})
```

### 1.2 Price / MA crossover & EMA pullback ("EMA reversal")

- **Price × MA:** buy when price closes above a chosen EMA (e.g. EMA20),
  sell when it closes below. `ma.price_cross`.
- **EMA pullback (the prompt's "EMA reversal"):** in a confirmed uptrend
  (price > EMA200, EMA50 rising), **buy when price dips to and bounces off** a
  faster EMA (e.g. EMA20/EMA50) — enter on the reclaim candle, stop below the
  pullback low. `ma.ema_pullback`. This buys the dip *within* a trend rather
  than chasing.

### 1.3 MA ribbon / Guppy — trend strength

Stack several EMAs (e.g. 8,13,21,34,55). **Aligned & fanning out** = strong
trend; **compressing/tangling** = trend weakening. Use as a *filter* to
allow/deny entries from other detectors. `ma.ribbon`.

### 1.4 VWAP cross & Supertrend flip

- **VWAP cross:** intraday, buy above VWAP / sell below; institutions anchor to
  it. `ma.vwap_cross`.
- **Supertrend:** buy when it flips green (close crosses above the band), sell
  on red flip; the line doubles as a trailing stop. `trend.supertrend`.

---

## 2. Momentum Oscillators

### 2.1 RSI — overbought/oversold + divergence (the prompt's example)

**Base signal:** buy when `RSI < 30` then crosses **back above 30** (oversold
reclaim); sell when `RSI > 70` then crosses back below. Firing on the *reclaim*
(not the first touch) avoids buying into a falling knife.

```
detect_rsi(ind, params={period:14, low:30, high:70}):
  r = ind.rsi(params.period)
  if r[t-1] < params.low and r[t] >= params.low:  emit long  'osc.rsi'
  if r[t-1] > params.high and r[t] <= params.high: emit short 'osc.rsi'
```

**RSI divergence (stronger):** price makes a lower low but RSI makes a higher
low ⇒ bullish divergence (and mirror for bearish). Detect by comparing the last
two price pivots to RSI at those pivots. `osc.rsi_divergence`. Also useful:
**RSI centerline (50)** crosses as a trend filter.

### 2.2 MACD — line cross / zero-line / histogram (the prompt's example)

**Base signal:** buy when the **MACD line crosses above the signal line**; sell
on cross below. Confirmation variants: require the cross to happen **below zero**
(for longs) for mean-reversion, or **above zero** for trend-continuation; or use
the **histogram** flipping sign / a **zero-line** cross.

```
detect_macd(ind, params={fast:12, slow:26, signal:9}):
  m = ind.macd(fast,slow,signal)
  if crossUp(m.macd, m.signal):   emit long  'osc.macd'
  if crossDown(m.macd, m.signal): emit short 'osc.macd'
  // MACD divergence vs price = high-quality reversal signal
```

### 2.3 Stochastic & Stochastic-RSI

`%K` crossing `%D` in the oversold zone (<20) ⇒ buy; in overbought (>80) ⇒ sell.
Stoch-RSI is the more sensitive version. `osc.stochastic`, `osc.stoch_rsi`.

### 2.4 CCI, Williams %R, ROC, Awesome Oscillator, MFI

| Detector | Buy trigger | id |
|----------|-------------|----|
| **CCI(20)** | crosses up through −100 (oversold) | `osc.cci` |
| **Williams %R(14)** | crosses up through −80 | `osc.williams_r` |
| **ROC / Momentum** | crosses up through 0 | `osc.roc` |
| **Awesome Oscillator** | saucer / zero-line cross up | `osc.ao` |
| **MFI(14)** (volume-weighted RSI) | crosses up through 20 | `osc.mfi` |

All follow the same "cross-through a threshold" pattern → reuse `crossUp`/`crossDown`.

---

## 3. Volatility / Breakout Signals

### 3.1 Bollinger Band Bounce (the prompt's example) & Squeeze

- **Bounce (mean-reversion):** buy when price **closes below the lower band then
  closes back inside**; sell on the mirror at the upper band. Fire on the
  *re-entry* close, not the poke. `vol.bb_bounce`.
- **Squeeze / breakout (volatility expansion):** when **bandwidth** hits a
  multi-bar low (bands pinch), a large move is loading; trade the **breakout
  candle** out of the squeeze. `vol.bb_squeeze`.
- **%B / band-ride:** in strong trends price "rides" the upper band — don't fade
  it; use ADX (§4) to tell ranging (fade) from trending (ride).

```
detect_bb_bounce(ind, params={period:20, mult:2}):
  b = ind.bollinger(period, mult)
  if close[t-1] < b.lower[t-1] and close[t] > b.lower[t]:  emit long  'vol.bb_bounce'
  if close[t-1] > b.upper[t-1] and close[t] < b.upper[t]:  emit short 'vol.bb_bounce'
```

### 3.2 TTM Squeeze (Bollinger inside Keltner)

The rigorous squeeze: fires when **Bollinger Bands contract inside the Keltner
Channels** (`bb.upper < kc.upper and bb.lower > kc.lower`) — volatility
compression — then trade the release in the direction of momentum. `vol.ttm_squeeze`.

### 3.3 Keltner & Donchian (Turtle) breakouts

- **Keltner breakout:** close beyond EMA±ATR band ⇒ momentum entry. `vol.keltner`.
- **Donchian / Turtle:** buy on a close above the **N-bar highest high** (e.g.
  20-bar), exit on the 10-bar lowest low — the classic Turtle breakout system.
  `vol.donchian`.

### 3.4 ATR breakout / channel

Enter when price moves more than `k·ATR` beyond the prior close or a reference
level; ATR also **sizes stops** everywhere else (doc 06). `vol.atr_breakout`.

---

## 4. Volume & Trend Signals

### 4.1 Volume Spike (the prompt's example)

Buy/confirm when volume **spikes** far above its average *and* price breaks a
level — volume confirms conviction behind the move.

```
detect_volume_spike(candles, ind, params={lookback:20, mult:2.5}):
  avgVol = SMA(volume, params.lookback)[t]
  if volume[t] >= params.mult * avgVol:
     side = close[t] > open[t] ? 'long' : 'short'
     // only act if it confirms a breakout from another detector / level
     emit Signal('vol.volume_spike', side, strength=clamp(volume[t]/avgVol/5,0,1),
                 evidence:{indicators:{volume:volume[t], avgVol}})
```

> **Use volume as confirmation, not a standalone entry.** Gate breakout/chart
> detectors with "was this bar a volume spike?" for higher win rate.

### 4.2 OBV, Accumulation/Distribution, Chaikin Money Flow

- **OBV trend / divergence:** OBV making new highs confirms price; OBV diverging
  warns of exhaustion. `vol.obv`.
- **CMF / A-D line:** positive = accumulation (buyers), negative = distribution.
  Use as a bias filter. `vol.cmf`.

### 4.3 ADX / DMI — trend strength & +DI/−DI cross

`ADX > 25` = trending (breakout strategies valid); `ADX < 20` = ranging (fade /
mean-reversion valid). **+DI crossing above −DI** with rising ADX ⇒ bullish
trend entry. This is the key **regime filter** for the whole system. `trend.adx`.

### 4.4 Ichimoku Cloud (Kumo)

Multi-signal trend system: buy when price is **above the cloud**, Tenkan crosses
**above** Kijun, and Chikou is above price; mirror for shorts. Cloud thickness =
support/resistance strength. `trend.ichimoku`.

### 4.5 Parabolic SAR & Aroon

- **SAR flip:** dots flip below price ⇒ buy (and they trail the stop). `trend.psar`.
- **Aroon:** Aroon-Up crossing above Aroon-Down ⇒ new uptrend. `trend.aroon`.

---

## 5. Composite / multi-factor strategies

The real edge is **confluence** — requiring several detectors to agree. The
strategy layer (doc 06) subscribes to signals and applies rules like:

- **RSI + MACD:** long only when RSI reclaims 30 **and** MACD line is above
  signal within *k* bars.
- **Trend-filtered mean reversion:** take `vol.bb_bounce` longs **only when**
  `trend.adx < 20` (ranging) and price > EMA200 (higher-timeframe uptrend).
- **Harmonic + oscillator:** take `harmonic.*` entries only when RSI is
  oversold/overbought at the PRZ.
- **Breakout + volume:** take `vol.donchian`/`chart.triangle` breakouts **only**
  when confirmed by `vol.volume_spike`.
- **Triple-screen (Elder):** trend on the higher timeframe, oscillator entry on
  the lower one.

Confluence is expressed as a **rule**, not a new detector — see doc 06 §2.

---

## 6. "Claude task" prompts

> **Claude task — Indicator library**
> Create `engine/indicators/*.ts` implementing every formula in §0 as pure,
> incremental functions, and wire them into `IndicatorBook` (doc 01 §5) with
> memoisation per `(symbol, tf, lastBarTs)`. **Acceptance:** values match a
> reference (TA-Lib / pandas-ta) within 1e-6 on a fixed 500-bar fixture; EMA/RSI/
> ATR update in O(1) per new bar.

> **Claude task — MA & trend detectors**
> Create `engine/detectors/ma.ts` and `engine/detectors/trend.ts` for
> golden/death cross, price_cross, ema_pullback, ribbon, vwap_cross, supertrend,
> adx, ichimoku, psar, aroon. **Acceptance:** on a scripted trending series each
> fires with the correct `side` on the correct bar; `strength` scales with MA
> separation / ADX.

> **Claude task — Oscillator & volatility/volume detectors**
> Create `engine/detectors/osc.ts` and `engine/detectors/vol.ts` for rsi (+
> divergence), macd (+ divergence), stochastic, stoch_rsi, cci, williams_r, roc,
> ao, mfi, bb_bounce, bb_squeeze, ttm_squeeze, keltner, donchian, atr_breakout,
> volume_spike, obv, cmf. All threshold-crossers reuse `crossUp/crossDown`.
> **Acceptance:** unit tests assert oversold-reclaim and cross events fire once
> per event; no repaint on the forming bar.

Signals now exist. Route them to people and orders:
[`04-signal-delivery.md`](04-signal-delivery.md).
