# Analysis API — Field Guide

What every field in the technical-analysis payload means, how it is computed,
and what it must **not** be read as. Written for integrators who build decision
logic on top of the API.

- **Applies to:** `GET /api/v1/market/analysis/:symbol`,
  `GET /api/v1/account/analysis/:symbol` (portal),
  `GET /api/v1/public/analysis/:symbol` (trimmed), the point-in-time payload
  `GET /api/v1/market/analysis/:symbol/at`, and the rows of
  `GET /api/v1/market/analysis/:symbol/replay`.
- **Schema:** `schema_version` **3.1**. Fields new in 3.1 are marked *(3.1)*.
  A server on an older version does not send them; check `schema_version`
  before relying on one.
- **Source of truth:** the Go code in `internal/ta` and `internal/api`. Every
  rule below was checked against it. Where this guide and the code ever
  disagree, the code is right and this guide is a bug.
- **Endpoint reference** (parameters, errors, a full example payload):
  [API.md — Get Technical Analysis](API.md#get-technical-analysis-multi-timeframe).

Nothing in this payload is a probability, a confidence level, a forecast or a
recommendation. See [What we have measured](#8-what-we-have-measured).

---

## Contents

1. [Read this first](#1-read-this-first)
2. [Three readings in one payload](#2-three-readings-in-one-payload)
3. [Timeframes, weights and bars](#3-timeframes-weights-and-bars)
4. [Field reference](#4-field-reference)
5. [Invariants you can rely on](#5-invariants-you-can-rely-on)
6. [Availability: partial vs insufficient](#6-availability-partial-vs-insufficient)
7. [Intended usage — worked examples](#7-intended-usage--worked-examples)
8. [What we have measured](#8-what-we-have-measured)
9. [Schema changelog](#9-schema-changelog)
10. [Where the code lives](#10-where-the-code-lives)

---

## 1. Read this first

Six common misreadings:

1. **`direction` and `consensus` are two different models.** `direction` is the
   pattern model. It is `NEUTRAL` most of the time because no pattern fired.
   `consensus` is the indicator vote. It is what the cross-timeframe `mtf.bias`
   is built from. Rules that mix them test something different from what the
   headline shows.
2. **`agreement` is not a probability, a confidence or a win rate.** It is the
   share of timeframe *weight* whose vote points the same way.
3. **`score` is not net strength.** Opposing pattern weight is not subtracted.
4. **`MIXED` is not one state.** It covers "the timeframes conflict", "the
   timeframes are balanced" and "they lean one way, but not far enough". Use
   `mtf.net` to see the lean.
5. **An unread timeframe still says `direction: "NEUTRAL"` and `score: 0`.**
   Check `status` first. `NEUTRAL` from a timeframe that was never read is not
   a reading.
6. **`targets`, `levels`, `stop` and `risk_reward` are geometry.** They are
   pivot-ladder rungs and ATR distances. They are not forecasts and not trade
   instructions.

---

## 2. Three readings in one payload

The engine runs over the same closed candles in three independent ways. A
fourth, smaller reading adds market structure. They share no scoring and are
never averaged together.

### 2.1 The pattern model (7 detectors)

Seven rule-based detectors look for specific events on each timeframe, for
example "the EMA ribbon is fully stacked" or "the MACD histogram crossed zero
on the last closed bar". Most of the time none of them fires. Their weighted
result is the per-timeframe `direction` and `score`.

- Code: `internal/ta/detectors.go` (detectors), `internal/ta/score.go`
  (scoring).
- Feeds: `timeframes[tf].direction`, `.score`, `.detectors`,
  `.detectors_active`, `.detectors_directional`, `.detectors_total`.
- Feeds nothing cross-timeframe. Since schema 2.0 it does not influence
  `mtf.*` or `verdict.*`.

### 2.2 The indicator vote (14 oscillators + 6 moving averages)

Twenty indicators each vote BUY, SELL or NEUTRAL on every timeframe. The votes
are counted and banded into a five-state label, and the label is collapsed to
a three-state `consensus`.

- Code: `internal/ta/votes.go`.
- Feeds: `timeframes[tf].summary.*` and `timeframes[tf].consensus`.
- Unlike the pattern model, it votes on every timeframe that has enough bars.

### 2.3 The cross-timeframe aggregate

The per-timeframe `consensus` values are combined, weighted by timeframe, into
one bias. This is the headline reading. Every directional statement in the
verdict comes from it.

- Code: `internal/ta/mtf.go` (aggregate), `internal/ta/verdict.go` (verdict).
- Feeds: `mtf.*`, `opinion`, and every `verdict.*` field except `setup`.
  `verdict.short_term` and `verdict.underlying` read the same indicator vote,
  but average the five-state labels per group of timeframes (see
  [4.3](#43-verdict)).

### 2.4 Market structure (1h, ICT-style)

Swing highs and lows on the **1h** series give a structure trend and the latest
break of structure.

- Code: `internal/ta/structure.go`.
- Feeds: `structure` (authenticated endpoints only) and `verdict.setup`.
- Independent of the bias. `setup` can point the other way from `mtf.bias`.

### 2.5 Reference levels (not a model of direction)

Pivot grids from the prior closed day, week and month. They carry no direction
and do not depend on any model above.

- Code: `internal/ta/pivots.go`, `internal/ta/derived.go`,
  `internal/ta/analysis.go`.
- Feeds: `pivots`, `levels`, `targets`, and the `level` / `stop` /
  `risk_reward` geometry inside `verdict`.

### 2.6 Which model owns which field

| Field | Model | Notes |
|---|---|---|
| `timeframes[tf].direction`, `.score` | Pattern | Sparse; `NEUTRAL`/0 usually means nothing fired |
| `timeframes[tf].detectors`, `.detectors_active`, `.detectors_directional`, `.detectors_total` | Pattern | Only fired detectors are listed |
| `timeframes[tf].summary.indicators`, `.summary.moving_averages`, `.summary.overall` | Indicator vote | Counts and five-state labels |
| `timeframes[tf].consensus` | Indicator vote | `summary.overall.label` collapsed to BUY/SELL/NEUTRAL |
| `timeframes[tf].values`, `.indicators_computed`, `.indicators_total` | Raw numbers | No vote; the 36-slot numeric slate |
| `timeframes[tf].status`, `.bars`, `.bars_required`, `.data_through`, `.sufficient` | Availability | Whether the timeframe was read |
| `mtf.bias`, `.agreement`, `.net`, `.alignment` | Aggregate of `consensus` | Weighted across timeframes |
| `mtf.sufficient`, `.coverage`, `completeness` | Availability of the aggregate | Over all nine timeframes |
| `verdict.stance`, `.level`, `.stop`, `.risk_reward`, `.narrative`, `opinion` | Direction from `mtf.bias` | Geometry from the pivot ladders (daily first, then weekly and monthly) and the daily ATR |
| `verdict.short_term`, `.underlying` | Indicator vote | Unweighted mean over a group of timeframes |
| `verdict.setup`, `structure` | 1h market structure | Independent of the bias |
| `verdict.atr` | Raw number | Daily ATR(14) |
| `divergence` | Indicator vote, sub-slates | Weekly MA slate vs daily oscillator slate |
| `pivots`, `levels`, `targets` | Reference levels | No direction |
| `ohlc`, `changes`, `live_price` | Live price | Reconciled with the latest tick |
| `as_of`, `data_age_seconds`, `is_stale`, `market_closed` | Data freshness | Set per request by the API layer |

---

## 3. Timeframes, weights and bars

The engine scores nine timeframes: `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d`,
`1w`, `1mo`. The list and the weights are in `internal/api/analysis_service.go`
(`analysisOrder`) and `internal/ta/mtf.go` (`tfWeight`).

| Timeframe | Weight in `mtf` | Share of full weight |
|---|---|---|
| `1m` | 0.25 | 2.0% |
| `5m` | 0.35 | 2.7% |
| `15m` | 0.50 | 3.9% |
| `30m` | 0.65 | 5.1% |
| `1h` | 1.00 | 7.8% |
| `4h` | 1.50 | 11.8% |
| `1d` | 2.50 | 19.6% |
| `1w` | 3.00 | 23.5% |
| `1mo` | 3.00 | 23.5% |
| **Total** | **12.75** | 100% |

The four sub-hour timeframes together weigh 1.75. They can move `agreement` but
cannot outvote the 1h-and-above timeframes.

**Bars.** For each timeframe the API fetches the newest 300 bars. The engine
drops the newest bar while it is still forming, so nothing repaints. A
timeframe is scored only with at least **30 closed bars** (**60** for `1mo`).
With fewer it is not read (see `status`).

**Which timeframes you see.** The engine always scores all nine. The payload
may show fewer:

- `/public/analysis/:symbol` shows `1d` and `1w` only, and drops `structure`
  and `gaps`.
- A plan with a minimum timeframe (`min_tf_minutes` > 1) does not see the
  timeframes below it.

In both cases `mtf.sufficient`, `mtf.bias`, `mtf.agreement`, `mtf.net`,
`mtf.coverage`, `completeness` and the whole `verdict` are still computed over
**all nine** timeframes. `mtf.alignment` and `opinion` are rewritten to name
only the timeframes shown. So a trimmed payload can carry a bias built partly
from timeframes it does not show you.

**Caching.** The timeframe scoring (every `timeframes` entry, `mtf`,
`divergence`, pivots, structure) is cached for up to 90 seconds and rebuilt
early when the newest 1h or 1d bar changes. Everything that depends on the
price is recomputed on every request, cache hit or not: `live_price`,
`levels`, `targets`, the verdict's `level`, `stop` and `risk_reward`,
`opinion`, `ohlc`, `changes` and the freshness fields. A
result with any timeframe `unavailable` is never cached. The HTTP response
carries `Cache-Control: private, max-age=30` (`/market`, `/account`),
`public, max-age=60` (`/public`) or `private, max-age=300` (`/at`, `/replay`).

---

## 4. Field reference

Each entry gives **what it is**, **how it is computed**, its **values**, and
**what it is not**.

**Replay series rows are a condensed shape.** The fields they share mean the
same thing, but a row of `/analysis/:symbol/replay` is not the live payload
(`internal/api/analysis_replay_point.go`). Adapt rules written for the live
payload:

| In the live payload | In a replay row |
|---|---|
| `timeframes[tf].summary.overall.label` | `timeframes[tf].vote` |
| `timeframes[tf].detectors` (`null` when none fired; full entries) | `timeframes[tf].detectors` (omitted when none fired; `name` and `direction` only) |
| `status`, `consensus`, `direction`, `score`, `data_through` | Same names, same meaning |
| `sufficient`, `bars`, `bars_required`, `summary`, `values`, `indicators_*`, `detectors_*` | Not present |
| `mtf.sufficient`, `bias`, `agreement`, `net`, `coverage` | Same names, same meaning |
| `mtf.alignment` | Not present |
| `verdict.short_term`, `underlying`, `stance`, `setup` | Same names, same meaning |
| `verdict.level`, `stop`, `atr`, `risk_reward`, `narrative`, `targets`, `levels`, `pivots` | Not present; call `/analysis/:symbol/at?time=<row t>` for the full payload at that instant |
| `live_price` | `price` (close of the newest closed bar, bid) |

The single-instant `/analysis/:symbol/at` returns the full live shape, minus
`ohlc` and `changes`, plus a `replay` block.

### 4.1 Per timeframe — `timeframes[tf]`

#### `status` *(3.1)*

- **What it is:** whether this timeframe was read, and if not, why.
- **How:** `ok` when the timeframe had at least `bars_required` closed bars.
  `insufficient_history` when bars were fetched but there were too few.
  `unavailable` when the fetch itself failed (a storage error). The engine
  sets the first two (`internal/ta/analysis.go`); the API layer sets
  `unavailable` (`internal/api/analysis_service.go`, `markUnavailable`).
- **Values:** `ok` | `insufficient_history` | `unavailable`.
- **Not:** a market state. `insufficient_history` is structural: a young
  instrument, a thin backfill, or a replay instant before history begins. It
  will not fix itself in the next minute. `unavailable` is transient: retry.

#### `bars`, `bars_required` *(3.1)*

- **What:** closed bars the timeframe had, and closed bars it needs.
- **How:** `bars` counts closed bars after the forming bar is dropped (at most
  300). `bars_required` is 30, or 60 for `1mo`.
- **Values:** integers. `bars` is 0 for an `unavailable` timeframe.
- **Not:** the bars each indicator used. Individual indicators need more:
  `trend.ema.stack` needs 200 bars, `bb.squeeze` 70, MACD 35. A timeframe with
  `status: ok` and 45 bars can never fire `trend.ema.stack`.

#### `data_through` *(3.1)*

- **What:** the close time of the newest closed bar behind this timeframe's
  reading. It tells you which moment the reading describes.
- **How:** the newest closed bar's close. Intraday bars close their length
  after they open. Daily, weekly and monthly bars open at the broker's
  midnight and close at the next broker midnight a day, a week or a calendar
  month on — so in UTC a daily bar can be 23 or 25 hours long across a
  daylight-saving change, and on the ORBEX feed (Europe/Athens) daily bars
  close at 21:00Z in summer and 22:00Z in winter.
- **Values:** RFC 3339 UTC. Absent when the timeframe has no bars.
- **Not:** when the analysis was computed, and not the time of the latest
  price. On a `1w` reading it can be days old while the market is live; that
  is expected.

#### `sufficient`

- **What:** the pre-3.1 boolean form of `status`.
- **How:** `true` exactly when `status` is `ok`.
- **Not:** anything more. Prefer `status`, which says why.

#### `direction`

- **What:** the pattern model's side for this timeframe.
- **How:** see `score`. It is the side whose adjusted weight leads.
- **Values:** `BUY` | `SELL` | `NEUTRAL`.
- **Not:**
  - **Not the indicator vote.** That is `consensus`. The two disagree often.
    In the API.md example, `1h` has `direction: "NEUTRAL"` and
    `consensus: "BUY"`, and `1m` has `direction: "BUY"` and
    `consensus: "SELL"`.
  - **`NEUTRAL` usually means "no detector fired".** It does not usually mean
    "balanced". It can mean three things:
    1. nothing fired (`detectors_active: 0`);
    2. only non-directional detectors fired (`bb.squeeze`, `vol.regime`), so
       `detectors_directional: 0`;
    3. BUY and SELL weight tied exactly — for example `macd.cross` BUY against
       `rsi.cross.reversal` SELL, two equal hits in the `momentum` group that
       cancel. Both stay listed with their own directions, so this case shows
       `detectors_directional: 2` beside `direction: "NEUTRAL"`.

    Use `detectors_active` and `detectors_directional` to tell them apart.
  - **Not an input to `mtf.bias`** since schema 2.0.
  - For an unread timeframe (`status` ≠ `ok`) it is always `NEUTRAL`. Treat
    it as "no reading", not as a neutral reading.

#### `score`

- **What:** how much pattern weight sits on the leading side, on a 0–10
  scale.
- **How** (`internal/ta/score.go`):
  1. Take the detectors that fired.
  2. Within a collinearity group (`trend`, `momentum`, `stretch`) count only
     the heaviest hit. If two hits in a group have equal weight and opposite
     directions, the group counts as NEUTRAL weight.
  3. Sum BUY, SELL and NEUTRAL weight.
  4. Share the NEUTRAL weight between BUY and SELL in proportion to their size:
     `buyAdj = buy + neutral × buy / (buy + sell)`, and the same for sell.
  5. `direction` is the larger side; a tie, or no BUY/SELL weight, is
     `NEUTRAL`.
  6. `score = min(10, lead / 5.0 × 10)`, rounded to 2 decimals, where `lead`
     is the larger adjusted side. It is 0 when `direction` is `NEUTRAL`.
- **Values:** 0, or 2.0 to 10.0. The smallest non-zero score is 2.0 (one
  `rsi.extreme.state` hit, weight 1.0).
- **Not:**
  - **Not net strength.** The losing side is not subtracted. With no neutral
    detector firing, BUY 2.5 against SELL 1.5 scores 5.0, exactly like an
    unopposed BUY 2.5. (Opposition only lowers the score through step 4, when
    a neutral detector also fired: BUY 2.5 + SELL 1.5 + a 0.75 squeeze gives
    5.94; unopposed BUY 2.5 + the squeeze gives 6.5.)
  - **Not the sum of the listed weights.** Grouped hits are listed but only
    the heaviest counts. In the API.md example, `1m` lists
    `rsi.extreme.state` (1.0) and `mr.stretch` (1.5); both are in `stretch`,
    so the score is 1.5 / 5 × 10 = 3.
  - **Not a probability or a confidence.** It has not been calibrated against
    outcomes.
  - **Not comparable across timeframes as "strength".** Detectors need
    different amounts of history, so what can fire differs by timeframe.

#### `detectors`

- **What:** the pattern detectors that fired on this timeframe.
- **How:** every detector is evaluated; only hits are listed. Each entry has
  `name`, `direction`, `weight` (the detector's nominal weight), `hit`
  (always `true` here) and `group` (`trend`, `momentum`, `stretch`, or `""`
  for independent detectors).
- **The seven detectors** (`internal/ta/detectors.go`):

  | Name | Group | Weight | Fires when | Needs bars |
  |---|---|---|---|---|
  | `trend.ema.stack` | trend | 2.5 | EMA20 > 50 > 100 > 200 and price > EMA20 (BUY); the exact inverse (SELL) | 200 |
  | `macd.cross` | momentum | 1.5 | MACD histogram crosses zero on the last closed bar | 36 |
  | `rsi.cross.reversal` | momentum | 1.5 | RSI(14) crosses back up through 30 (BUY) or down through 70 (SELL) | 16 |
  | `rsi.extreme.state` | stretch | 1.0 | RSI(14) ≤ 30 (BUY) or ≥ 70 (SELL); suppressed when ADX(14) > 30 | 15 |
  | `mr.stretch` | stretch | 1.5 | Close ≤ lower Bollinger band and RSI < 35 (BUY), or ≥ upper band and RSI > 65 (SELL); suppressed when ADX(14) > 30 | 25 |
  | `bb.squeeze` | — | 0.75 | Bollinger bandwidth in the bottom quarter of the last 50 bars; always NEUTRAL | 70 |
  | `vol.regime` | — | 0.5 | ATR(14) now vs 14 bars ago, ratio < 0.7 or > 1.4; always NEUTRAL | 44 |

- **Values:** an array, or **`null`** when nothing fired. It is `null`, not
  `[]`.
- **Not:** the full detector list. A detector that did not fire is absent, not
  listed with `hit: false`.

#### `detectors_active`, `detectors_directional`, `detectors_total`

- **What:** how many detectors fired; how many of those carried a BUY or SELL;
  how many were evaluated.
- **How:** `detectors_active` equals the length of `detectors`.
  `detectors_directional` counts the entries whose `direction` is not
  `NEUTRAL`. `detectors_total` is 7 when `status` is `ok`, and 0 otherwise.
- **Not:** a quality score. Two of the seven detectors can never carry a
  direction, so `detectors_active: 1, detectors_directional: 0` is a timeframe
  where the model saw nothing directional.

#### `summary`

- **What:** the indicator vote tallies. Three blocks: `indicators` (14
  oscillators), `moving_averages` (6 moving averages) and `overall` (the two
  combined).
- **How** (`internal/ta/votes.go`). Each indicator votes once, all with equal
  weight. An indicator that cannot be computed from the available bars is left
  out, not counted as NEUTRAL.
  - Reversal oscillators vote only at an extreme that is turning:
    RSI(14) at ≤ 30 and rising is BUY, at ≥ 70 and falling is SELL.
    Stochastic %K(14) uses 20/80, CCI(20) −100/100, Williams %R(14) −80/−20.
    Anything else is NEUTRAL.
  - Sign votes: ROC(10), the MACD(12,26,9) histogram, TRIX(9) and Coppock are
    BUY when positive and SELL when negative.
  - ADX/DMI(14): BUY when ADX > 20 and +DI > −DI; SELL when ADX > 20 and
    −DI > +DI.
  - Aroon(14) up vs down, Vortex(14) VI+ vs VI−, the Parabolic SAR side and
    the Supertrend(10,3) side vote in the direction they point.
  - Bollinger(20,2) votes against the move: BUY at or below the lower band,
    SELL at or above the upper band.
  - Moving averages: last close above SMA/EMA 20, 50 and 200 is BUY, below is
    SELL.
  - Labels: `share = (buy − sell) / (buy + sell + neutral)`.
    `share ≥ 0.5` is `STRONG_BUY`, `> 0.1` is `BUY`, `≤ −0.5` is
    `STRONG_SELL`, `< −0.1` is `SELL`, anything else is `NEUTRAL`.
    `overall` sums the two slates' counts and applies the same bands. It is
    not a combination of the two labels.
  - Each slate has `rows` (`name`, `value`, `signal`). `overall.rows` is
    `null`. A row's `value` is the number the vote read. For a few rows that
    is not the indicator itself: the Bollinger row shows the close, the Aroon
    row shows up − down, and the Vortex row shows VI+ − VI−.
- **Values:** counts are integers; labels are `STRONG_BUY` | `BUY` |
  `NEUTRAL` | `SELL` | `STRONG_SELL`. Absent when `status` ≠ `ok`.
- **Not:**
  - **Not 20 independent opinions.** Fifteen of the twenty voters follow trend
    or momentum (the six moving averages, MACD, ROC, TRIX, Coppock, ADX/DMI,
    Aroon, Vortex, SAR, Supertrend). The four reversal oscillators vote
    NEUTRAL unless at an extreme and turning. So a lopsided count mostly
    restates one fact: price has been moving that way.
  - **Not weighted.** No indicator counts more than another.

#### `consensus` *(2.0)*

- **What:** the indicator vote for this timeframe as one direction. This is
  the value the cross-timeframe aggregate reads.
- **How:** `summary.overall.label` collapsed: `STRONG_BUY` and `BUY` become
  `BUY`; `STRONG_SELL` and `SELL` become `SELL`; `NEUTRAL` stays `NEUTRAL`.
- **Values:** `BUY` | `SELL` | `NEUTRAL` when `status` is `ok`; the **empty
  string** when it is not.
- **Not:**
  - **Not the pattern model.** See `direction`.
  - **Not a strength.** `STRONG_BUY` and `BUY` both become `BUY`. The
    conviction is in `summary.overall.label`.
  - **Its `NEUTRAL` means "balanced".** On a read timeframe the slate always
    votes, so a `NEUTRAL` consensus is a real, balanced reading. This is the
    opposite of `direction`, where `NEUTRAL` usually means silence.

#### `values`

- **What:** raw numeric indicator readings for this timeframe, 36 slots: ATR,
  Bollinger, ADX/DMI, Aroon, Vortex, Supertrend, Parabolic SAR, MACD, TRIX,
  Coppock, RSI, Stochastic, CCI, Williams %R, ROC, CMO, SMA/EMA 20/50/200,
  DEMA/TEMA 20 and VWAP.
- **How:** `internal/ta/indicator_values.go`, on closed bars only. A slot that
  cannot be computed is omitted, never sent as 0.
- **Not:** votes. Some values (CMO, DEMA, TEMA, VWAP) are not in the vote at
  all.

#### `indicators_computed`, `indicators_total`

- **What:** how many of the 36 `values` slots were computed.
- **How:** a count of non-empty slots. `indicators_total` is 36.
- **Values:** integers. Both are omitted when `status` ≠ `ok`.
- **Not:** the size of the vote. The vote has 20 voters; see the counts in
  `summary`. A low `indicators_computed` does mean a short history, which also
  thins the vote.

### 4.2 Cross-timeframe — `mtf`

All of `mtf` is computed in `AggregateMTF` (`internal/ta/mtf.go`) from the
per-timeframe `consensus` of every read timeframe.

#### `mtf.sufficient` *(1.4)*

- **What:** whether any timeframe at all was read.
- **How:** `true` when at least one of the nine timeframes has `status: ok`.
- **Not:** "all timeframes were read". That is `completeness: "full"`.
  `sufficient: true` with `coverage: 0.2` is possible.

#### `mtf.bias`

- **What:** the headline cross-timeframe reading.
- **How:** each read timeframe votes +weight (`consensus` BUY), −weight (SELL)
  or 0 (NEUTRAL). Unread timeframes do not vote. `agreement` is computed and
  rounded first; then `BULLISH` if the net vote is positive and
  `agreement ≥ 0.60`, `BEARISH` if negative and `agreement ≥ 0.60`, otherwise
  `MIXED`.
- **Values:** `BULLISH` | `BEARISH` | `MIXED`, or the **empty string** when
  `mtf.sufficient` is `false`.
- **Not:**
  - **Not a forecast.** It describes the indicator vote across timeframes now.
  - **Not debounced.** It flips as soon as `agreement` crosses 0.60 either
    way. One 30m timeframe changing its vote moves `agreement` by about 0.05.
    If you act on changes, debounce them yourself (example
    [7.5](#75-acting-on-changes-debounce)).
  - **`MIXED` is not a single state.** It covers conflicting timeframes, a
    fully balanced vote (every timeframe `NEUTRAL`: `agreement` 0) and a lean
    below the threshold (`agreement` 0.59). Read `net` for the lean.
  - **`""` is not `MIXED`.** Empty means nothing was read.
  - Since schema 2.0 it is **not** built from `direction`.

#### `mtf.agreement`

- **What:** how much of the read timeframe weight points the same way.
- **How:** `|Σ weight × vote| / Σ weight of read timeframes`, rounded to 2
  decimals, where vote is +1, −1 or 0. `NEUTRAL` timeframes stay in the
  denominator, so they dilute it.
- **Values:** 0.00–1.00. 0 when nothing was read.
- **Not:**
  - **Not a probability, a confidence level, a hit rate or an expected
    accuracy.** `0.86` means "timeframes carrying 86% of the read weight vote
    the same way". It says nothing about whether that way is right.
  - **Not coverage-aware.** It is computed only over what was read. A reading
    from `1d` and `1w` alone, both BUY, has `agreement: 1.00` at
    `coverage: 0.43`.
  - **Not a count of independent confirmations.** Timeframes share price
    history: a daily bar contains its hours. Agreement between them is
    expected when price has trended.

#### `mtf.net` *(3.1)*

- **What:** `agreement` with its sign.
- **How:** `Σ weight × vote / Σ weight of read timeframes`, rounded to 2
  decimals.
- **Values:** −1.00 to +1.00. Positive leans BUY, negative leans SELL.
  `|net|` always equals `agreement`.
- **Not:** a separate model. It exists so a `MIXED` bias still shows which way
  the timeframes lean.

#### `mtf.coverage` *(3.1)*

- **What:** the share of the full timeframe weight (12.75) that was read.
- **How:** `Σ weight of read timeframes / 12.75`, rounded to 2 decimals.
- **Values:** 0.00–1.00. 1.00 exactly when every timeframe is `ok`. Missing
  only `1mo` (or only `1w`) gives 0.76. Missing both gives 0.53.
- **Not:** a quality score of the reading. It only says how much of the
  intended input was there.

#### `mtf.alignment`

- **What:** the votes the bias was built from, as text.
- **How:** `"<tf> <consensus>"` for each read timeframe, in order,
  comma-separated. On a trimmed payload it lists only the visible timeframes.
- **Values:** e.g. `"1d BUY, 1w NEUTRAL"`. Empty when nothing visible was read.
- **Not:** a list of `direction` values (it was before 2.0). Not something to
  parse: use `timeframes[tf].consensus`.

### 4.3 `verdict`

The verdict is present exactly when `mtf.sufficient` is `true`. It is built in
`buildVerdict` (`internal/ta/verdict.go`). Its one direction — the "lean" — is
`mtf.bias`: `BULLISH`, `BEARISH`, or `NEUTRAL` for `MIXED`.

#### `verdict.short_term`, `verdict.underlying`

- **What:** trend labels for two groups of timeframes.
- **How:** `short_term` reads `15m`, `30m`, `1h` and `4h`; `underlying` reads
  `1d`, `1w` and `1mo`. `1m` and `5m` are excluded. Each read timeframe gives
  its `summary.overall.label` a value: `STRONG_BUY` +2, `BUY` +1, `NEUTRAL` 0,
  `SELL` −1, `STRONG_SELL` −2. The plain average (no timeframe weights) is
  banded: ≥ 1.5 `STRONGLY BULLISH`; ≥ 0.75 `BULLISH`; > 0.25
  `SLIGHTLY BULLISH`; ≤ −1.5 `STRONGLY BEARISH`; ≤ −0.75 `BEARISH`;
  < −0.25 `SLIGHTLY BEARISH`; otherwise `NEUTRAL`.
- **Values:** the seven labels above.
- **Not:**
  - **Not the bias.** They use the same indicator vote but a different
    aggregation (five bands, unweighted, grouped).
    `underlying: "SLIGHTLY BULLISH"` beside `bias: "BEARISH"` is possible and
    not an error.
  - **`NEUTRAL` can mean "no timeframe in this group was read".** Check the
    group's `status` values.

#### `verdict.stance`, `verdict.level`

- **What:** the bias restated against a price line.
- **How:** `BULLISH` → `"BULLISH ABOVE"` with `level` = the nearest daily
  support; `BEARISH` → `"BEARISH UNDER"` with `level` = the nearest daily
  resistance; `MIXED` → `"NEUTRAL AROUND"` with `level` = the nearest support.
  When price has left the daily ladder on the side the stance needs (below S3
  for a bullish stance, above R3 for a bearish one), `level` is the nearest
  weekly or monthly floor level on that side. If no ladder has a level there
  (price beyond every monthly support, or no weekly/monthly grid yet), `level`
  is one daily ATR from price on that side. A directional `level` is always on
  the correct side of price: below it for `BULLISH ABOVE`, above it for
  `BEARISH UNDER`. Only with no pivot level and no ATR on that side is the
  stance `"NEUTRAL AROUND"` whatever the bias. A neutral stance anchors to the
  nearest daily support, else the daily pivot point; with neither, `level` is 0.
  When the line comes from a wider ladder or the ATR, `levels.nearest_support`
  (or `nearest_resistance`) is 0 beside it: that field is the daily ladder
  only.
- **Values:** the three strings above; `level` is a price or 0.
- **Not:** a trigger or an order level. "BULLISH ABOVE 65,703" means "the
  bias is bullish and this is the nearest support below price". It does not
  claim the bias holds only above it.

#### `verdict.stop`, `verdict.risk_reward`

- **What:** an invalidation distance and a reward-to-risk ratio for the
  current bias.
- **How:** only when the lean is `BULLISH` or `BEARISH`, `atr` > 0, and a
  first target exists on that side. For `BULLISH`:
  `stop = level − 0.5 × atr` and
  `risk_reward = (targets.upside[0] − price) / (price − stop)`.
  `BEARISH` mirrors it with the nearest resistance and `targets.downside[0]`.
  Both are omitted if price is already beyond `stop`.
- **Values:** a price, and a positive ratio. Both omitted otherwise.
- **Not:**
  - **Not a recommendation** to trade, and not a suggested stop.
  - **Not an expected value.** The ratio is two distances. It says nothing
    about how likely either level is to be reached. It is often below 1 (the
    API.md example has 0.62).
  - It is recomputed from the current price on every request, so it always
    matches `live_price` (see [Caching](#3-timeframes-weights-and-bars)).

#### `verdict.atr`

- **What:** ATR(14) of the closed **daily** bars, whatever the timeframe of
  interest.
- **Values:** a price distance. Omitted when it cannot be computed.
- **Not:** a volatility forecast.

#### `verdict.setup`

- **What:** the latest break of market structure on the **1h** series.
- **How** (`internal/ta/structure.go`). Swings use a 2-bar fractal. The trend
  is `BULLISH` for higher highs and higher lows, `BEARISH` for lower highs and
  lower lows, else `RANGE`. A close above the last swing high is a bullish
  break; below the last swing low is a bearish break. A break against the trend
  is an `MSS`; any other break is a `BOS`. The label:
  - `"<dir> BOS — continuation"` when the trend is `BULLISH` or `BEARISH`;
  - `"<dir> BOS — range break"` when the trend is `RANGE`;
  - `"<dir> MSS — possible reversal"`.
- **Values:** one of those strings, or omitted when there is no break.
- **Not:** filtered by the bias. `"BEARISH BOS — continuation"` under
  `bias: "BULLISH"` is possible. It is also not the daily structure.

#### `verdict.narrative`

- **What:** a fixed-template paragraph built from `underlying`, `short_term`,
  the lean, `level` and the first two targets. Prices use thousands
  separators, with 2 decimals at 100 and above, else 5.
- **Not:** analysis beyond the fields above, and not advice. For a `MIXED`
  bias it always says the timeframes "do not agree", including when they are
  all balanced.

### 4.4 Top level

#### `completeness` *(3.1)*

- **What:** one field to gate on before reading anything else.
- **How:** `insufficient` when `mtf.sufficient` is `false`; `full` when all
  nine timeframes are `ok`; otherwise `partial`. Computed over all nine, shown
  or not.
- **Values:** `full` | `partial` | `insufficient`.
- **Not:** a quality rating. `partial` with only `1m` missing is almost the
  same reading as `full`. Use `mtf.coverage` for how much is missing.

#### `opinion`

- **What:** a one-line summary of `mtf.bias` and `mtf.alignment`.
- **Values:** `"Bullish bias across timeframes (…)."`,
  `"Bearish bias across timeframes (…)."`,
  `"Mixed timeframes — no clear bias (…)."`, or
  `"Not enough price history to form a reading on this instrument."`. On a
  trimmed payload whose visible timeframes were not read but others were, it
  says the bias was read from timeframes not shown.
- **Not:** anything to parse. Use `mtf`.

#### `divergence`

- **What:** a flag for the weekly trend and the daily momentum pointing
  opposite ways.
- **How** (`internal/ta/divergence.go`): the weekly moving-average lean
  (`1w` `summary.moving_averages` buy − sell) against the daily oscillator lean
  (`1d` `summary.indicators` buy − sell). Present only when the weekly lean is
  non-zero, the daily lean is at least 2 votes either way, and the signs
  differ. `kind` is `PULLBACK` (weekly up, daily down) or `BOUNCE` (weekly
  down, daily up). `trend` and `momentum` carry the two slate labels.
- **Values:** `{"present": false}`, or the full block.
- **Not:** a reversal signal, and not part of `mtf.bias`. The `note` text says
  "more often a corrective pullback than a confirmed reversal". That describes
  the pattern; it is not a measured frequency.

#### `levels`

- **What:** the nearest daily support and resistance around the price.
- **How:** from the daily **floor** pivots. `nearest_support` is the highest of
  S1, S2, S3 and PP at or below price. `nearest_resistance` is the lowest of
  R1, R2, R3 and PP at or above price. `distance_pct` is the distance to
  **support** only, in percent of price.
- **Values:** prices; 0 means "none on that side".
- **Not:** support or resistance in any predictive sense. They are arithmetic
  on the prior day's high, low and close.

#### `targets`

- **What:** the two nearest daily floor-pivot rungs above and below the price.
- **How:** `upside` is up to two of S3…R3 and PP above price, nearest first.
  `downside` is up to two below, nearest first. `pivot` is the daily PP.
- **Values:** arrays of prices (`null` when none on that side). The whole block
  is absent when there is no daily pivot.
- **Not:** forecasts or price objectives. Both sides are always given, whatever
  the bias. They are the same rungs as `pivots.daily.floor`, sorted by
  distance.

#### `pivots`

- **What:** floor, Camarilla, Woodie and Fibonacci grids for the prior closed
  day, week and month (`internal/ta/pivots.go`).
- **Values:** `pp` of 0 means that grid is unavailable. Woodie has no R3/S3.
  Camarilla `pp` is the prior close (since 3.0).

#### `structure`, `gaps`

- **What:** the full 1h market-structure read (trend, event, order block) and
  up to three unfilled 1h fair-value gaps, newest first.
- **Values:** omitted on the public endpoint, and when 1h has fewer than 30
  closed bars (`structure`) or no unfilled gap (`gaps`).
- **Not:** inputs to `mtf` or the verdict, apart from `setup`.

#### `ohlc`, `changes`

- **What:** the current day's bar and percent changes over 1D, 1W, 1M, 3M and
  1Y, reconciled with the live price.
- **Values:** absent in point-in-time and replay payloads.

#### `live_price`

- **What:** the mid of the latest tick, `(bid + ask) / 2`. Without a tick it
  falls back to the newest 1h close.
- **Not:** a bar price. Bars are broker bid prices. In `/at` and replay rows the
  price is the close of the newest closed bar (bid).

#### `as_of`, `data_age_seconds`, `is_stale`, `market_closed`

- **What:** how fresh the data behind this payload is.
- **How** (`internal/api/analysis_service.go`, `applyStaleness`), recomputed on
  every request, including cache hits:
  - `as_of` is the newest of: the newest 1h bar's open time, the newest 1d
    bar's open time, and the latest tick time. It is **absent** when there is
    no 1h bar, no 1d bar and no tick.
  - `data_age_seconds` is now − `as_of`, rounded.
  - `market_closed` is `true` when the symbol's session is known to be closed.
  - `is_stale` is `true` when the data is older than 5 minutes and the market
    should be open (or its state is unknown), and always `true` when there is
    no data at all.
- **Not:**
  - **`as_of` is not the computation time.** It is data freshness. The
    timeframe scoring may have been computed up to 90 seconds earlier.
  - **`as_of` is not tied to `mtf.sufficient`.** An instrument with ticks but no
    bars has an `as_of` and no reading.
  - A closed market is not stale. Over a weekend `is_stale` is `false` and
    `market_closed` is `true`.
  - In `/at` and replay: `as_of` is the close of the bar the price came from,
    and `is_stale` and `market_closed` are always `false`.

#### `schema_version`

- **What:** the payload contract version.
- **How:** minor bump on additive changes; major bump on any rename, removal
  or change of meaning. Key your parser on the major.

#### `disclaimer`

- Travels with every payload. If you show the analysis to end users, show it.

---

## 5. Invariants you can rely on

Each follows from the code named beside it. Tests in `internal/ta` pin most of
them. They describe the live and `/at` payloads; replay rows are condensed
(see the mapping at the top of [section 4](#4-field-reference)).

**Per timeframe** (`internal/ta/analysis.go`, `score.go`, `votes.go`):

1. `sufficient` is `true` exactly when `status` is `ok`.
2. If `status` ≠ `ok`: `direction` is `"NEUTRAL"`, `score` is 0, `consensus` is
   `""`, `detectors` is `null`, `detectors_active`, `detectors_directional` and
   `detectors_total` are 0, and `summary`, `values`, `indicators_computed` and
   `indicators_total` are absent.
3. `score > 0` exactly when `direction` ≠ `"NEUTRAL"`. Any non-zero score is at
   least 2.0.
4. `detectors_directional == 0` implies `direction == "NEUTRAL"`. The reverse
   does not hold (an exact tie is also `NEUTRAL`).
5. If `status` is `ok`: `consensus` ∈ {`BUY`, `SELL`, `NEUTRAL`} and equals
   `summary.overall.label` collapsed; `detectors_total` is 7;
   `detectors_active` equals the length of `detectors`.

**Cross-timeframe** (`internal/ta/mtf.go`, `analysis.go`, `verdict.go`):

6. These are all the same condition: `mtf.sufficient` is `true`; `mtf.bias` is
   not `""`; `verdict` is present; `completeness` is not `"insufficient"`; at
   least one of the nine timeframes is `ok`.
7. When `mtf.sufficient` is `false`: `agreement`, `net` and `coverage` are 0 and
   `alignment` is `""`.
8. `|mtf.net| == mtf.agreement`, exactly.
9. `bias == "BULLISH"` exactly when `agreement ≥ 0.60` and `net > 0`;
   `bias == "BEARISH"` exactly when `agreement ≥ 0.60` and `net < 0`;
   `bias == "MIXED"` exactly when `mtf.sufficient` and `agreement < 0.60`. The
   threshold is applied to the published, rounded `agreement`.
10. `coverage == 1` exactly when `completeness == "full"`.
11. `mtf.sufficient`, `bias`, `agreement`, `net`, `coverage`, `completeness`
    and the whole `verdict` are computed over all nine timeframes, even when
    the payload shows fewer. `alignment` and `opinion` describe only the
    timeframes shown.

**Verdict** (`internal/ta/verdict.go`):

12. `stance == "BULLISH ABOVE"` implies `bias == "BULLISH"`;
    `stance == "BEARISH UNDER"` implies `bias == "BEARISH"`;
    `bias == "MIXED"` implies `stance == "NEUTRAL AROUND"`. A directional bias
    shows `"NEUTRAL AROUND"` only when there is no line on its side of price
    in any pivot ladder and no daily ATR — in practice, no daily data. A
    directional `level` is below price for `BULLISH ABOVE` and above it for
    `BEARISH UNDER`, always.
13. `stop` and `risk_reward` present implies the stance is directional, which
    implies `bias` is directional. The reverse does not hold: a directional
    bias can come without them (no ATR, no target on that side, or price
    already past the stop).
14. The narrative's lean always matches the stance.

**Levels** (`internal/ta/derived.go`, `analysis.go`):

15. Every `targets.upside` price is above the price the analysis was computed
    at, and every `targets.downside` price is below it; at most two each.

**Determinism:** the same bars, price and clock always give the same payload.
The clock matters only for deciding which bar is still forming and for the
live-day blocks. There is no randomness and no model training.

---

## 6. Availability: partial vs insufficient

Three layers tell you what you actually got.

### 6.1 HTTP status

| Status | Code | Meaning | What to do |
|---|---|---|---|
| 200 | — | An analysis was served, possibly partial | Read `completeness` |
| 429 | `RATE_LIMITED` | Per-minute limit hit: the plan limiter (per API key, with `X-RateLimit-*` headers) or the edge limiter (per IP, `Retry-After: 1`) | Wait `Retry-After` seconds |
| 429 | `QUOTA_EXCEEDED` | Daily quota used up | `Retry-After` counts to 00:00 UTC |
| 503 | `SERVICE_UNAVAILABLE` | Nothing could be read: no timeframe could be fetched, the database is down, or the service is restarting | Wait `Retry-After` (5 s), then retry with backoff |
| 500 | `ANALYSIS_FAILED` | Engine-side failure | Retry with backoff; report if persistent |
| 4xx | others | Your request or credentials | Fix; do not retry unchanged |

The full list is in [API.md — Error Codes Reference](API.md#error-codes-reference).

Since 3.1 a storage error on **some** timeframes no longer fails the request.
Those timeframes come back with `status: "unavailable"` inside a 200. Only
when **no** timeframe can be fetched is the answer 503. `/at` behaves the same
way. The replay **series** does not: any fetch error fails the whole page with
503, because a series with holes would be worse than a retry.

### 6.2 Payload

- `completeness`: `full` | `partial` | `insufficient`. Gate on this first.
- `mtf.coverage`: how much of the timeframe weight was read.
- `timeframes[tf].status`: which timeframe is missing, and why.

| You see | It means |
|---|---|
| `completeness: "insufficient"` | Nothing was read. `mtf.bias` is `""`, `verdict` is absent. Not a neutral market |
| `completeness: "partial"`, a timeframe `unavailable` | A component is down. Transient. The result was not cached, so a retry can come back `full` |
| `completeness: "partial"`, a timeframe `insufficient_history` | Not enough stored history. Structural. It stays that way until history accumulates or is backfilled |
| A timeframe missing from `timeframes` | Your plan or the public endpoint hides it. Not an availability problem |
| `is_stale: true` | The newest data is over 5 minutes old while the market should be open: a feed gap |

### 6.3 A gate for a market monitor

Pseudocode. The thresholds are yours to choose.

```text
function readAnalysis(symbol):
    resp = GET /api/v1/market/analysis/{symbol}

    # 1. HTTP
    if resp.status == 429:  return RETRY_AFTER(resp.header["Retry-After"])
    if resp.status == 503:  return RETRY_AFTER(resp.header["Retry-After"])
    if resp.status >= 500:  return RETRY_WITH_BACKOFF
    if resp.status != 200:  return REQUEST_ERROR(resp.body.error)   # fix, don't retry

    a = resp.body

    # 2. Anything read at all?
    if a.completeness == "insufficient":
        return NO_READING                     # not "neutral"

    # 3. Fresh?
    if a.is_stale:
        return STALE_DATA                     # feed gap during an open session

    # 4. Enough of the intended input?
    if a.mtf.coverage < MIN_COVERAGE:         # e.g. 0.75 tolerates losing one of 1w/1mo
        return THIN_READING

    # 5. The timeframes your rule reads
    for tf in TIMEFRAMES_MY_RULE_USES:
        if tf not in a.timeframes:            return NOT_IN_MY_PLAN
        s = a.timeframes[tf].status
        if s == "unavailable":                return COMPONENT_DOWN     # retry soon
        if s == "insufficient_history":       return NOT_ENOUGH_HISTORY # structural

    # 6. Only now read directions
    return evaluateRule(a)
```

---

## 7. Intended usage — worked examples

Pseudocode, language-neutral. Every threshold here is a parameter for you to
test, not a recommended value. None of these rules has a measured edge.

### 7.1 A "no clear call" gate

Decline to call a direction unless the aggregate is directional, well past the
threshold, and built on enough of the timeframes.

```text
function call(a):
    if a.completeness == "insufficient":  return NO_CALL
    if a.mtf.coverage  < 0.75:            return NO_CALL    # too little read
    if a.mtf.bias == "MIXED":             return NO_CALL
    if a.mtf.agreement < 0.70:            return NO_CALL    # stricter than the engine's 0.60
    return a.mtf.bias                     # "BULLISH" or "BEARISH"
```

0.60 is where the engine starts naming a direction. It is a naming rule, not a
calibrated confidence, so a stricter cut-off is a legitimate choice. It is not
automatically a better one: test it (7.4).

### 7.2 "4h bullish, confirmed by 1h"

Write it on `consensus`, the same model the headline uses:

```text
function h4ConfirmedByH1(a):
    for tf in ["4h", "1h"]:
        if tf not in a.timeframes:              return NO_CALL   # hidden by plan
        if a.timeframes[tf].status != "ok":     return NO_CALL
    return a.timeframes["4h"].consensus == "BUY"
       and a.timeframes["1h"].consensus == "BUY"
```

A stricter version reads the five-state label:
`a.timeframes["4h"].summary.overall.label == "STRONG_BUY"` (in a replay row:
`row.timeframes["4h"].vote == "STRONG_BUY"`).

**Why not `direction`?** `direction` is the pattern model. It is `NEUTRAL`
whenever no detector fired, which is most of the time. A rule written on
`direction == "BUY"` asks "did a bullish pattern fire on both timeframes?".
That is a legitimate question, but a different one. It fires far less often,
and it tests a model the headline no longer uses. In the API.md example, the
`consensus` rule is true (4h BUY, 1h BUY), and the `direction` rule is false
because nothing fired on 1h.

### 7.3 Reading the lean behind `MIXED`

```text
function describe(a):
    if not a.mtf.sufficient:     return "no reading"
    if a.mtf.bias != "MIXED":    return a.mtf.bias

    if a.mtf.net >=  0.40:       return "leans bullish, below the threshold"
    if a.mtf.net <= -0.40:       return "leans bearish, below the threshold"

    # Near zero: balanced, or conflicting?
    buyW = sellW = 0
    for tf, v in a.timeframes where v.status == "ok":
        if v.consensus == "BUY":   buyW  += WEIGHT[tf]      # table in section 3
        if v.consensus == "SELL":  sellW += WEIGHT[tf]
    if buyW > 0 and sellW > 0:   return "timeframes conflict"
    return "timeframes balanced"
```

Worked numbers, all nine read: `1mo` NEUTRAL, `1w` BUY, `1d` BUY, `4h`
NEUTRAL, `1h` BUY, `30m` BUY, `15m` SELL, `5m` and `1m` NEUTRAL. The net vote
is 3 + 2.5 + 1 + 0.65 − 0.5 = 6.65; 6.65 / 12.75 = 0.52. So `agreement: 0.52`,
`net: 0.52`, `bias: "MIXED"`: a clear bullish lean below the threshold.
Without `net` (before 3.1), this looked the same as a bearish lean of the same
size.

On a trimmed payload you cannot see every timeframe's `consensus`, so the
balanced-vs-conflict split can only use the timeframes shown. `net` is still
over all nine.

### 7.4 Testing a rule before trusting it — the replay endpoint

`GET /api/v1/market/analysis/:symbol/replay?from=&to=&step=1h|4h|1d` returns
the analysis at every close of `step`, computed only from bars closed by then.
Each row carries what price did next at 1h, 4h, 12h and 24h. Details:
[API.md — Analysis Replay Series](API.md#analysis-replay-series-backtesting).
Rows use a condensed shape: `vote` instead of `summary.overall.label`, and no
`level`, `stop` or targets (see the mapping at the top of
[section 4](#4-field-reference)).

```text
rows = []
cursor = T_START
loop:
    page = GET /market/analysis/{symbol}/replay?from={cursor}&to={T_END}&step=4h
    rows += page.points
    if page.next_from is absent: break
    cursor = page.next_from                # pages cover ≤ 31 days / 200 points

inSample  = rows where T_START <= row.t < T_SPLIT
outSample = rows where T_SPLIT <= row.t <= T_END

# 1. Choose thresholds on inSample ONLY.
# 2. Evaluate once on outSample. Do not go back and tune.
for row in outSample:
    if row.completeness == "insufficient": continue
    side = rule(row)                                    # +1 long, -1 short, 0 none
    r = row.forward["24h"].return_pct                   # null until the horizon passed
    if side == 0 or r is null: continue
    net = side * r - MY_COSTS_PCT                       # costs are NOT included
    record(net, hit = side * r > 0)
    # excursions: long  MFE = max_up_pct,   MAE = max_down_pct
    #             short MFE = -max_down_pct, MAE = -max_up_pct

compare against a baseline:
    - the unconditional mean return and hit rate of all rows (market drift)
    - a random rule with the same number of signals
walk forward: move T_START, T_SPLIT and T_END forward and repeat
```

Things that bite:

- **No costs are applied.** Spread, commission and slippage are not in any
  number. Subtract your own.
- **Rows overlap.** With `step=1h`, consecutive rows share 23 of the 24 hours
  of their 24h outcome, so they are not independent. A t-statistic over them
  overstates significance.
- **Eras differ.** Intraday history is shorter than daily history. Early rows
  have lower `mtf.coverage` and are built from fewer timeframes. Filter on
  `coverage` or `completeness` before comparing periods. Per-symbol depth:
  `/api/v1/market/coverage`.
- **Replay is not the published record.** It is recomputed from bars as stored
  today, including later corrections. What the live endpoint actually
  published is the track record (section 8).
- **Replay rows are not debounced.** They carry the raw `mtf.bias` at every
  step close. The track record logs only *changes* of a *debounced* bias, so
  the two samples differ and their hit rates will not match.
- **Many rules, some winners.** Try enough rules and some will look good by
  chance. Keep a log of everything you tried.
- **Prices are bid.** Row prices and outcomes are broker bid prices.

### 7.5 Acting on changes: debounce

`mtf.bias` flips the moment `agreement` crosses 0.60. The engine's own signal
tracker holds a directional call until `agreement` falls below 0.55
(`internal/ta/hysteresis.go`, `StickyBias`). If you act on changes, do the
same or wider:

```text
function effectiveBias(published, agreement, previous):
    if published == "":                     return ""          # nothing read: drop the call
    if published != "MIXED":                return published   # a directional reading always wins
    if previous in ["BULLISH", "BEARISH"] and agreement >= 0.55:
        return previous                                         # hold through the dead band
    return "MIXED"
```

---

## 8. What we have measured

**The stance.** Nothing in this payload is a probability or a forecast. The
thresholds (0.60 for the bias, the vote bands, the detector weights) are
design choices. The in-repo calibration tool, `cmd/tavalidate`, runs on
synthetic data, and says itself that it "validates wiring/calibration shape,
NOT real-market edge". No measurement recorded in this repository shows a
directional edge for any field in the payload.

**What has been measured, and where it is recorded:**

- **The pattern model showed no directional edge.** It was the headline model
  before schema 2.0 (track-record kind `bias`, recorded live 2026-07-03 to
  2026-08-03). Scored against forward returns on that month of signals, it
  averaged −0.09% after BULLISH calls and +0.10% after BEARISH ones, with
  51.3% and 42.9% win rates. The same measurement found that "only one of the
  four structure signals pointed the right way". Every mean was about 0.05%
  against a standard deviation of about 1.1%: indistinguishable from zero.
  Sources: the commit that relabelled `agreement` (0278178, 2026-08-18) and
  the header comment of `frontend/lib/agreement.ts`.
- **The pattern model is silent most of the time.** On 2026-08-04, 5 of the 17
  curated instruments had zero daily detector hits, 13 of 17 had at most one
  of seven, and no instrument had more than three on any timeframe. Source:
  `docs/ADR-001-analysis-headline-model.md`.
- **Aggregating the pattern model could not point anywhere.** 42% of the
  average symbol's timeframe weight came from timeframes where no detector had
  fired, and 16 of 17 curated symbols read `MIXED`. This is why schema 2.0
  moved `mtf.bias` to the indicator consensus. Sources: commit e8e72ea
  (2026-08-17) and the doc comment on `AggregateMTF` in `internal/ta/mtf.go`.
- **The current headline (the consensus bias) has no measurement recorded in
  this repository.** When the pattern model was scored, the consensus model
  had no scored outcomes. It has been recorded live since schema 2.0 as
  track-record kind `bias_consensus`. Its running statistics per horizon
  (`n`, `mean_pct`, `stdev_pct`, `t_stat`, `hit_rate` with a 95% Wilson
  interval, mean MFE/MAE) are at
  `GET /api/v1/public/analysis/performance?kind=bias_consensus`. The numbers in
  the API.md example for that endpoint are illustrative, not measurements.

**How to read the track record.** It is described in
[API.md — Signal Track Record](API.md#signal-track-record-public). It records
every change of the debounced bias (enter at 0.60, release at 0.55) and every
structure event on the 17 curated symbols, live and once, and never edits
them. Entries are at the live mid price. No costs are deducted. Signals overlap
in time and the symbols move together, so `t_stat` overstates significance.

**Why the replay endpoint exists.** Not to imply an edge. It exists so you can
test your own combinations of these fields, out of sample, before you depend
on them (example [7.4](#74-testing-a-rule-before-trusting-it--the-replay-endpoint)).

---

## 9. Schema changelog

Source of truth: the doc comment on `AnalysisSchemaVersion` in
`internal/ta/analysis.go`, and the commits named below. Dates are commit dates.

| Version | Date | Change |
|---|---|---|
| 1.1 | 2026-07-03 | `schema_version` introduced (1d5e809). The same release extended scoring to nine timeframes, 1m–1mo (6d7f0f2). |
| 1.2 | 2026-08-05 | Per-timeframe `detectors_active`, `detectors_directional`, `detectors_total`, so "nothing fired" can be told from "balanced" (eb2322d). |
| 1.3 | 2026-08-05 | `summary.overall`: the two vote slates combined under the same bands (0449acc). |
| 1.4 | 2026-08-17 | `mtf.sufficient`. When nothing was read, `mtf.bias` and `mtf.alignment` are `""` instead of `"MIXED"`/`""`, and `verdict` is absent. `as_of` is omitted instead of sent as the zero time when there is no bar and no tick (dd67e7d). |
| 2.0 | 2026-08-17 | **Meaning change.** `mtf.bias` (and `agreement`, `alignment`) are built from the indicator consensus, not the pattern `direction`. Per-timeframe `consensus` added (e8e72ea). |
| 3.0 | 2026-08-21 | **Meaning change.** `verdict.short_term`, `underlying`, `stance` and `narrative` come from the same indicator consensus: the stance and narrative from `mtf.bias`, the two labels from `summary.overall`. `verdict.setup` no longer calls a break inside a range a "continuation". Camarilla `pp` is the prior close (bede4b0). |
| 3.1 | 2026-09-24 | Additive. Per timeframe: `status`, `bars`, `bars_required`, `data_through`. `mtf.net`, `mtf.coverage`, top-level `completeness`. A storage error on some timeframes gives a partial 200 instead of a 500; none fetchable gives 503 instead of 500 (1143583). The point-in-time endpoints `/analysis/:symbol/at` and `/analysis/:symbol/replay` were added without a version change (e5229ad). |

A major bump means an existing field changed meaning even if its type did not.
A parser keeps working across 2.0 and 3.0; its interpretation does not.

---

## 10. Where the code lives

| Concern | File |
|---|---|
| Pipeline, per-timeframe availability, `completeness`, `levels`, `opinion`, schema version | `internal/ta/analysis.go` |
| Per-timeframe fields | `internal/ta/types.go` |
| Pattern detectors | `internal/ta/detectors.go` |
| Pattern score and direction | `internal/ta/score.go` |
| Indicator vote, `summary`, `consensus` | `internal/ta/votes.go` |
| Raw `values` | `internal/ta/indicator_values.go` |
| `mtf.*`, timeframe weights, trimmed-payload restatement | `internal/ta/mtf.go` |
| `verdict.*` | `internal/ta/verdict.go` |
| 1h structure, `setup` | `internal/ta/structure.go` |
| `divergence` | `internal/ta/divergence.go` |
| `targets`, `ohlc`, `changes` | `internal/ta/derived.go` |
| Pivot grids | `internal/ta/pivots.go` |
| Bias debounce used by the signal tracker | `internal/ta/hysteresis.go` |
| Fetching, caching, `unavailable`, freshness fields | `internal/api/analysis_service.go` |
| Endpoints, plan trimming, 503 mapping | `internal/api/handlers_analysis.go` |
| Public trimming | `internal/api/handlers_public_analysis.go` |
| Point-in-time and replay | `internal/api/analysis_replay*.go`, `internal/api/handlers_analysis_replay.go` |
