# Market Oracle — instructions for an AI agent of a client platform

**docs_updated:** `2026-09-20 05:30 UTC+2`

This is the **first signal** that the documentation has changed. If the stamp is newer than your copy/session cache — do not rely on the previous contract: first `docs/updates.md` (what changed), then reread this file. On any edit to `docs/`, update this stamp here.

Russian: `https://market-oracle.pro/ru/docs/agents.md`. English: `https://market-oracle.pro/en/docs/agents.md`.
Crawler index: `https://market-oracle.pro/llms.txt`. Full English spec dump: `https://market-oracle.pro/llms-full.txt`.

This document is intended for an AI agent that will write a decision center, trading strategies, and Market Oracle clients in another repository.

Client tiers **`free` / `basic` / `pro`** (limits, who gets which, no price) — section **“Client tiers”**. Do not offer the operator’s internal tier. Name a price only from the sales site.

## Purpose and core rules

Market Oracle is only a source of market data. It does not open trades and does not manage positions.

Main rule: **trading decisions are taken only on a closed bar (`closed: true`)**. Oracle’s live trigger is a closed **1m** (`bar_close`); a strategy on `5m`/`15m`/`1h` must wait for the close of the **native** bar of its TF (`GET /v1/history?interval=` or live `context.htf`), not aggregate minutes and not trade an unclosed candle. Do not emit a signal again for the same dedup key `` `${symbol}:${bar.ts}` `` (canonical string format).

The oracle:

- stores closed bars and computed indicators on **all** enabled TFs (`1m` live + native `5m`/`15m`/`30m`/`1h`/`4h`/`1d`/`1w`);
- on each TF after warmup delivers the same core: EMA/RSI/ATR/ADX(+DI)/VWAP/OBV/MFI/CVD, plus slope, BB, volume$, swing;
- returns the list of available symbols and their readiness;
- sends fresh closed **1m** bars over WebSocket (**lean payload**: bar + indicators; without `trade_cost`/`cost_risk`/`htf`);
- aggregates the existing Binance top-20 book into closed minute microstructure facts;
- provides a 24h ticker and instrument trading constraints;
- uses Binance Spot as the primary source; Bybit — 1m fallback; OKX — last-resort 1m + quotes; CVD only from Binance (`/health.cvd`);
- in a single call `GET /v1/context/{symbol}` returns a snapshot for a decision (bar, depth, **`trade_cost` / `cost_risk`**, **`htf`**, derivatives, macro, quotes, regime).

**Edge primitives (mandatory for robots from 2026-07):** do not hardcode fees and “cost in R” in the strategy. Read `context.trade_cost` and `context.cost_risk` (see the section below). Sandbox and live DEMO must use the same fee preset (Oracle config or query override).

**Multi-timeframe (mandatory from 2026-08):** do not aggregate `5m`/`15m`/`30m` from local `1m` for oracle-only fields (`adx14`, `vwap`, `cvd_delta`, `obv`, `mfi14`, DI, BB…). Take native series `GET /v1/history?interval=15m` (and so on). Live HTF snapshot — `context.htf`; for sandbox/replay — history as-of by `bar.ts`.

## What’s new for the strategy agent (2026-08)

This block is for ChatGPT / Lab / bots that write strategy code. Oracle is no longer “indicators only on 1m + HTF without `indicators`”.

### Full native `5m` / `15m` / `30m`

| Before (do not do this) | Now |
|------------------------|--------|
| Lab/client glued `5m`/`15m`/`30m` from `1m` | Oracle itself syncs native Binance REST klines |
| `adx14` / `vwap` / `cvd_delta` / `obv` / `mfi14` on the aggregate often `null` | the same core as on `1m`, after warmup **on that TF** |
| EMA/RSI on H1/H4/1d the client computed itself | `GET /v1/history?interval=1h\|4h\|1d\|1w` already carries `bar.indicators` |

History intervals: `1m` (live WS) + native `5m` / `15m` / `30m` / `1h` / `4h` / `1d` / `1w`. Example:

```http
GET /v1/history/BTCUSDT?interval=15m&limit=300
GET /v1/history/BTCUSDT?interval=5m&limit=500
POST /v1/history/batch   {"symbols":["BTCUSDT"],"interval":"15m","limit":200}
```

Depth (defaults `[timeframes]`): `5m` 60d, `15m` 90d, `30m` 120d, `1h` ~9 mo, `4h` 2y, `1d` 3y, `1w` 5y.

### New key catalog in `bar.indicators` (all TFs)

Added to the old core (`ema20/50/200`, `rsi14`, `atr14`, `vwap`, `vol_sma20`, `adx14`, `obv`, `mfi14`, `cvd_delta`, candle shape):

| Keys | Why the strategy needs them |
|-------|-----------------|
| `plus_di14` / `minus_di14` / `di_side` (`"1"`/`"-1"`/`"0"`) | trend direction (ADX itself is strength, not side) |
| `ema50_slope_pct` / `ema_slope_abs` | flat for a grid (`\|slope\| ≤ 0.1` on its own TF) |
| `atr50` / `atr_ratio_14_50` | squeeze / “ATR low vs its own norm” |
| `volume_usd` / `volume_rate` | bar `$` and `$/min` (rate is comparable across TFs) |
| `bb_mid` / `bb_upper` / `bb_lower` / `bb_width` | Bollinger 20,2; squeeze/breakout |
| `swing_high` / `swing_low` / `last_swing_*` | fractal (strength=2), lag of 2 bars of **this** TF |

`indicators_ready` is still = `ema20`+`rsi14`+`atr14` are present. The remaining keys appear later (ADX/DI ~ after 27 bars, EMA200 after 200 bars of **this interval**, BB after 20). Do not substitute `0` for `null`.

### Live vs history: what lives where

| Strategy needs | From | Not from |
|-----------------|--------|-----------|
| 1m signal | `WS bar_close` / `/v1/latest` / `context.bar` | — |
| 5m / 15m / 30m signal | `/v1/history?interval=5m\|15m\|30m` | **not** in WS; **no** `5m`/`30m` in `context.htf` |
| Live higher TF (filter) | `context.htf.m15` / `.h1` / `.h4` / `.d1` | no `.m5`, no `.m30`, no `.w1` |
| Replay / sandbox HTF | history as-of: last bar with `ts <= decisionBar.ts` | do not copy live `context.htf` onto past bars |
| Indicators-only series | `/v1/indicators/{symbol}` | **1m only**; for 15m take history |
| Cold dump | `POST /v1/sync/bootstrap` | **1m only**; mid/HTF is raised by background TF-sync |

`context.htf` is live as-of the last **closed** `15m`/`1h`/`4h`/`1d`. For `5m`/`30m`/`1w` always history.

WebSocket does **not** send `5m`/`15m` `bar_close`. 15m-strategy loop:

```text
WS 1m bar_close
  → is this the last minute of the 15m bucket?  (bar.ts % 900_000 === 840_000)
  → wait for HTF-sync (usually ≤ 60 s) or immediately GET /v1/history?interval=15m&limit=1
  → trade only if 15m.closed && 15m.ts === open of this bucket
  → dedup `${symbol}:${m15.ts}`  (higher-TF bar key, not 1m)
```

For 5m: `bar.ts % 300_000 === 240_000`. For 30m: `bar.ts % 1_800_000 === 1_740_000`.
Before an entry still `GET /v1/context` (`trade_cost` / `cost_risk` / `htf` / quality) — these fields are not in WS.

### What Oracle still does not have

Do not ask for and do not wait in `bar.indicators`: MACD, Stochastic, Supertrend, Ichimoku, RSI-reclaim state, touch counter, daily HL channel, `htf_trend.trend_z`, `rs_rank`. That is Lab extra / storage / strategy logic. Raw book history is only live `GET /v1/depth` and minute microstructure.

`vwap` is session VWAP with a **UTC-day reset**. On `5m`/`15m`/`1h` this is a normal daily VWAP. On `1d` it is the VWAP of the daily candle itself. On `1w` it is **not** weekly VWAP (reset every UTC day) — do not use it as a weekly anchor price.

`ready` (≥200 **1m** bars) ≠ EMA200 warmup on 15m. For `interval=15m` check `bar.indicators.ema200` on the 15m bar (~200 candles ≈ 50 h of native history).

## Correctness and recovery guarantees in v0.1.1

- EMA, RSI, ATR(14/50), VWAP, volume SMA, OBV, MFI, CVD-delta, ADX(+DI), Bollinger(20,2), EMA50 slope, volume_usd/rate and fractal swings are covered by unit/batch tests where applicable. Candle shape (`body`/`candle_q`/…) and `atr_pct` are computed O(1) on bar close. `adx14` / DI use the standard Wilder seed: first full 14 periods of DM/TR, then smoothing. During warmup the value is `null`; it must not be replaced with zero.
- A closed 1m bar and indicator state are saved to Redis atomically. After a restart Oracle restores missing/corrupted state by replaying saved history. Mid/HTF state is separate: `mo:state:ind:{symbol}:{interval}`.
- Gap-fill is considered successful only on a fully continuous range of valid candles. The REST request is retried with backoff; if the range is not restored, a new bar is not committed over a hole.
- A valid closed candle with zero volume is accepted if OHLC are positive. Any candle with `open/high/low/close <= 0` is rejected as corrupted.
- Startup bootstrap and TF sync (`5m`…`1w`) load only missing ranges and **write indicators** with the same engine as 1m. A fully healthy Redis history is not re-downloaded; the series is updated after a new bucket closes. Legacy bars with `indicators: null` are recomputed on the next sync.
- All process Binance REST clients share a weighted limiter. `429`/`418` create a shared cooldown honoring `Retry-After`. Settled funding after the first fill is requested incrementally from the last Redis point and no more than once per hour.
- Re-adding an already registered symbol does not trigger Binance REST. `POST /v1/symbols` only writes the pair into the Oracle registry; bootstrap/`exchangeInfo` run in the background inside the oracle, not on the client GET path. `GET /v1/meta/{symbol}` returns only the Redis cache (404 if not yet present). Unsupported futures pairs are excluded from repeated REST polls.
- Fresh macro/calendar snapshots are reused after a restart until the next scheduled refresh. The calendar **accumulates** (~90 days) for as-of replay and is not overwritten by the weekly feed. Market regime does not publish an empty “fresh” snapshot: after a cold start it waits for native 1h history readiness and retries the calculation.
- These guarantees apply to live-data integrity. They do **not** make the current `/v1/context` historically safe: `context_scope="live_snapshot"` and `historical_safe=false` remain a mandatory constraint. `context.htf` is live as-of the last closed mid/HTF bars, not a join to an arbitrary historical `bar.ts`.

## Microstructure Contract v1 in v0.2.1

- Existing `depth20@1000ms` is aggregated by UTC minutes without extra Binance WS/REST traffic. Binance sends a low-activity book only on change; Oracle therefore samples the last confirmed top-20 once per second while the WS provider is healthy.
- `sample_count` is the number of deterministic 1Hz Oracle samples, and `source_update_count` is the real number of Binance messages. Do not use `source_update_count < 40` as a quality gate: an unchanged book is not stale.
- The next minute’s snapshot cannot land in the previous one; a reconnect gap is capped at two seconds in the pressure integral.
- After successful finalization the object is atomically replaced in a separate Redis ZSET and only then published as opt-in `microstructure_close`.
- Raw depth snapshots remain only in a bounded RAM structure and are deleted after finalization; Redis stores only closed minute features.
- Ordinary bars do not depend on microstructure being present. On failover to a BBO-only source unfinished top-20 microstructure is discarded, and `bar_close` continues to work.

## Connection and authorization

The API address is given to the client by configuration:

```env
ORACLE_BASE_URL=https://api.market-oracle.pro
ORACLE_API_KEY=mo_...
```

Do not embed the key in a public frontend or repository. For every REST request to `/v1/*`:

```http
Authorization: Bearer mo_...
```

`GET /health` and `GET /metrics` are public. For WebSocket the key is passed in the `Authorization` header or, if the library cannot set WS headers, once in the URL:

```text
wss://api.market-oracle.pro/v1/stream?token=mo_...
```

## Client tiers

Names: only **`free` / `basic` / `pro`**. There is no obsolete `standard`. Do **not** offer the operator’s internal tier to a client.

**Do not name or guess a price** — it exists only on the key sales site. Here are limits so you can pick a tier for the bot and explain to the user *why* that slug, not *how much it costs*.

Hard numbers for **this** key always come from `GET /v1/me`. The canon below is what to show the user and bake into architecture (seed / storefront). If `/v1/me` diverges from the table — trust `/v1/me`.

`daily` = paid REST per UTC day (**1 HTTP = 1**). Frames of `WS /v1/stream` and service `GET /v1/me`, `/v1/symbols`, `/v1/meta/*`, `/v1/calendar` are **not** counted in `daily`. Several bots on one key: WS are parallel; REST is a shared queue (`rpm` + `min_interval_ms` + `daily`).

| slug | Requests/day | RPM | Min. REST pause | Rec. client pause | Max WS | Whom to offer |
|------|--------------|-----|-----------------|-------------------|--------|-----------------|
| `free` | **2000** | 60 | 300 ms | 1000 ms | **1** | Sandbox, one bot, getting to know the API. Not 24/7 live on 5m with several pairs. |
| `basic` | **5000** | 60 | 200 ms | 1000 ms | **2** | Live DEMO/REAL: 1–2 bots per key, TF 1m/5m/15m+, days and weeks without shutdown. |
| `pro` | **8000** | 90 | 150 ms | 667 ms | **3** | Live 24/7: up to 3 bots on one key (three WS). Need more processes — a second key, not a “limit bypass”. |

How to explain to the user (without price):

1. Ask: how many **simultaneous** live bots and which TF (1m / 5m / 15m+). One process = one WS.
2. **1 bot, trial, sandbox** → `free`.
3. **1 or 2 live bots 24/7** → `basic`.
4. **3 live bots on one key** → `pro`.
5. **More than 3 processes** → another key of the needed tier (each has its own `daily` / WS).
6. If a bot without WS hammers `latest` every 10 s — that is ~8640 REST/day, it will not fit `free` or `basic`; first Live on WebSocket, then the tier.

New keys default to `free`, term **+30 days**, unless the operator set otherwise.

## Recommended client startup

1. Call `GET /health` (public). HTTP 200 only means “the process answered”. Trade on fields:
   - OHLC / 15m without CVD: `status == "ok"` and `bars.status == "ok"` (plus `redis == "ok"`, `ws_connected == true`).
   - CVD: additionally `cvd.status == "ok"`. On Bybit/OKX CVD is always `down` — do not enter on cumulative CVD.
   Do not gate the session on `failover == false` alone: a brief failover to Bybit with live bars is acceptable for 15m without CVD.
2. Call `GET /v1/me` — learn `tier`, `daily_limit` (**count of paid REST per UTC day**, not weights), `rpm`, `min_interval_ms`, `max_ws`, `expires_at`. Field `daily_load` is load for debugging, **not** a second limit. Do not divide `daily_remaining` by `weights.context`.
3. Call `GET /v1/symbols`.
4. For analysis take only pairs with `ready == true`; preferably `history == "full_day"`.
5. Check `ready`/`history`/`last_closed_ts`. Call `POST /v1/sync/bootstrap` only if history is not yet warmed or explicit recovery is required; server startup bootstrap already syncs missing ranges.
6. Connect to `WS /v1/stream` and send a subscription; for book strategies add `"microstructure": true`.
7. On every `bar_close` event update local state and run analysis exactly once (dedup `` `${symbol}:${bar.ts}` ``). Join `microstructure_close` by the same `symbol + ts`, not by receive time.
   If the strategy uses a live execution-filter, after 1–2 seconds request `GET /v1/depth/{symbol}?seconds=3`; this does not change the historical signal of the closed bar.
8. Keep background polling per section **“Recommended delays between REST requests”**: for `basic`/short demo **`latest` 10–15 s**, **`context` 60–90 s** on active pairs; on `free` request `context` much less often, guideline **~30 min**. **Before an entry** always once `GET /v1/context/{symbol}` and check `cost_risk.tradable` (+ `data_quality`, depth freshness, and if needed `htf.h1`/`htf.h4`). `trade_cost`/`cost_risk`/`htf` exist **only** in context, not in WS.
9. After reconnect — pause **≥ min_interval_ms**, then first `/v1/history/{symbol}`. For a book strategy after the next pause request `/v1/microstructure/{symbol}` for the same range and do an exact join. `POST /v1/sync/bootstrap` is needed only on a confirmed gap/unready history; do not launch it in parallel by the dozens.
10. If WebSocket is unavailable — `GET /v1/latest` **every 30–60 s**, `context` **no more often than 60–120 s**.
11. Higher TF: live — read `context.htf` (last closed `m15`/`h1`/`h4`/`d1`); history/replay — `/v1/history?interval=5m|15m|30m|1h|4h|1d|1w` with cache **≥ 5 min**. Do not compute EMA/RSI/ADX on the client if Oracle already returned them in `bar.indicators`.

## Recommended delays between REST requests

Two independent limits on **every** REST call (except public `/health`, `/metrics`):

1. **`min_interval_ms`** — minimum pause between **any** two REST requests of one key. Violation → `429 too_fast` + `retry_after_ms`.
2. **`rpm`** — rolling 60 s window: no more than N requests per minute. Violation → `429 rate_limited`.

Practice: after **every** REST keep a queue with `await sleep(max(min_interval_ms, retry_after_ms))`. Do not launch parallel “storms” of `Promise.all` on dozens of `/v1/context` — serial queue or p-limit=1–2.

**Basic spacing rule:** `delay_ms ≥ max(min_interval_ms, ceil(60000 / rpm))` — lower bound, not the target polling. For `free`/`basic` (rec. **1000 ms** at 60 RPM) and `pro` (rec. **667 ms** at 90 RPM) follow the “rec. spacing” column in the tier table below; the hard server floor is `min_interval_ms` from `/v1/me`. Several live bots on one key must either share one REST queue or each wait ≥ `rec. spacing × ws_count` (WS frames do not wait on that queue).

### Table by endpoints (production bot with WS)

**Daily stop = HTTP count.** One paid REST = **1** of `daily`, even if `context`/`history` are “heavy”. Weights (`weights.*`) accumulate only in `daily_load` and do **not** disable the key. Do not divide `daily_remaining` by weight. Service `GET /v1/me`, `/v1/symbols`, `/v1/meta/*`, `/v1/calendar` and WS frames are not in `daily`.

Assumed: **`WS /v1/stream`** is connected, signal only on `bar_close`. Intervals are **between repeated calls of the same endpoint**; between different endpoints still honor `min_interval_ms`.

| Endpoint | Daily (requests) | Load (weight) | Recommended interval | Why |
|----------|-----------------|-------------------|------------------------|--------|
| `WS /v1/stream` | **0** | **0** | persistent connection | `bar_close` trigger, no REST every minute |
| `GET /v1/context/{symbol}` | **1** | **3** | `basic`/short demo: **60–90 s** per active pair; `free`: **~30 min**; **+ 1× before entry** | depth/macro/regime/quality + **`trade_cost`/`cost_risk`/`htf`** |
| `GET /v1/latest?symbols=` | **1** | **1** | **10–15 s** (ticker UI); **30–60 s** if WS is alive and the bar is already there | bid/ask/24h without full context |
| `GET /v1/history/{symbol}` | **1** | **3** | **≥ 5 min** cache per TF; only TF change / reconnect backfill | chart, mid/HTF with indicators |
| `GET /v1/depth/{symbol}` | **1** | **1** | once after a signal; not constant REST polling | current/recent top-20 |
| `GET /v1/microstructure/{symbol}` | **1** | **3** | sandbox start / reconnect backfill | minute book history |
| `GET /v1/microstructure/{symbol}/latest` | **1** | **3** | as needed; WS is preferable | last closed microstructure |
| `POST /v1/history/batch` | **1** (one HTTP) | **3×N** | **≥ 10 min** or once at start | several pairs at once |
| `POST /v1/sync/bootstrap` | **1** | **10** | only if `ready/history/last_closed_ts` confirm incomplete history | explicit warmup/recovery; load flat 10 (no extra charge for history volume) |
| `GET /v1/symbols` | **0** | **0** | **3–15 min** | pair list, `ready`/`history` |
| `GET /v1/calendar` | **0** | **0** | **20–60 min** live; in sandbox — `?as_of=bar.ts&window_min=60` | macro windows (context already has the live flag ≤60 min) |
| `GET /v1/meta/{symbol}` | **0** | **0** | **1× per pair** (or on instrument change) | tick/step/min_notional |
| `GET /v1/me` | **0** | **0** | **1× at start + ~1 h** | tier/RPM/expiry, not for the quota counter |
| `GET /v1/indicators/{symbol}` | **1** | **1** | cache / reconnect; not routine polling | **1m only**; mid/HTF — `/v1/history?interval=` |
| `GET /v1/status/{symbol}` | **1** | **1** | **≥ 60 s** or do not call — duplicates context/status fields | diagnostics of one pair |
| `GET /v1/derivatives/{symbol}` | **1** | **1** | **≥ 60 s** or do not call — present in context | raw liq events |
| `GET /v1/quotes/{symbol}` | **1** | **1** | **≥ 60 s** or do not call — present in context | venue-only debug |
| `GET /v1/market-regime` | **1** | **1** | **≥ 15–60 min** (server updates ~1 h) | global regime |
| `GET /v1/macro` | **1** | **1** | **≥ 20–60 min**; F&G history — less often | without `fng_days` duplicates context |

**Without WebSocket (fallback):** on `basic` `GET /v1/latest` **every 30–60 s**, dedup by `bar.ts`; `context` **no more often than 60–120 s**. On `free` keep the **~30 min** guideline for `context`. WS is preferable — it saves requests and RPM.

**Example daily budget (`basic`, 5000 requests/day):** WS is free. Each paid REST = 1. `latest` every 10 s around the clock ≈ 8640 — will not fit; demo keeps `latest` only on an open tab + `context` every 15–30 min. Load (`daily_load`) is higher in that case (context weighs 3), but the stop is only by request count.

### Demo-client profile (`examples/web-client`)

Reference implementation of the “golden mean” for a shared demo key (~2000 requests/day on `free`):

| Data | Endpoint | Interval | Requests | Load |
|--------|----------|----------|---------|----------|
| 1m `bar_close` | `WS /v1/stream` | ~1/min | **0** | **0** |
| Ticker | `GET /v1/latest` | **10 s** | **1** | **1** |
| Depth, X-ray, showcase | `GET /v1/context` | **60 s** | **1** | **3** |
| Symbols | `GET /v1/symbols` | **3 min** | **0** | **0** |
| Calendar | `GET /v1/calendar` | **20 min** | **0** | **0** |
| History (chart) | `GET /v1/history` | cache **5 min** | **1** | **3** |

Showcase widgets (traffic light, vol, funding, venues…) are **UI only**, data from already loaded `context`; no extra REST.

### Live DEMO/REAL 24/7 (Basic 2 WS / Pro 3 WS)

One API key = several **parallel** WebSockets. Each socket independently receives the same `bar_close` broadcast (1m + indicators). Upgrade `/v1/stream` does **not** go through RPM/`min_interval` and does **not** debit `daily`. It is cut only by socket count (`max_ws`).

REST from these bots is the opposite, **one** pipe per key: `min_interval` between any two HTTP, shared RPM, shared `daily`.

WS does **not** send closes of `5m`/`15m`/`1h`. Live loop:

| Bot TF | Trigger | Paid REST on bar close | `/v1/context` |
|---------|---------|--------------------------------|---------------|
| **1m** | WS `bar_close` (0) | not needed | only **before entry** (`trade_cost` / `cost_risk` / quality). Not every 1m. |
| **5m** | WS 1m, last minute of the bucket | `GET /v1/history?interval=5m&limit=1` (no `htf.m5`) | before entry |
| **15m** | same, 15m bucket | history `interval=15m` **or** `context.htf.m15` if a snapshot is enough | before entry |
| **1h+** | higher-TF bucket | history `interval=1h`… or `context.htf.h1/h4/d1` | before entry |

Budget for a **24h day**, up to **3 pairs** per bot, without polling `latest` (ticker not needed — the bar is already in WS):

| TF | REST/bot/day (3 pairs) | Basic × 2 WS | Pro × 3 WS |
|----|------------------------|--------------|------------|
| 1m (WS + context only on entry, reserve 100 entries) | ~120 | ~240 | ~360 |
| 15m (history on every close + entry) | ~350 | ~700 | ~1050 |
| **5m** (the “hungriest” live) history on close | ~900 | ~1800 | ~2700 |
| 5m + context on every close (naive) | ~1800 | ~3600 | ~5400 |
| Reserve reconnect / warmup / depth | +200 | +400 | +600 |

Total with 1.5× reserve: **Basic 5000** is enough for 2 live bots even on naive 5m+context. **Pro 8000** — for 3 bots. Do not budget `latest` every 10 s (that is 8640/bot and will eat any tier).

Do not: `Promise.all` context from three bots in one millisecond — you will get `too_fast`. Shared queue on the key or pause ≥ rec. spacing.

## Core REST API

### Own tier and limits (mandatory for the client)

```http
GET /v1/me
Authorization: Bearer mo_...
```

Returns limits of the **current** client key. Call at start and periodically (for example once an hour) to adjust REST frequency and WS count.

Catalog **`free` / `basic` / `pro`** (who gets which, no price) — section **“Client tiers”** above. Here only a snapshot of **this** key: `tier`, `daily_*`, `rpm`, `min_interval_ms`, `max_ws`. The name `standard` does not exist.

Example response:

```json
{
  "status": "ok",
  "key_id": "key_ab12…",
  "name": "home-bot",
  "key_prefix": "mo_41ec884",
  "tier": "basic",
  "expires_at": "2026-08-16T00:00:00+00:00",
  "expired": false,
  "created_at": "2026-07-16T04:00:00+00:00",
  "last_used_at": "2026-07-16T06:30:00+00:00",
  "daily_used": 420,
  "daily_limit": 5000,
  "daily_remaining": 4580,
  "daily_load": 1260,
  "rpm": 60,
  "min_interval_ms": 200,
  "max_ws": 2,
  "ws_open": 1,
  "weights": {
    "default": 1,
    "context": 3,
    "history": 3,
    "history_batch_per_symbol": 3,
    "bootstrap": 10
  }
}
```

How to use:

| Field | Client strategy |
|------|-------------------|
| `tier` | `free` / `basic` / `pro`. Who gets which — section **“Client tiers”**. Do not confuse with the obsolete name `standard` |
| `daily_used` / `daily_limit` / `daily_remaining` | **paid REST requests** per UTC day (1 HTTP = 1) |
| `daily_load` | sum of weights for the same day; do **not** compare with `daily_limit` |
| `daily_remaining` | if `< 20%` of `daily_limit` — rarer `context`, lean more on WS |
| `rpm` / `min_interval_ms` | do not send REST faster than these limits; client guideline — the tier’s rec. spacing |
| `max_ws` / `ws_open` | do not open more than `max_ws` sockets |
| `expires_at` / `expired` | warn the operator in advance about renewal |
| `weights.context` | load of one `GET /v1/context` (not the daily cost) |

**Daily = requests:** `daily_used` / `daily_limit` / `daily_remaining` are counted in paid REST pieces, not in weights. `GET /v1/context` debits **1** from the limit and **3** into `daily_load` (if `weights.context=3`). Batch of N pairs = **1** request and **N × history_batch_per_symbol** load.

After any successful client REST request read
`X-Quota-Used|Limit|Remaining|Cost|Weight|Reset`: this is the current snapshot already including
the current request (`Cost` = 0 or 1, `Weight` = call load). Do not poll `/v1/me` just to refresh the counter.
`/v1/me`, `/v1/symbols`, `/v1/meta/*`, `/v1/calendar` do not consume daily requests,
but remain under auth + RPM/min-interval.

On JSON field `status` / `code` = `key_expired` on any route — stop trading and request a new/renewed key from the operator. Do not confuse with the HTTP status code (see the errors section).

### Server check

```http
GET /health
```

Important fields: `status`, `redis`, `ws_connected`, `active_provider`, `failover`, `bars`, `cvd`, `bar_lag_sec`, `providers`, `uptime_sec`.

`ws_connected == true` means the **current writer** (`active_provider`) is connected, not “at least one provider in the list”. This is not a guarantee of a fresh tick on every pair: look at `bars.lag_sec` / per-symbol `lag_sec`.

Writer chain: `binance:spot` → `bybit:spot` → `okx:spot`. While `bars.status == "ok"`, OHLC is continuous (Redis keys remain `binance:spot:{PAIR}`). `failover == true` = not Binance writing; `cvd.status` at that moment is `down` (`reason: "failover"`). Microstructure is not written on Bybit/OKX.

Each `providers` element has: `connected`, `last_status_ts`, `reconnect_count`. `reconnect_count` increases on an actual disconnect/reconnect, but not on ordinary initial `ws_connecting`. The value is stored in Redis, so after an upgrade from an old version the previous accumulated counter may remain.

### Symbol list

```http
GET /v1/symbols
Authorization: Bearer mo_...
```

For each pair returned:

- `symbol`, `exchange`, `market`;
- `bars_1m` — count of stored minute bars;
- `span_hours` — approximate history depth;
- `ready` — **≥200** closed 1m bars (EMA200 warmup threshold). This is **not** the same as `indicators_ready`;
- `history` — `warming` / `ready` / `full_day` (≥1440 bars);
- `last_closed_ts` — timestamp of the last closed bar (**ms UTC**);
- `lag_sec` — age of that bar: `(now − last_closed_ts) / 1000`. No bars → field absent.

The list is built only from the Oracle registry (DB/Redis), without a live exchange request. Do not treat a pair as fit for trading analysis merely because it is in the list. Check `ready`, `lag_sec`, and `indicators_ready`.

### Detailed symbol status

```http
GET /v1/status/BTCUSDT
Authorization: Bearer mo_...
```

Important fields: `registered`, `bar_count`, `history`, `last_closed_ts`, `lag_sec`, `indicators_ready`, `data_quality`, `active_provider`, `failover`.

**`lag_sec` (age of the closed bar, not exchange RTT):**

```text
lag_sec = (now_ms − bar.ts) / 1000
```

`bar.ts` is the **open time** of the last already closed 1m candle. This is not “Binance lagged N seconds” and not the client’s network RTT: it is the age of the canonical closed bar relative to Oracle server clocks (ingest + Redis + open-ts semantics).

For 1m mid-minute **~60–120s is expected** (the next minute has not closed yet). This is a normal healthy stream, not an incident.

| `lag_sec` (1m) | Meaning for the agent |
|----------------|------------------|
| ≤ 120 | normal / info |
| > 120 | caution — the next 1m candle is late |
| > 180 | skip / do not open new positions on the pair |

Cross-check with `data_quality.flags` (`lag_sec>120`, `lag_sec>150`, `lag_sec>180`) and `data_quality.score`. Live mid — from `ticker` / quotes; **signal only** from `bar` with `closed: true`.

**`data_quality` (Phase 11):** `{ "score": 0..100, "flags": ["lag_sec>120", ...] }`. Score is assembled from lag (thresholds above), ready/indicators, history, failover, fresh venues / divergence / reconnects and freshness of live components. Possible freshness flags: `ticker_stale`, `depth_stale`, `macro_stale`, `market_regime_stale`. Flag `failover` lowers score by 10 — this is not a ban on 15m without CVD if `/health.bars.status == "ok"`; for CVD look at `/health.cvd`, not only this flag. For a new trading decision **require `score >= 70`**; `lag_sec > 120` hard-caps score at 69, so such a bar fails the gate regardless of other feeds.

**`ready` vs `indicators_ready`:**

| Flag | Condition | Meaning |
|------|---------|--------|
| `ready` | `bar_count ≥ 200` | Enough history for EMA200 / “warmed” analysis |
| `indicators_ready` | last bar has the **base set**: `ema20` + `rsi14` + `atr14` | Core is already computed; at `bar_count == 150` it can be `true` even if `ready == false` |

For strategies with EMA200 require **both**: `ready == true` and `indicators_ready == true`. For short strategies on ema20/rsi/atr `indicators_ready` is enough.

Before creating a signal reject stale data by the `lag_sec` table above (and/or by `data_quality`).

### Initial synchronization

```http
POST /v1/sync/bootstrap
Authorization: Bearer mo_...
Content-Type: application/json

{
  "symbols": ["BTCUSDT", "ETHUSDT"],
  "days": 7,
  "include_indicators": true,
  "include_microstructure": true,
  "limit_per_symbol": 10080
}
```

Empty or missing `symbols` means all registered pairs. `days` is limited to the range 1–30, but actually available depth depends on `redis.ttl_days`. Maximum response per symbol is 10080 bars. One POST = **1** quota request (regardless of symbol/bar count); `daily_load` gets flat `weights.bootstrap` (usually **10**).

The dump is **only live interval `1m`**. Series `5m`…`1w` are not returned by bootstrap: they are written by background TF-sync; the client reads `GET /v1/history?interval=…` (or batch with the same `interval`).

The response contains array `symbols`; each element has `symbol`, `exchange`, `market`, `count`, `last_closed_ts`, `bars`. With `include_microstructure=true` array `microstructure` is also returned, which the client joins to bars strictly by `symbol + ts`. Historical microstructure is not loaded from Binance retroactively: the field contains only minutes collected by Oracle after version 0.2.1 started.

Synchronization is incremental: Oracle checks timestamps in Redis and talks to Binance only for head/tail/internal gaps. Already stored continuous ranges are not re-downloaded. For a recently added instrument Oracle remembers the actual start of available history and does not request time before listing on every restart.

### History of one pair

```http
GET /v1/history/BTCUSDT?interval=1m&limit=500
Authorization: Bearer mo_...
```

Additional query parameters:

- `interval` — `1m` (default), `5m`, `15m`, `30m`, `1h`, `4h`, `1d`, `1w`;
- `from` and `to` — Unix timestamp in **milliseconds UTC**;
- `limit` — 1..10080;
- `market=spot`;
- `exchange=binance`.

Available depth by interval (higher TFs are synced as native Binance REST candles, not aggregated from minutes):

| Interval | Depth | Indicators on bars |
|----------|---------|--------------------|
| `1m` | `redis.ttl_days` (code fallback **7**; in shipped `config.toml` usually **60**) | yes |
| `5m` | 60 days | yes (including adx/vwap/cvd) |
| `15m` | 90 days | yes |
| `30m` | 120 days | yes |
| `1h` | ~9 months (`timeframes.1h.ttl_days`, default 270) | yes (same core as on 1m) |
| `4h` | 2 years (default 730) | yes |
| `1d` | 3 years (default 1095) | yes |
| `1w` | 5 years (default 1825) | yes |

For multi-timeframe analysis request the needed intervals via `/v1/history` (or `POST /v1/history/batch` with the same `interval`). After warmup the **full catalog** is already in `bar.indicators` — do not recompute EMA/RSI/ADX/BB on the client. Prefer native `5m`/`15m`/`30m` over aggregation from `1m`. Unknown `interval` is not hard-validated by history: a wrong id returns an empty series, not `400`.

### Book microstructure (v0.2.1)

Oracle uses the already existing Binance `depth20@1000ms`; no new REST calls or extra WS streams to Binance are created. Raw snapshots are not stored in Redis. For each closed UTC minute a compact `MicrostructureBar` is stored with the same `ts` as the 1m bar.

```http
GET /v1/microstructure/BTCUSDT?interval=1m&from=1783574400000&to=1784179200000&limit=10080
Authorization: Bearer mo_…
```

```http
GET /v1/microstructure/BTCUSDT/latest?interval=1m
Authorization: Bearer mo_…
```

History returns `{ symbol, exchange, market, interval, from, to, count, items }`, `items` sorted by `ts` ascending. In v1 only `interval=1m` is supported, maximum 10080 elements. Retention matches `redis.ttl_days` (the same TTL as 1m bars).

Query parameters: `interval=1m`, `from`, `to`, `limit=1..10080`, `exchange=binance`, `market=spot`. `from >= to` returns `400 bad_range`, another interval — `400 bad_interval`. The latest endpoint returns one `MicrostructureBar` directly; until the first collected minute the response is `404 microstructure_not_ready`. Both routes are ordinary Bearer auth, **1** quota request + load like history (usually **3**), the same RPM/`min_interval_ms` as `/v1/history`.

Canonical form of `MicrostructureBar`:

```json
{
  "symbol": "BTCUSDT",
  "exchange": "binance",
  "market": "spot",
  "interval": "1m",
  "ts": 1784179200000,
  "closed": true,
  "sample_count": 58,
  "source_update_count": 21,
  "expected_samples": 60,
  "coverage_pct": "96.7",
  "spread": {
    "avg_pct": "0.000307",
    "max_pct": "0.000615",
    "close_pct": "0.000292"
  },
  "imbalance": {
    "open": "0.61",
    "high": "0.84",
    "low": "0.52",
    "close": "0.58",
    "avg": "0.69",
    "pressure_integral_above_0_65": "3.42",
    "seconds_above_0_65": 38
  },
  "bid_wall": {
    "price": "65000",
    "qty": "12.5",
    "notional": "812500",
    "distance_pct": "0.063",
    "significance": "0.0101",
    "first_seen_ts": 1784179210000,
    "last_seen_ts": 1784179255000,
    "persistence_ms": 45000,
    "sample_hits": 45,
    "qty_min": "11.8",
    "qty_max": "13.1",
    "qty_change_pct": "4.8",
    "touched": true,
    "survived_touch": true,
    "pulled_before_touch": false
  },
  "ask_wall": null,
  "wall_ratio": {
    "open": "1.42",
    "close": "2.68",
    "min": "1.20",
    "max": "3.10",
    "change_pct": "88.732394"
  },
  "quality": {
    "score": 85,
    "flags": ["missing_ask_wall"]
  }
}
```

Sampling and formula semantics:

- Oracle snapshots the last confirmed top-20 exactly once per second while the active Binance WS is healthy. This is local sampling of an already received book, not a new request to Binance;
- `sample_count` — number of 1Hz states used; `source_update_count` — number of real Binance depth updates inside the minute. For a quiet pair the second value can reasonably be 10–35 with `sample_count≈60`;
- `spread_pct = (best_ask - best_bid) / mid × 100`;
- `imbalance = bid_qty_top20 / (bid_qty_top20 + ask_qty_top20)`, range 0..1;
- `pressure_integral_above_0_65 = Σ max(0, imbalance_i - 0.65) × delta_seconds_i`, each `delta` capped at 2 seconds;
- a wall is chosen and tracked by visible notional `price × qty`; a neighboring price keeps identity when shifted by no more than `max(2 × tick_size, mid × 0.0005)`;
- `significance = wall.notional / avg_quote_volume` of the previous 20 closed 1m bars; until warmup the value is `null`;
- `wall_ratio` is computed in each snapshot as `largest_bid_wall_notional / largest_ask_wall_notional`, then aggregated over the minute;
- `pulled_before_touch=true` means only disappearance of a visible level before a touch without reappearing nearby within 5 seconds. This is not proof of spoofing or participant intent.

Decimal fields are serialized as strings. When `sample_count < 40` unreliable spread/imbalance/wall/ratio are `null`, and score is capped at 49. For a depth-entry require all of:

```text
sample_count >= 40
coverage_pct >= 66.7
quality.score >= 70
microstructure.ts == bar.ts
microstructure.closed == true
```

Possible quality flags: `low_coverage`, `depth_stale`, `provider_changed`, `wall_identity_reset`, `quote_volume_warming`, `missing_bid_wall`, `missing_ask_wall`, `spread_above_limit`.

`depth_stale` caps score at 69; `provider_changed` and `low_coverage` — at 49; `spread_above_limit` is set when max spread is above 0.5%. No-lookahead is guaranteed by bucket `[bar.ts, bar.ts + 60000)`: a snapshot with the next minute’s timestamp does not enter the current object. On failover to a provider without a compatible top-20 the unfinished bucket is discarded; ordinary `bar_close` continues to arrive.

Redis key: `mo:microstructure:{exchange}:{market}:{symbol}:1m`. One immutable member per minute (internal binary V1 or legacy JSON), ZSET score equals `ts`, TTL = shared `redis.ttl_days`. HTTP `/v1/microstructure` is still JSON. See [REDIS-BINARY-STORAGE.md](REDIS-BINARY-STORAGE.md). History before v0.2.1 deployment is not restored: the Binance kline API has no historical book path.

Retention of 1m/microstructure is set by `redis.ttl_days` (code fallback **7**, shipped config usually **60**). One day (1440 points/symbol) is a smoke-check of ingestion/coverage; seven days (10080) is the minimum backtest cycle with weekdays; 60 days (~86400 points) covers several liquidity regimes at moderate Redis volume, because raw snapshots are not stored.

### Current top-20 for REAL execution (v0.3.0)

```http
GET /v1/depth/BTCUSDT
GET /v1/depth/BTCUSDT?seconds=3
Authorization: Bearer mo_…
```

- without `seconds` or `seconds=0` — only the last 1Hz top-20 sample;
- `seconds=1..10` returns a RAM window in time order, including the current sample;
- the endpoint reads already received data and **does not call Binance REST/WS**;
- data exists only in RAM, at most 15 samples per symbol, and never enters Redis history (`redis.ttl_days`);
- **1** quota request, load `weights.default` (usually 1); ordinary auth, RPM and `min_interval_ms` apply;
- until the first healthy sample the response is `404 depth_not_ready`; `seconds > 10` — `400 bad_seconds` (`seconds` allowed in range **0..10**).

```json
{
  "symbol": "BTCUSDT",
  "exchange": "binance",
  "market": "spot",
  "sample_interval_ms": 1000,
  "requested_seconds": 3,
  "count": 4,
  "items": [
    {
      "symbol": "BTCUSDT",
      "exchange": "binance",
      "market": "spot",
      "provider": "binance:spot",
      "sampled_ts": 1784179202000,
      "source_received_ts": 1784179201250,
      "source_age_ms": 750,
      "last_update_id": 123456789,
      "metrics": {
        "ts": 1784179202000,
        "best_bid": "65000",
        "best_ask": "65000.1",
        "spread_pct": "0.000154",
        "bid_volume": "18.4",
        "ask_volume": "15.9",
        "imbalance": "0.536443",
        "bid_wall": null,
        "ask_wall": null,
        "levels": 20
      },
      "snapshot": {
        "received_ts": 1784179202000,
        "source_received_ts": 1784179201250,
        "last_update_id": 123456789,
        "bids": [{ "price": "65000", "qty": "1.2" }],
        "asks": [{ "price": "65000.1", "qty": "0.8" }]
      }
    }
  ]
}
```

To detect new volume, compare `last_update_id` and same-price levels in neighboring `items`. The same `last_update_id` means Oracle re-sampled an unchanged confirmed book.
A large `source_age_ms` by itself does not mean stale: on a quiet book Binance does not send a new partial-depth payload until top-20 changes. On a real disconnect the cache is invalidated immediately and the endpoint stops returning the old book.

This is an **execution-filter after the signal**, not a historical feature of the closed candle. If the decision is taken at `bar.ts + 2s`, record that latency in the strategy. A sandbox on minute microstructure must not pretend it already knew post-close top-20; absence of a historical post-close snapshot only allows modeling the signal without this live filter.

### History of several pairs

```http
POST /v1/history/batch
Authorization: Bearer mo_...
Content-Type: application/json

{
  "symbols": ["BTCUSDT", "ETHUSDT"],
  "interval": "1m",
  "limit": 500
}
```

You may pass `from` and `to` in milliseconds. Maximum 20 symbols per request and 10080 bars per symbol. `interval` is the same set as `/v1/history` (`1m`/`5m`/`15m`/`30m`/`1h`/`4h`/`1d`/`1w`). One POST = **1** quota request (not N); load = `N × history_batch_per_symbol` (usually 3×N).

### Latest data

```http
GET /v1/latest?symbols=BTCUSDT,ETHUSDT
Authorization: Bearer mo_...
```

Maximum 50 pairs; empty `symbols` means all registered.

**Response shape is always the same**, even if one symbol was requested:

```ts
{ ts: number; symbols: Record<string, { bar, ticker, indicators_ready }> }
```

You cannot read `response.bar` — only `response.symbols["BTCUSDT"].bar` (or iterate `Object.keys(response.symbols)`).

Inside each pair:

- `bar` — last closed bar with indicators;
- `ticker` — current price and 24-hour statistics;
- `indicators_ready` — base set (`ema20`+`rsi14`+`atr14`) is present (see the table above).

`ticker` may have a timestamp newer than the bar and is intended for market display. The strategy signal must still be formed on the closed `bar`.

### Indicators only

```http
GET /v1/indicators/BTCUSDT?limit=200
Authorization: Bearer mo_...
```

Supports `from`, `to`, `limit` (up to 10080). **Interval is always `1m`** (no `interval` query). For `5m`/`15m`/HTF take `/v1/history/{symbol}?interval=…` and read `bars[].indicators`. Returns items `{ "ts": ..., "indicators": ... }`.

### Combined context (recommended main request)

```http
GET /v1/context/BTCUSDT
Authorization: Bearer mo_...
```

**This is the main endpoint for a trading bot/agent.** One request instead of 8–10.
Returns everything needed for a decision on a closed bar:

| Field | Purpose |
|------|------------|
| `context_scope`, `historical_safe` | always `"live_snapshot"` and `false`: the response is a current snapshot, not a historical data row |
| `bar` | closed **1m** + indicators (1m-strategy signal). For `5m`/`15m`/`1h` the signal is native `/v1/history?interval=`, not this 1m bar |
| `ticker` | 24h volume / change / bid-ask (display and sizing, not the signal) |
| `current_bar` | **not yet returned by the API** (planned). When it appears — unclosed “bar 0” for execution simulation / emergency exit. ⚠️ **NOT FOR SIGNALS** — signal only on `bar` with `closed: true` |
| `depth` | spread / imbalance / walls for fill simulation (feeds `trade_cost` when `freshness.depth.fresh`) |
| `trade_cost` | unified round-trip `c_spot` (preset fees + half_spread + slip). **REST context only, not WS** |
| `cost_risk` | `c_r`, `p_be`, `tradable` — “pair too expensive” gate. **REST context only** |
| `htf` | last closed bars `m15`/`h1`/`h4`/`d1` with indicators (live as-of). For replay — `/v1/history?interval=` |
| `bar_count`, `ready`, `history`, `lag_sec`, `indicators_ready` | whether the pair can be traded (`ready` ≠ `indicators_ready`, see above; `lag_sec` is closed-bar age from open ts, see the status section) |
| `data_quality` | Phase 11: `{ score: 0–100, flags: string[] }`. Prefer trading when `score >= 70`; low score — skip / paper |
| `freshness` | availability, age, and freshness threshold of current `ticker`, `depth`, `macro_snapshot`, `market_regime`; check before using a live component |
| `volatility_regime` | Phase 11: copy of this pair’s label from market-regime (`low_vol_range` / `breakout_expansion` / `high_vol_mean_reversion` / `crisis`) — strategy regime choice, not a signal |
| `decoupling_detected` | Phase 11: short BTC-corr dropped sharply vs 7d — the pair “lives its own life” |
| `macro_event_soon`, `next_macro_event` | news-window risk (**from now**, not as-of; for sandbox — `/v1/calendar?as_of=`) |
| `derivatives`, `liquidations` | funding overheat / liquidation cascade |
| `macro_snapshot` | F&G, dominance, stables, USD index, 10Y (see timestamps / age below) |
| `cross_exchange` | median mid Binance/Bybit/OKX, stale, divergence bps |
| `market_regime` | correlation to BTC, vol, breadth + per-symbol fields. Do **not** confuse with `htf_trend.trend_z` / `rs_rank` (they are not in Oracle yet) |
| `active_provider`, `failover` | who writes canonical 1m now. `failover=true` ⇒ CVD from this bar is invalid; OHLC on 15m can be used when `/health.bars.status == "ok"` |

#### `context.htf` (live)

```json
"htf": {
  "m15": { "ts": 0, "open": "…", "close": "…", "indicators": { "ema20": "…", "adx14": "…" } },
  "h1":  { },
  "h4":  { },
  "d1":  { }
}
```

Each field is the last **closed** bar of that interval (or absent until the series is warmed). Indicators are the same as in `/v1/history`. Fields `m5` / `m30` / `w1` are **absent** — for those only history. For NY Open / H1-confluence in **live**. In sandbox do not copy `htf` onto past bars — load history and take the last bar with `ts <= decisionBar.ts`. After a 15m/1h close a new snapshot in Redis can take up to ~60 s (TF-sync tick + REST).

Query override fees (sandbox = Lab `commissionPct`):
`GET /v1/context/BTCUSDT?fee_buy=0.001&fee_sell=0.001&slippage=0.0002&k_sl=2&rr=2&tradable_c_r_max=0.45`

It is **forbidden** to apply one `/v1/context/{symbol}` response to several historical bars, mix its live fields into a backtest, or replicate the current context along a historical timeline.

Historically safe are only data bound to observation time:

- the bar’s own OHLCV and `indicators` embedded in that bar (on any `interval`);
- mid/HTF history from `/v1/history?interval=…`, joined **as-of** (higher-TF `bar.ts` ≤ decision bar.ts, no look-ahead);
- Fear & Greed history from `/v1/macro?fng_days=...`, as-of `fng.ts <= bar.ts`;
- macro calendar `/v1/calendar?as_of=bar.ts&window_min=60&high_only=true` (events in the window after the anchor; `historical_safe=true` in the response).

Live/current snapshot only: `ticker`, `depth`, `trade_cost`, `cost_risk`, **`htf`**, current dominance/macro snapshot, `market_regime`/volatility regime, `derivatives`/`liquidations`, `cross_exchange`, `macro_event_soon` (from `now`) and provider/failover status. Historical research of those factors needs timestamped history / as-of APIs above.

Do not trade if `ready == false`, `indicators_ready == false`, `data_quality.score < 70`, `lag_sec > 120`,
`cost_risk.tradable == false`, `macro_event_soon == true`, or `cross_exchange.divergence_warning == true`
without extra checks. `lag_sec > 120` automatically keeps `data_quality.score` below 70; wait for a fresh `bar_close`.

Needed separately only: live `WS /v1/stream`, history `/v1/history`, lot filters `/v1/meta`,
full calendar `/v1/calendar`, F&G history `/v1/macro?fng_days=`.

Depth metrics (updated once per second, TTL 120 seconds — a stale book is not served):

- `best_bid` / `best_ask`, `spread_pct` — to estimate slippage in the simulator;
- `imbalance` — 0..1, bid share of visible volume; > 0.5 = buyer pressure;
- `bid_wall` / `ask_wall` — largest limit level of each side with `distance_pct` from mid.

Futures data in context:

- `derivatives.snapshot`: mark/index price, predicted funding, next funding time, open interest;
- `derivatives.funding_7d`: settled funding sample count, `funding_ma_7d`, `funding_std_7d`;
- `derivatives.stale`: `true` if the mark stream has not updated for more than 10 minutes;
- `liquidations.windows.five_min` / `one_hour`: `count`, `total_long_qty` / `total_short_qty`, `total_long_value` / `total_short_value`.

Futures data is used only as context for a spot decision; Oracle does not trade futures.

### Detailed derivatives context

```http
GET /v1/derivatives/BTCUSDT?liquidation_limit=20
Authorization: Bearer mo_...
```

Returns the same derivatives/liquidation windows and up to 100 latest raw liquidation events. Direction is already normalized: `side=long` means liquidation of a long position, `side=short` — a short position. The agent does not need to interpret Binance order side.

If `macro_event_soon == true`, an important macro event (FOMC, CPI…) is within the next 60 minutes — the strategy should pause or tighten risk.

### Macro calendar

```http
GET /v1/calendar?hours=168&high_only=true
Authorization: Bearer mo_...

# Sandbox / replay: high-impact in the next window_min minutes after bar.ts
GET /v1/calendar?as_of=1700000000000&window_min=60&high_only=true
```

Returns economic events: `ts` (ms UTC), `title`, `country`, `impact` (`low`/`medium`/`high`), `forecast`, `previous`. Response fields: `now`, `as_of`, `to`, `count`, `events`, `historical_safe` (`true` if `as_of` is set explicitly).

Updated on the server once an hour; events **accumulate** in Redis (~90 days), the weekly feed no longer overwrites past weeks. After a restart the fresh cache is used until the next refresh.

Live flag `context.macro_event_soon` looks from **current** `now` (+60 min) — in sandbox it must not be replicated across history; for each bar call calendar with `as_of=bar.ts`.

### Global macro snapshot (Phase 7)

```http
GET /v1/macro?fng_days=30
Authorization: Bearer mo_...
```

Response: root `updated_ts` / `lag_sec` / `freshness`, object `snapshot` + optionally `fear_greed_history` (up to 90 days when `fng_days>0`):

```json
{
  "updated_ts": 1784112000000,
  "lag_sec": 100,
  "freshness": {
    "available": true,
    "fresh": true,
    "age_sec": 100,
    "max_age_sec": 7200
  },
  "snapshot": {
    "ts": 1784112000000,
    "fear_greed": { "ts": 1784073600000, "value": 25, "classification": "Extreme Fear" },
    "fear_greed_updated_ts": 1784112000000,
    "btc_dominance_pct": "54.32",
    "eth_dominance_pct": "16.78",
    "total_market_cap_usd": "2345678901234.5",
    "total_volume_24h_usd": "98765432109.8",
    "global_updated_ts": 1784112000000,
    "stablecoin_total_usd": "151000000000",
    "usdt_supply_usd": "112000000000",
    "usdc_supply_usd": "34000000000",
    "stablecoins_updated_ts": 1784112000000,
    "usd_index": "121.4523",
    "usd_index_ts": 1783987200000,
    "us_10y_yield_pct": "4.45",
    "us_10y_ts": 1783987200000
  },
  "fear_greed_history": [ { "ts": 1783987200000, "value": 31, "classification": "Fear" } ]
}
```

**Timestamps:** all `ts` / `*_ts` in the Oracle API are **milliseconds UTC** (13 digits), including `usd_index_ts` and `us_10y_ts`. `fear_greed_updated_ts`, `global_updated_ts`, `stablecoins_updated_ts` change only after a successful response from the corresponding source; overall `snapshot.ts` — if at least one source updated. Therefore a strict strategy should additionally check the timestamp of the needed field, not only overall `snapshot.ts`. The FRED source delivers daily values; do not interpret its granularity as a live stream.

**Staleness:** `/v1/macro` returns `updated_ts`, `lag_sec`, and `freshness` in the same shape as `context.freshness.macro_snapshot` (`available`, `fresh`, `age_sec`, `max_age_sec`). Threshold `max_age_sec` = `2 × refresh_minutes`. For an individual field (F&G / dominance / stables) still look at `fear_greed_updated_ts` / `global_updated_ts` / `stablecoins_updated_ts`.

How to interpret:

- `fear_greed < 20` — extreme fear, historically a zone of reversal up; `> 80` — overheat;
- rising `btc_dominance_pct` — capital leaves alts for BTC, alt longs are risky;
- rising `usdt_supply_usd`/`stablecoin_total_usd` — accumulation of buying power (bullish backdrop);
- rising `usd_index` (FRED DTWEXBGS) — dollar strengthening, risk-off for crypto; a sharp rise → more caution with long;
- rising `us_10y_yield_pct` — more expensive money, pressure on risk assets; >5% historically a headwind for alts;
- update once an hour; data is global, the same for all symbols.

The same snapshot is duplicated as field `macro_snapshot` in `GET /v1/context/{symbol}` — a separate `/v1/macro` request is needed only for Fear & Greed history. If the field is `null` — the poller has not completed the first cycle yet or `[macro].enabled=false`.

### Venue consensus (Phase 8)

```http
GET /v1/quotes/BTCUSDT
Authorization: Bearer mo_...
```

This is a quality check of the canonical Binance price, not an arbitrage signal. Use
only venues with `stale=false`. `consensus_mid` is the median mid of fresh Binance,
Bybit, and OKX; `divergence_bps` shows a given exchange’s deviation from
consensus. When `divergence_warning=true` do not open a new position without
an extra check. `feed.reconnect_count` and `age_ms` help distinguish
a real market divergence from a damaged/stuck feed.

### Market regime from own data

```http
GET /v1/market-regime
Authorization: Bearer mo_...
```

Once an hour Oracle computes only from its own `1h` bars:

- `correlation_btc_7d` / `correlation_btc_24h` — link of the symbol’s returns to BTC;
- `decoupling_detected` — short corr dropped sharply vs 7d (or abs short is very low while 7d is high);
- `decoupling_count` — how many pairs are currently in decoupling;
- `realized_volatility_24h_pct` / `7d` — annualized volatility;
- `volatility_regime` — label: `low_vol_range` | `breakout_expansion` | `high_vol_mean_reversion` | `crisis` (strategy regime, not an entry signal);
- `breadth_above_ema20_pct` / `ema50` — share of the market above its EMAs;
- `median_return_1h_pct` / `24h` — broad-market direction.

Fields `cross_exchange` and `market_regime` are also in
`GET /v1/context/{symbol}` (plus convenient copies `volatility_regime` / `decoupling_detected` for the requested pair). A missing regime means HTF history warmup,
not a spot-feed error. On cold start the calculation waits for native 1h bars and retries after a short interval; an empty snapshot with `symbol_count=0` must not be treated as a ready regime. The client still checks `freshness.market_regime.available/fresh` and presence of the requested pair in `market_regime.symbols`.

### Instrument metadata

```http
GET /v1/meta/BTCUSDT
Authorization: Bearer mo_...
```

Fields: `base_asset`, `quote_asset`, `status`, `tick_size`, `step_size`, `min_notional`, `updated_at`. Oracle cache (lot filters for an order). Does **not** proxy `exchangeInfo` on every GET: if the snapshot is not yet written by bootstrap — `404 meta_unavailable`. Do not compute tick/step from the number of digits in the current price.

## Bar and indicator format

Example structure:

```json
{
  "ts": 1784098980000,
  "open": "0.32730000",
  "high": "0.32730000",
  "low": "0.32720000",
  "close": "0.32720000",
  "volume": "101557.20000000",
  "quote_volume": "33239.33722000",
  "trades": 86,
  "closed": true,
  "taker_buy_volume": "49770.10000000",
  "indicators": {
    "ema20": "0.32718746",
    "ema50": "0.32706362",
    "ema200": "0.32676985",
    "rsi14": "53.3606",
    "atr14": "0.00011636",
    "atr50": "0.00014000",
    "atr_ratio_14_50": "0.831000",
    "vwap": "0.32659678",
    "vol_sma20": "77102.62500000",
    "adx14": "58.6860",
    "plus_di14": "32.1000",
    "minus_di14": "18.4000",
    "di_side": "1",
    "obv": "4214111.1000",
    "mfi14": "84.3805",
    "cvd_delta": "-2017.00000000",
    "ema50_slope_pct": "0.012000",
    "ema_slope_abs": "0.012000",
    "volume_usd": "33239.33722000",
    "volume_rate": "33239.33722000",
    "bb_mid": "0.32700000",
    "bb_upper": "0.32850000",
    "bb_lower": "0.32550000",
    "bb_width": "0.009174",
    "body": "0.500000",
    "wick_balance": "0.100000",
    "close_pos": "0.800000",
    "candle_q": "0.410000",
    "range_ratio": "0.859000",
    "atr_pct": "0.035560",
    "last_swing_high": "0.32800000",
    "last_swing_low": "0.32500000"
  }
}
```

Additionally on a fractal confirmation bar: `swing_high` / `swing_low` (pivot price). `last_swing_*` are carried forward on every bar after the first confirmation.

Price, volume, and indicator numbers are serialized as **strings** to preserve precision. In TypeScript do not assume type `number` in the DTO. For money calculations use a decimal library; conversion to `number` is allowed only for charts and approximate analytics.

Indicator fields may be absent during warmup **or on old bars** stored before new keys were deployed. Check `indicators_ready` and presence of the specific value. After deploy, new closed bars fill the fields without a Redis flush; mid/HTF series are recomputed on the next TF sync.

### Indicator catalog (all trading TFs)

The same key set on `1m` and on native mid/HTF after warmup. `indicators_ready` = presence of the **base** set `ema20` + `rsi14` + `atr14` (not all fields below). During warmup individual fields are `null` (do not substitute `0`). All values depend on TF: EMA200 on `15m` is 200 fifteen-minute bars (~50 hours), not the same 200 minutes as on `1m`. Below is an extended breakdown for a human and an AI agent: what the indicator measures, how to read values, and how to apply it in a trading bot, entry alerter, or screener.

Short summary:

| Key | Meaning in one word |
|------|-------------------|
| `ema20` / `ema50` / `ema200` | trend (fast / medium / slow moving average) |
| `rsi14` | momentum-overbought (0–100) |
| `atr14` / `atr_pct` | volatility in price and in % |
| `atr50` / `atr_ratio_14_50` | volatility norm and squeeze/expansion |
| `vwap` | daily price anchor (UTC-day reset) |
| `vol_sma20` / `volume_usd` / `volume_rate` | bar volume and liquidity |
| `adx14` / `plus_di14` / `minus_di14` / `di_side` | trend strength and its direction |
| `obv` / `mfi14` | accumulation/distribution and money flow |
| `cvd_delta` | aggressive flow per bar (taker buy − sell) |
| `ema50_slope_pct` / `ema_slope_abs` | trend slope, flat detector |
| `bb_mid` / `bb_upper` / `bb_lower` / `bb_width` | Bollinger 20,2: channel and squeeze |
| `body` / `wick_balance` / `close_pos` / `candle_q` / `range_ratio` | candle shape and quality |
| `swing_high` / `swing_low` / `last_swing_*` | fractal levels with a 2-bar lag |

#### `ema20` / `ema50` / `ema200` — exponential moving averages of close

EMA smooths noise and shows trend direction: price above EMA — buyers stronger, below — sellers. Short `ema20` reacts quickly and suits bounce entries or a cross with `ema50`; long `ema200` is the “bull/bear market boundary”, below which longs without higher-TF confirmation are risky. In a bot three typical techniques: filter “trade long only above EMA200 of its TF”, `ema20/ema50` cross as a momentum trigger, bounce off EMA after a wick test. Pitfalls: EMA200 appears only after 200 bars of **this interval** — on `15m` that is ~2 days of native history; check field presence, not `ready` of the 1m series.

#### `rsi14` — relative strength index, 0–100

RSI shows how “overheated” momentum is: values above 70 are traditionally read as overbought, below 30 as oversold, and the middle ~50 as balance. For a trading bot this is not a “buy/sell” button but a mean-reversion filter: RSI returning from an extreme toward the middle confirms fading momentum, while RSI stuck above 60 with rising price is a strong-trend sign where a countertrend short is dangerous. An entry alerter usually waits not for the level itself but for the combo “RSI left the zone + price held EMA/VWAP + volume above norm”. During a strong trend RSI can sit in an extreme for a long time — do not place a blind countertrend only on `rsi14 > 70`.

#### `atr14` / `atr_pct` — average true range and its share of price

ATR measures the “usual” candle size over 14 bars and answers how much price actually moves, regardless of direction. `atr_pct = atr14 / close × 100` normalizes this to price, so pairs can be compared: `0.05%` on 1m is calm BTC, `0.5%` is a pumped alt. Bots use ATR for stops (`stop = k × ATR`, in Oracle `d_sl = k_sl × ATR/close`), position size (risk as % of ATR), and a noise filter (`range_ratio = range / ATR << 1` — skip sluggish bars). `trade_cost`/`cost_risk` in `context` already compute the ratio of fees to the ATR stop — do not hardcode your own ATR in parallel.

#### `atr50` / `atr_ratio_14_50` — long volatility norm and squeeze

`atr50` is the same ATR but over 50 bars: “what volatility is normal here”. Ratio `atr_ratio_14_50 = atr14 / atr50` below ~0.8 means the market has compressed vs its own norm (squeeze, energy building), above ~1.2 — expansion, impulse already underway. Screeners like the condition “ratio crossed up from below + Bollinger break + volume ×2 vs SMA” as a breakout-signal stub, while countertrend systems skip entries when ratio >> 1. The field appears later than base `atr14`; check `null` in the first dozens of TF bars.

#### `vwap` — session volume-weighted price anchor (reset at 00:00 UTC)

VWAP shows where the day’s main trade went with volume, so institutional algorithms measure execution against it: above VWAP — the day is with buyers, below — with sellers. An intraday bot uses it as a dynamic level: bounce off VWAP in the direction of the daily trend — entry; stuck under it on a falling day — long ban. Important: on `5m`/`15m`/`1h` this is a correct daily VWAP, on `1d` — VWAP of the daily candle itself, and on `1w` it is **not** weekly VWAP (reset is still every UTC day) — it cannot be treated as a weekly anchor.

#### `vol_sma20` / `volume_usd` / `volume_rate` — volume and liquidity

`vol_sma20` is the 20-bar average volume base, the “much/little” reference. `volume_usd` is the bar’s money in dollars (`quote_volume`), answering whether you can even get filled here without slippage. `volume_rate = volume_usd / minutes_of_interval` ($/min) makes volumes comparable across TFs: a 1m bar of $30k and a 15m bar of $450k are the same pace. Bots filter entries with “current $/min ≥ 1.5–2 × SMA”, alerters highlight a volume spike as breakout confirmation, and screeners sort pairs by `volume_rate` to pick liquid ones. On fresh pairs until SMA warmup it may be absent — then use absolute `volume_usd`.

#### `adx14` — trend strength without direction (0–100)

ADX answers “is there a trend”, not “where”: values up to ~20–25 — flat/chop, 25–30 — movement forming, above 30 — a clear trend, above 50 — strong acceleration. Grid and mean-reversion bots trade only when ADX ≤ 25 on their TF; trend bots — only when ADX ≥ 25–30 plus DI confirmation. ADX is computed with Wilder smoothing and appears after about 27 TF bars — until then the field is `null`, and that is normal, not “zero trend”.

#### `plus_di14` / `minus_di14` / `di_side` — trend direction

The DI pair adds a side to ADX: `plus_di14 > minus_di14` — buyer pressure, the reverse — sellers, and string `di_side` folds this to `"1"` (long), `"-1"` (short), `"0"` (tie). Typical filter: `adx14 ≥ 25 AND di_side == "1"` allows longs only; a `di_side` change at high ADX is an early reversal sign. Do not read ADX as direction: ADX=40 with `di_side="-1"` is a strong downtrend; longing it “because ADX is high” is a mistake.

#### `obv` — on-balance volume (cumulative)

OBV adds bar volume with the close’s sign (up — plus, down — minus) and shows whether the asset is being accumulated or distributed: rising OBV with sideways price — bullish divergence, falling with rising price — weakness. In a bot OBV is confirmation, not a trigger: a level break with rising OBV is taken, without it — skipped. The absolute OBV number is meaningless by itself (depends on history); slope and divergences matter; at history start the series is short — wait for accumulation.

#### `mfi14` — money flow index, 0–100 (volume RSI)

MFI is like RSI but weights the move by volume: values above 80 — overbought with money, below 20 — oversold. Alerters use it as a second vote to RSI: “RSI > 70 AND MFI > 80” is more reliable overheat than RSI alone, and a bullish MFI divergence (price lower, MFI higher) is an early long signal. On thin pairs MFI is noisier than RSI — confirm with a volume filter (`volume_rate`) and do not build an entry on MFI alone.

#### `cvd_delta` — cumulative volume delta for the bar (taker buy − sell)

`cvd_delta` shows who hit the market inside the bar: positive — aggressive buying, negative — selling. Summing it on the client over closed bars yields a CVD curve for divergences with price (price up + CVD down = buying exhausting). Critical: the delta is valid only while Binance is writing (`/health.cvd.status == "ok"`); on failover to Bybit/OKX the field loses meaning as cumulative flow — a CVD bot must stand aside then, while 15m OHLC signals without CVD are allowed when `bars.status == "ok"`.

#### `ema50_slope_pct` / `ema_slope_abs` — trend slope per bar

EMA50 slope in percent shows trend speed and serves as a flat detector: `|slope| ≤ 0.1` on its TF is the classic “chop, enable the grid”; higher — trend regime. Grid bots take this as a regime switch instead of eyeballing; trend bots — as a filter “do not enter a drift without slope”. Sign of `ema50_slope_pct` sets the side, `ema_slope_abs` is the modulus for the threshold; on higher TFs the flat threshold is chosen separately — you cannot copy 0.1 from 1m to 1h.

#### `bb_mid` / `bb_upper` / `bb_lower` / `bb_width` — Bollinger 20,2 and channel width

Bands are SMA20 ± 2σ: a touch of the upper band with a rising channel — strength, walking the band — trend, return inside — fade. Normalized width `bb_width = (U−L)/mid` is the main squeeze meter: N-bar lows + price leaving the band with volume — classic breakout entry. Mean-reversion systems instead trade a return to `bb_mid` only with a wide channel and calm ADX. Bands appear after 20 TF bars; on a thin market false pokes are many — confirm with volume and `range_ratio`.

#### `body` / `wick_balance` / `close_pos` / `candle_q` / `range_ratio` — candle shape and quality

The pack describes “how” the bar closed, not “where”: `body` — body directionality (−1…1), `wick_balance` — lower/upper wick dominance (rejection without named patterns), `close_pos` — where it closed inside the range (0…1), `candle_q = 0.4·body + 0.3·wick_balance + 0.3·(2·close_pos−1)` — a single quality number, `range_ratio = range / ATR` — noise (`<< 1`) or expansion (`>> 1`). Bots cut “empty” dojis (`|body| < 0.2`), require `close_pos > 0.7` for a long and `candle_q` above a threshold as a junk filter. When `range = 0` the fields are absent — that is a degenerate bar, not “zero signal”.

#### `swing_high` / `swing_low` / `last_swing_high` / `last_swing_low` — fractal levels (strength=2)

A fractal records a local extreme with confirmation after 2 bars of **this** TF: fields `swing_high`/`swing_low` are present only on the confirmation bar (lag 2), and `last_swing_*` are carried forward on every bar as current levels. These are ready supports for stops beyond the extreme, Fib targets, breakout entries, and a client-side touch counter. Do not read swing as a signal by itself — it is a level scaffold; an entry is built as “touch/break of the level + bar shape + volume + higher-TF filter”, and the 2-bar lag forbids rewriting in hindsight.

**Do not ask Oracle for:** RSI-reclaim state, touch counter, strategy phases, daily HL channel from storage, MACD/Stoch/Supertrend/Ichimoku (exist in Lab extra), `trend_z` / `rs_rank`, raw depth history / wall_* for book scalping.

### Candle shape and normalized risk (on the bar)

Computed O(1) on bar close; go into `bar.indicators` (and into WS `bar_close` for 1m). `range = high − low`; when `range = 0` shape fields are absent.

| Field | Formula / meaning | How to use in a robot |
|------|-----------------|---------------------------|
| `body` | `(close − open) / range` (signed) | Body directionality; cut “empty” candles |
| `wick_balance` | `(lower_wick − upper_wick) / range` | Rejection / pin without named patterns |
| `close_pos` | `(close − low) / range`, 0..1 | Long is stronger when close is near high |
| `candle_q` | `0.4·body + 0.3·wick_balance + 0.3·(2·close_pos − 1)` | One “shape quality” threshold |
| `range_ratio` | `range / atr14` | Noise (`<< 1`) vs expansion (`>> 1`) |
| `atr_pct` | `atr14 / close × 100` | Risk as % of price; compare pairs with each other |

Do not take a decision from one indicator. Take fee and entry “expensiveness” from `context.trade_cost` / `context.cost_risk`, do not hardcode them in the strategy.

## Edge: `trade_cost` and `cost_risk`

Available **only** in `GET /v1/context/{symbol}` (not duplicated in WS — intentionally, so as not to bloat the stream across 10–30 API keys).

Defaults — section `[trade_cost]` in the instance `config.toml`. There is **no** fee binding to an Oracle API-key: sandbox/DEMO pass their commissions as query parameters or accept the server preset. Oracle does not use the user’s exchange VIP API-key.

### Formulas

```text
half_spread = spread_bps / 20000          # share of notional; spread from fresh depth or default_spread_bps
C_spot      = fee_buy + fee_sell + 2·slippage + half_spread
C_perp      = C_spot + |funding_rate|    # rough estimate for one funding interval
d_sl        = k_sl · (atr14 / close)     # stop as a share of price
c_R         = C_spot / d_sl
p_be        = (1 + c_R) / (RR + 1)       # break-even winrate at given RR
tradable    = c_R <= tradable_c_r_max    # default 0.45
```

### Response fields

| Object / field | Meaning |
|---------------|--------|
| `trade_cost.source` | always `"preset"` (no account-fee API yet) |
| `trade_cost.fee_buy` / `fee_sell` | shares of notional (0.001 = 0.1%) |
| `trade_cost.spread_bps` | full spread in bps |
| `trade_cost.spread_from_depth` | `true` if taken from fresh `depth`; otherwise config fallback |
| `trade_cost.slip_bps` | per-side slip in bps (from `slippage` preset/query) |
| `trade_cost.half_spread` | half the spread as a share (enters `c_spot` once) |
| `trade_cost.c_spot` | **main**: round-trip cost spot |
| `trade_cost.funding_rate` / `c_perp` | optional from derivatives |
| `cost_risk.atr_pct` | ATR as % of price |
| `cost_risk.d_sl_pct` | `k_sl · atr_pct` |
| `cost_risk.c_r` | trade cost in stop units |
| `cost_risk.p_be` | what winrate is needed to break even |
| `cost_risk.tradable` | hard gate before entry |
| `cost_risk.params` | actually used `k_sl`, `rr`, `tradable_c_r_max` |

### Recommended robot decision path

```text
bar_close (WS)
  → update local bar / candle_q / atr_pct / cvd_delta
  → GET /v1/context/{symbol}[?fee_buy=…&rr=…]   # before entry or on a schedule
  → if data_quality.score < 70 or lag_sec > 120 → SKIP
  → if cost_risk.tradable == false             → SKIP  (fees eat the noise)
  → if candle_q / close_pos not in your favor  → SKIP  (optional shape filter)
  → own signal logic (EMA/RSI/ADX/regime…)
  → position size from atr_pct and C_spot
```

**Sandbox ↔ live:** align Lab fees (`commissionPct`) with query/`[trade_cost]`, otherwise EV and `tradable` will diverge. Do not compute your own `C` in parallel “by eye” if `trade_cost.c_spot` already exists.

Ticker contains `last`, `bid`, `ask`, `open_24h`, `high_24h`, `low_24h`, `volume_24h`, `quote_volume_24h`, `price_change_pct_24h`, `weighted_avg_24h` (daily VWAP). Individual nullable fields may be unavailable from the provider.

## WebSocket

After connect the server sends `hello`. Events do not arrive until you subscribe explicitly.

Subscribe to selected pairs. Microstructure is opt-in, so older clients keep receiving only `bar_close`:

```json
{"op":"subscribe","symbols":["BTCUSDT","ETHUSDT"],"microstructure":true}
```

Subscribe to all pairs:

```json
{"op":"subscribe","symbols":["*"]}
```

Event:

```json
{
  "type": "bar_close",
  "symbol": "BTCUSDT",
  "interval": "1m",
  "active_provider": "binance:spot",
  "bar": {}
}
```

`bar` contains OHLCV + `indicators` (full catalog after warmup). **`trade_cost` / `cost_risk` / `htf` do not arrive over WS** — request `/v1/context` before entry. The client computes cumulative CVD itself from `cvd_delta`.

After a successful write of the minute object to Redis, a subscriber with `microstructure:true` receives a separate additive event:

```json
{
  "type": "microstructure_close",
  "symbol": "BTCUSDT",
  "interval": "1m",
  "microstructure": {
    "ts": 1784179200000,
    "closed": true,
    "sample_count": 58,
    "expected_samples": 60,
    "coverage_pct": "96.7",
    "quality": { "score": 95, "flags": [] }
  }
}
```

`bar_close` is not blocked when microstructure is unavailable. The client temporarily stores both objects and joins them strictly on `symbol + ts`; missing microstructure only forbids a depth-dependent entry, not processing of the ordinary bar. Do not rely on network order of the two event types: they are independent broadcast channels.

Also supported:

```json
{"op":"unsubscribe","symbols":["ETHUSDT"]}
{"op":"ping"}
```

The client must implement reconnect with backoff, bar and microstructure deduplication on the key `` `${symbol}:${ts}` ``, and gap recovery via REST. After reconnect first load `/v1/history`, then `/v1/microstructure` for the same range, and perform an exact join. WebSocket is a notification; REST/Redis is the source of state recovery.

Recommended reconnect:

| Parameter | Value |
|----------|----------|
| initial delay | **1s** |
| multiplier | **2×** |
| max delay | **60s** |
| jitter | **±25%** |

Do not set initial delay in milliseconds — a mass client restart then hits the server.

## Minimal TypeScript example

```typescript
const baseUrl = process.env.ORACLE_BASE_URL!;
const apiKey = process.env.ORACLE_API_KEY!;

async function oracle<T>(path: string, init: RequestInit = {}): Promise<T> {
  const response = await fetch(`${baseUrl}${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      ...init.headers,
    },
  });

  if (!response.ok) {
    throw new Error(`Oracle ${response.status}: ${await response.text()}`);
  }
  return response.json() as Promise<T>;
}

const symbols = await oracle("/v1/symbols");
const latest = await oracle<{
  ts: number;
  symbols: Record<string, { bar: unknown; ticker: unknown; indicators_ready: boolean }>;
}>("/v1/latest?symbols=BTCUSDT,ETHUSDT");
const btcBar = latest.symbols["BTCUSDT"]?.bar; // always via .symbols[…]
```

For Node.js WebSocket it is preferable to pass the key as a header, not a query parameter, so the secret does not land in access logs.

## Errors and operational limits

### HTTP status code ≠ JSON `status`

| Layer | What it is | Example |
|------|---------|--------|
| **HTTP status** | protocol response code (`response.status` / `res.status`) | `200`, `401`, `429`, `503` |
| **JSON `status`** | machine code in the body; on errors **equals** `code` | `"ok"`, `"key_expired"`, `"quota_exceeded"` |

On success (`/health`, `/v1/me`) JSON `status` is usually `"ok"`. On an auth error JSON `status` will be `"key_expired"` and similar, while HTTP is `401`. Do not write a parser that only checks `body.status === "ok"` without checking HTTP.

```javascript
const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
const body = await res.json().catch(() => ({}));

if (!res.ok) {
  // look at body.code (or body.status — the same value on errors)
  if (body.code === "key_expired") {
    // stop trading, request a renewal
  } else if (res.status === 429) {
    // too_fast | rate_limited | quota_exceeded | ws_limit → backoff
  }
  throw new Error(`${res.status} ${body.code}: ${body.error}`);
}
// success: HTTP 2xx; for /health: body.status === "ok" and body.bars.status === "ok";
// a CVD strategy additionally needs body.cvd.status === "ok"
```

- `401` — a key problem. Look at JSON **`code`** / **`status`** (they are the same):
  - `key_expired` — the term has ended (`expires_at` in the body);
  - `key_revoked` — revoked by an admin;
  - `key_invalid` — unknown key;
  - `key_missing` — no Bearer.
- `503` / JSON `status: unavailable` — Oracle/auth is temporarily unavailable; backoff and retry.
- `429` — plan limit. Look at `code` (on REST errors usually equal to JSON `status`):
  - `too_fast` — min_interval between REST requests;
  - `rate_limited` — RPM (key or global);
  - `quota_exceeded` — daily **billable REST requests** are exhausted (`daily_used >= daily_limit`). **Do not** retry data-REST until UTC midnight (`X-Quota-Reset` / `retry_after_ms`). WebSocket and utility `/v1/me`/`/v1/symbols` are not part of this stop;
  - `ws_limit` — too many simultaneous WS on the key; the upgrade-response body contains `code`/`error`/`max_ws`/`retry_after_ms` and **may not contain** a `status` field — parse `code`.
  Headers (REST): `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Retry-After`, plus a quota snapshot `X-Quota-Used|Limit|Remaining|Cost|Weight|Reset` (`Cost` = 0 or 1 request, `Weight` = call load).
  The REST body has `retry_after_ms`. For `too_fast` / `rate_limited` — backoff with jitter; for `quota_exceeded` the loop below **does not** apply (it would hammer until midnight):

```javascript
async function oracleGet(url, key, attempt = 0) {
  const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
  if (res.status !== 429) return res;
  const body = await res.json().catch(() => ({}));
  if (body.code === "quota_exceeded") return res; // wait for UTC midnight, do not spin REST
  const waitMs = body.retry_after_ms
    ?? (Number(res.headers.get("Retry-After") || 1) * 1000);
  const jitter = Math.floor(Math.random() * 250);
  await new Promise((r) => setTimeout(r, Math.min(60_000, waitMs * (2 ** attempt) + jitter)));
  return oracleGet(url, key, attempt + 1);
}
```

**Client plans** (catalog without prices — section **“Client plans”**): `free` **2000**/day · 1 WS; `basic` **5000** · 2 WS; `pro` **8000** · 3 WS. There is no name `standard`. New keys = `free`, term **+30 days**. Hard numbers for the key — `GET /v1/me`.

- `400` — invalid symbol/body/time range;
- `404` / JSON `code: meta_unavailable` — Redis does not yet have a lot-filter cache for the pair (`GET /v1/meta/{symbol}` does not go to the exchange);
- `5xx` — Redis or an internal error; retry with bounded exponential backoff.

Do not tight-poll: see section **“Recommended delays between REST requests”**. Live is **`WS /v1/stream`**; `/latest` and `/context` — on intervals, not “every 100 ms”.

Redis stores a sliding 1m/microstructure window of `redis.ttl_days` (code fallback **7** days; in shipped `config.toml` usually **60**), not an eternal archive. If the platform needs long-term statistics or model training, it must persist received closed bars in its own DB.

## Administration and additional documents

Operator docs below are in Russian:

- [DOC.md](DOC.md) — main Russian guide and ready PowerShell commands;
- [AUTH.md](AUTH.md) — API keys and HTTPS;
- [DEPLOY.md](DEPLOY.md) — build and VPS deployment;
- [PROVIDERS.md](PROVIDERS.md) — providers and fallback;
- [Demo web client](../examples/web-client/README.md) — working HTML/JS example;
- demo source: [`examples/web-client`](../examples/web-client/).

### Demo UI: Showcase + Market X-ray — not an API

In `examples/web-client` there are two **client** layers on top of **`GET /v1/context/{symbol}`**. There are **no** separate endpoints `/v1/xray`, `/v1/report`, `/v1/showcase` — extra REST calls for the widgets are not required.

| Layer | Component | Data source | REST |
|------|-----------|-----------------|------|
| **Showcase** | READY / CAUTION / SKIP traffic light | `computeScorecard().verdict` + `data_quality` | only the latest `context` |
| | stress / quality rings (SVG) | overheat + `(100 − dq.score)` | same |
| | macro countdown | `macro_event_soon`, `next_macro_event` | same |
| | regime badge | `volatility_regime`, `computeRegimeHint()` | same |
| | vol / funding / depth / liq / venues | `derivatives`, `depth`, `liquidations`, `cross_exchange` | same |
| **Market X-ray** | overheat thermometer | client-side `computeOverheat()` | same |
| | scorecard (verdict + factors) | `computeScorecard()` | same |
| | anomaly radar | heuristics on lag, divergence, funding z-score | same + client-side |

Showcase updates on every `loadContext` / `renderXray`; live countdown (macro, next funding) — via `tickClock` (1 s), **without** new polling timers.

For a paying client / your own trading platform:

- source of truth — `context` fields + `data_quality` / `lag_sec` / regime / derivatives;
- demo UI — an example of UX and heuristics, **not a trading signal** and not a server `verdict`;
- demo polling: WS + `latest` 10 s + `context` 60 s (see the delays section);
- when integrating, copy the logic you need or build your own report from `context`.

Before integration, check real routes against `crates/oracle-api/src/serve.rs` and DTOs against `crates/oracle-core/src/types.rs`. This document describes the current Oracle `0.3.0` API (native mid-TF `5m`/`15m`/`30m` + the full indicators catalog on all TFs).

---

## Appendix: request and response examples

Below are typical calls with a **JSON response** and a field-by-field readout. Price numbers in the API often arrive as **strings** (Decimal). All `/v1/*` (except public `/health`) require:

`Authorization: Bearer mo_…`

Example base: `https://api.market-oracle.pro`.

---

### 1. `GET /health` (public)

**Request**

```http
GET /health
```

**Response**

```json
{
  "status": "ok",
  "uptime_sec": 31641,
  "redis": "ok",
  "ws_connected": true,
  "active_provider": "binance:spot",
  "failover": false,
  "bar_lag_sec": 85,
  "bars": { "status": "ok", "lag_sec": 85, "writer": "binance:spot" },
  "cvd": { "status": "ok", "source": "binance:spot" },
  "providers": [
    { "id": "binance:spot", "role": "primary", "priority": 0, "connected": true, "last_status_ts": 1784179235000, "reconnect_count": 0 },
    { "id": "bybit:spot", "role": "fallback", "priority": 1, "connected": true, "last_status_ts": 1784179234900, "reconnect_count": 0 },
    { "id": "okx:spot", "role": "fallback", "priority": 3, "connected": true, "last_status_ts": 1784179234800, "reconnect_count": 0 }
  ],
  "version": "0.3.0"
}
```

Failover example (Bybit writes 1m, CVD is off): `"status":"ok"`, `"failover":true`, `"active_provider":"bybit:spot"`, `"bars":{"status":"ok","writer":"bybit:spot"}`, `"cvd":{"status":"down","source":"binance:spot","reason":"failover"}`.

| Field | Description |
|------|----------|
| `status` | `ok` if Redis is alive, the writer WS is connected, and **bars** are fresh (`bars.status == "ok"`). Does **not** fall because of failover to Bybit/OKX and does **not** reflect CVD. HTTP 200 ≠ you may trade; HTTP 200 + `status=ok` ≠ you may trade CVD. |
| `bars` | Canonical 1m. `ok` — OHLC may be used (writer Binance, Bybit, or OKX), typical `lag_sec` mid-minute ~60–120, stale threshold **180 s**. `down` + `reason: "stale"` — a hole in candles, do not enter. `writer` — who is writing now. |
| `cvd` | `ok` only while the active writer is Binance (`taker_buy_volume` → `cvd_delta`). On Bybit/OKX: `down` / `reason: "failover"`. If primary is not Binance: `unavailable` / `no_taker_source`. Bots with cumulative CVD **must** look at this field and not sum `cvd_delta` into a hole. |
| `bar_lag_sec` | Worst (largest) age of a closed 1m among registered pairs; duplicates `bars.lag_sec`. |
| `uptime_sec` | How many seconds Oracle has been running continuously since start. A sharp reset = there was a restart: check `history`/`lag_sec`; the server itself fills only missing ranges. |
| `redis` | `ok` / `down`. Without Redis there is no history and no quotas; when `down` the API is useless for a bot. |
| `ws_connected` | Whether the current `active_provider` that writes canonical bars is connected. |
| `active_provider` | Who writes canonical 1m: `binance:spot` → `bybit:spot` → `okx:spot`. |
| `failover` | `true` if the writer is not primary. For 15m without CVD on liquid USDT this is normal (close divergence is usually bps, not a hole). CVD is then `down`; there is no microstructure on that bar. |
| `providers` | Enabled providers: role/priority, real `connected`, last status time, and the number of actual reconnects. |
| `version` | Oracle binary version. |

Before a 15m entry: `GET /health` (`bars` / `cvd` if needed) + `GET /v1/status/{symbol}` (`data_quality.score >= 70`, `lag_sec`, `indicators_ready`) + `GET /v1/context/{symbol}` (`cost_risk.tradable`, freshness). Native 15m is `history?interval=15m` / `context.htf.m15`, not an aggregate from 1m. If Binance REST for HTF is unavailable, the oracle pulls 15m from Bybit REST into the same keys.

---

### 2. `GET /v1/me` — current key plan

**Request**

```http
GET /v1/me
Authorization: Bearer mo_…
```

**Response**

```json
{
  "status": "ok",
  "key_id": "key_bf6ed463bc8d112f",
  "name": "home-bot",
  "key_prefix": "mo_41ec884",
  "tier": "basic",
  "expires_at": "2026-08-16T00:00:00+00:00",
  "expired": false,
  "created_at": "2026-07-16T04:00:00+00:00",
  "last_used_at": "2026-07-16T06:30:00+00:00",
  "daily_used": 420,
  "daily_limit": 5000,
  "daily_remaining": 4580,
  "daily_load": 1260,
  "rpm": 60,
  "min_interval_ms": 200,
  "max_ws": 2,
  "ws_open": 1,
  "weights": {
    "default": 1,
    "context": 3,
    "history": 3,
    "history_batch_per_symbol": 3,
    "bootstrap": 10
  }
}
```

| Field | Description |
|------|----------|
| `status` | Always `ok` here on success. |
| `key_id` | Internal key id (not a secret). Needed for support/logs. |
| `name` | Human-readable name set by an admin. |
| `key_prefix` | Short secret prefix (`mo_…`) to recognize the key without full disclosure. |
| `tier` | Client plan: `free` / `basic` / `pro` (there is no name `standard`). Limit catalog and who gets which — section **“Client plans”**. |
| `expires_at` | UTC moment when the key stops working. After it any REST returns `key_expired`. |
| `expired` | Whether the key is already expired at response time (convenient boolean check). |
| `created_at` / `last_used_at` | When created and when it last passed auth successfully. |
| `daily_used` | How many **billable REST requests** were spent in the current UTC day (1 HTTP = 1). |
| `daily_limit` | Daily request ceiling. Seed canon: free=2000, basic=5000, pro=8000. `0` = no limit. |
| `daily_remaining` | Remaining daily budget in **requests**. |
| `daily_load` | Sum of weights for the same day (load). Do not compare with `daily_limit`. |
| `rpm` | Maximum REST requests per rolling minute for this key. |
| `min_interval_ms` | Minimum pause between two REST calls of one key. |
| `max_ws` | How many simultaneous `/v1/stream` connections are allowed. |
| `ws_open` | How many WS this key currently has open. |
| `weights.*` | Load reference for endpoints (`daily_load`): `context`/`history` are usually heavier than `latest`. They do not affect `daily`. |

---

### 3. `GET /v1/symbols`

**Request**

```http
GET /v1/symbols
Authorization: Bearer mo_…
```

**Response (fragment)**

```json
{
  "symbols": [
    {
      "symbol": "BTCUSDT",
      "exchange": "binance",
      "market": "spot",
      "bars_1m": 30240,
      "span_hours": 504.0,
      "ready": true,
      "history": "full_day",
      "last_closed_ts": 1784179200000,
      "lag_sec": 75
    }
  ]
}
```

| Field | Description |
|------|----------|
| `symbol` | Trading pair in upper case. |
| `exchange` / `market` | Exchange and market of the canonical store (usually `binance` + `spot`). |
| `bars_1m` | How many closed 1m bars sit in Redis. |
| `span_hours` | Rough history depth ≈ `bars_1m / 60`. |
| `ready` | `true` if ≥200 bars — enough for EMA200. Does **not** mean ema20/rsi/atr are present. |
| `history` | `warming` / `ready` / `full_day` (≥1440 bars). For live strategies prefer `full_day`. |
| `last_closed_ts` | Timestamp (**ms UTC**) of the last closed bar or `null`. |
| `lag_sec` | Age of that bar in seconds: `(now − last_closed_ts) / 1000`. No bars → the field is absent. Pair liveness = Oracle has fresh data, not a live request to the exchange. |

---

### 4. `GET /v1/status/{symbol}`

**Request**

```http
GET /v1/status/BTCUSDT
Authorization: Bearer mo_…
```

**Response**

```json
{
  "symbol": "BTCUSDT",
  "exchange": "binance",
  "market": "spot",
  "interval": "1m",
  "registered": true,
  "bar_count": 30240,
  "span_hours": 504.0,
  "ready": true,
  "history": "full_day",
  "last_closed_ts": 1784179200000,
  "lag_sec": 12,
  "indicators_ready": true,
  "data_quality": { "score": 95, "flags": ["fresh_venues=2"] },
  "active_provider": "binance:spot",
  "failover": false
}
```

| Field | Description |
|------|----------|
| `registered` | The pair is in the SQLite registry (ingest listens to it). |
| `bar_count` / `span_hours` / `ready` / `history` | Same as in `/v1/symbols`, but more detailed for one pair. |
| `interval` | Live series interval (usually `1m`). |
| `lag_sec` | Age of the last closed bar: `(now − bar.ts) / 1000`, where `bar.ts` is the **open time** of the closed 1m. Mid-minute ~60–120s is normal; `>120` — caution; `>180` — skip. Do not confuse with Binance RTT. |
| `indicators_ready` | The last bar has the **base set** (`ema20` + `rsi14` + `atr14`). May be `true` when `bar_count < 200` (`ready == false`). For EMA200 additionally require `ready`. |
| `data_quality.score` | 0–100 composite of feed quality. For a new decision, `>= 70` is required; when `lag_sec > 120` the score is forced below 70. |
| `data_quality.flags` | Penalty tags (`lag_sec>120`, `lag_sec>180`, `divergence_warning`, `fresh_venues=1`, …). |
| `active_provider` / `failover` | Who is writing bars now and whether failover is on. |

---

### 5. `GET /v1/latest?symbols=`

**Request**

```http
GET /v1/latest?symbols=BTCUSDT
Authorization: Bearer mo_…
```

**Response (shortened)**

The shape is **always** `{ "ts": number, "symbols": { "<PAIR>": { ... } } }` — even for one symbol. You cannot use `response.bar`; only `response.symbols.BTCUSDT.bar`.

```json
{
  "ts": 1784179200000,
  "symbols": {
    "BTCUSDT": {
      "bar": {
        "ts": 1784179200000,
        "open": "65000.00",
        "high": "65080.00",
        "low": "64950.00",
        "close": "65040.00",
        "volume": "123.45",
        "quote_volume": "8023456.78",
        "trades": 4120,
        "closed": true,
        "taker_buy_volume": "60.10",
        "indicators": {
          "ema20": "64910.12",
          "ema50": "64500.00",
          "ema200": "62000.00",
          "rsi14": "54.2",
          "atr14": "180.5",
          "vwap": "64980.00",
          "vol_sma20": "95.0",
          "adx14": "22.1",
          "obv": "1500000",
          "mfi14": "48.0",
          "cvd_delta": "-3.25",
          "body": "0.307692",
          "wick_balance": "0.153846",
          "close_pos": "0.692308",
          "candle_q": "0.261538",
          "range_ratio": "0.720222",
          "atr_pct": "0.277521"
        }
      },
      "ticker": {
        "ts": 1784179235000,
        "last": "65042.10",
        "bid": "65042.00",
        "ask": "65042.20",
        "volume_24h": "28000.5",
        "quote_volume_24h": "1800000000",
        "price_change_pct_24h": "1.25",
        "weighted_avg_24h": "64890.00"
      },
      "indicators_ready": true
    }
  }
}
```

| Field | Description |
|------|----------|
| `ts` | Maximum `bar.ts` among returned symbols (**ms UTC**). |
| `symbols` | Required wrapper `Record<string, SymbolLatest>`; key = symbol in upper case. |
| `symbols.<PAIR>.bar` | Last **closed** 1m bar. This is the input for a signal (`closed: true`). |
| `bar.ts` | Open time of the closed candle (**ms UTC**), aligned to the minute. |
| `bar.open/high/low/close/volume` | Classic OHLCV. Prices are strings. |
| `bar.quote_volume` | Volume in the quote asset (USDT). |
| `bar.trades` | Number of trades inside the bar. |
| `bar.taker_buy_volume` | Aggressive buy volume; needed for `cvd_delta`. |
| `bar.indicators.*` | Incremental indicators at bar close (EMA/RSI/ATR/ADX+DI/VWAP/OBV/MFI/CVD, slope, BB, volume$, swing, shape/`atr_pct`). One catalog on all TFs. Do not recompute without a reason. `trade_cost`/`cost_risk`/`htf` are **not** here — only in `/v1/context`. |
| `ticker` | Live 24h snapshot (may be newer than the bar). For UI/sizing/slippage, **not** for generating an entry. |
| `indicators_ready` | Base set ema20+rsi14+atr14 on the bar (not a substitute for `ready` ≥200). Does not require `candle_q`. |

---

### 6. `GET /v1/history/{symbol}`

**Request**

```http
GET /v1/history/BTCUSDT?interval=1m&limit=3
GET /v1/history/BTCUSDT?interval=15m&limit=200
Authorization: Bearer mo_…
```

**Response**

```json
{
  "symbol": "BTCUSDT",
  "exchange": "binance",
  "market": "spot",
  "interval": "15m",
  "from": 1784178300000,
  "to": 1784179200000,
  "count": 2,
  "bars": [
    {
      "ts": 1784178300000,
      "open": "64950.00",
      "high": "65120.00",
      "low": "64910.00",
      "close": "65060.00",
      "volume": "410.25",
      "quote_volume": "26680000.00",
      "trades": 18500,
      "closed": true,
      "taker_buy_volume": "215.40",
      "indicators": {
        "ema20": "64890.12",
        "ema50": "64520.00",
        "ema200": "62100.00",
        "rsi14": "58.40",
        "atr14": "210.50",
        "atr50": "245.00",
        "atr_ratio_14_50": "0.859184",
        "atr_pct": "0.323596",
        "vwap": "65010.00",
        "vol_sma20": "380.00",
        "volume_usd": "26680000.00",
        "volume_rate": "1778666.67",
        "adx14": "24.80",
        "plus_di14": "26.10",
        "minus_di14": "18.20",
        "di_side": "1",
        "obv": "1520000.00",
        "mfi14": "55.20",
        "cvd_delta": "18.60",
        "ema50_slope_pct": "0.042000",
        "ema_slope_abs": "0.042000",
        "bb_mid": "64900.00",
        "bb_upper": "65300.00",
        "bb_lower": "64500.00",
        "bb_width": "0.012320",
        "body": "0.523810",
        "wick_balance": "0.095238",
        "close_pos": "0.714286",
        "candle_q": "0.366667",
        "range_ratio": "0.997626",
        "last_swing_high": "65200.00",
        "last_swing_low": "64400.00"
      }
    },
    {
      "ts": 1784179200000,
      "open": "65060.00",
      "high": "65180.00",
      "low": "65010.00",
      "close": "65140.00",
      "volume": "385.10",
      "quote_volume": "25070000.00",
      "trades": 17200,
      "closed": true,
      "taker_buy_volume": "205.00",
      "indicators": {
        "ema20": "64920.40",
        "ema50": "64560.00",
        "ema200": "62150.00",
        "rsi14": "61.10",
        "atr14": "205.20",
        "atr_pct": "0.314992",
        "vwap": "65080.00",
        "vol_sma20": "382.00",
        "adx14": "27.30",
        "plus_di14": "28.40",
        "minus_di14": "16.90",
        "di_side": "1",
        "obv": "1538000.00",
        "mfi14": "62.40",
        "cvd_delta": "24.90",
        "ema50_slope_pct": "0.061957",
        "bb_mid": "64940.00",
        "bb_upper": "65340.00",
        "bb_lower": "64540.00",
        "bb_width": "0.012320",
        "body": "0.470588",
        "close_pos": "0.764706",
        "candle_q": "0.385000",
        "range_ratio": "0.828460",
        "swing_high": "65180.00",
        "last_swing_high": "65180.00",
        "last_swing_low": "64400.00"
      }
    }
  ]
}
```

| Field | Description |
|------|----------|
| `interval` | Requested TF: `1m` / mid `5m`/`15m`/`30m` / HTF `1h`/`4h`/`1d`/`1w` (core indicators after warmup). |
| `from` / `to` | Actual sample range (ms). |
| `count` | Number of bars in the array. |
| `bars` | Closed candles in time-ascending order. Use for backfill after disconnect. |

Query: `interval`, `limit`, `from`, `to`.

---

### 7. `GET /v1/context/{symbol}` — main decision snapshot

**Request**

```http
GET /v1/context/BTCUSDT
Authorization: Bearer mo_…
```

**Response — full realistic example (only long `htf.indicators` are shortened; structure is 1-to-1)**

```json
{
  "symbol": "BTCUSDT",
  "exchange": "binance",
  "market": "spot",
  "interval": "1m",
  "ts": 1784179200000,
  "context_scope": "live_snapshot",
  "historical_safe": false,
  "bar": {
    "ts": 1784179200000,
    "open": "65000.00",
    "high": "65080.00",
    "low": "64950.00",
    "close": "65040.00",
    "volume": "123.45",
    "quote_volume": "8023456.78",
    "trades": 4120,
    "closed": true,
    "taker_buy_volume": "60.10",
    "indicators": {
      "ema20": "64910.12",
      "ema50": "64500.00",
      "ema200": "62000.00",
      "rsi14": "54.20",
      "atr14": "180.50",
      "atr50": "210.00",
      "atr_ratio_14_50": "0.859524",
      "atr_pct": "0.277521",
      "vwap": "64980.00",
      "vol_sma20": "95.00",
      "volume_usd": "8023456.78",
      "volume_rate": "8023456.78",
      "adx14": "22.10",
      "plus_di14": "24.50",
      "minus_di14": "17.80",
      "di_side": "1",
      "obv": "1500000.00",
      "mfi14": "48.00",
      "cvd_delta": "-3.25",
      "ema50_slope_pct": "0.031000",
      "ema_slope_abs": "0.031000",
      "bb_mid": "64900.00",
      "bb_upper": "65200.00",
      "bb_lower": "64600.00",
      "bb_width": "0.009244",
      "body": "0.307692",
      "wick_balance": "0.153846",
      "close_pos": "0.692308",
      "candle_q": "0.261538",
      "range_ratio": "0.720222",
      "last_swing_high": "65200.00",
      "last_swing_low": "64400.00"
    }
  },
  "ticker": {
    "ts": 1784179235000,
    "last": "65042.10",
    "bid": "65042.00",
    "ask": "65042.20",
    "open_24h": "64200.00",
    "high_24h": "65300.00",
    "low_24h": "63800.00",
    "volume_24h": "28000.50",
    "quote_volume_24h": "1800000000.00",
    "price_change_pct_24h": "1.25",
    "weighted_avg_24h": "64890.00"
  },
  "depth": {
    "ts": 1784179230000,
    "best_bid": "65042.00",
    "best_ask": "65042.20",
    "spread_pct": "0.000307",
    "imbalance": "0.55",
    "bid_wall": { "price": "65000.00", "qty": "12.50", "notional": "812500.00", "distance_pct": "0.064615" },
    "ask_wall": { "price": "65100.00", "qty": "8.00", "notional": "520800.00", "distance_pct": "0.088942" }
  },
  "bar_count": 30240,
  "ready": true,
  "history": "full_day",
  "lag_sec": 75,
  "indicators_ready": true,
  "data_quality": { "score": 92, "flags": [] },
  "freshness": {
    "ticker": { "available": true, "fresh": true, "age_sec": 1, "max_age_sec": 30 },
    "depth": { "available": true, "fresh": true, "age_sec": 1, "max_age_sec": 10 },
    "macro_snapshot": { "available": true, "fresh": true, "age_sec": 900, "max_age_sec": 7200 },
    "market_regime": { "available": true, "fresh": true, "age_sec": 900, "max_age_sec": 7200 }
  },
  "macro_event_soon": false,
  "next_macro_event": { "ts": 1784246400000, "title": "CPI m/m", "country": "USD", "impact": "high" },
  "derivatives": {
    "snapshot": {
      "ts": 1784179230000,
      "mark_price": "65045.10",
      "index_price": "65040.00",
      "funding_rate": "0.000100",
      "next_funding_ts": 1784196000000,
      "open_interest": "85000.50"
    },
    "funding_7d": { "samples": 21, "funding_ma_7d": "0.000080", "funding_std_7d": "0.000050" },
    "stale": false
  },
  "liquidations": {
    "windows": {
      "five_min": { "count": 3, "total_long_qty": "1.80", "total_short_qty": "0.60", "total_long_value": "120000.00", "total_short_value": "40000.00" },
      "one_hour": { "count": 40, "total_long_qty": "14.00", "total_short_qty": "8.00", "total_long_value": "900000.00", "total_short_value": "500000.00" }
    }
  },
  "macro_snapshot": {
    "ts": 1784179100000,
    "fear_greed": { "ts": 1784073600000, "value": 25, "classification": "Extreme Fear" },
    "btc_dominance_pct": "56.27",
    "eth_dominance_pct": "12.10",
    "total_market_cap_usd": "2400000000000.00",
    "total_volume_24h_usd": "90000000000.00",
    "stablecoin_total_usd": "300000000000.00",
    "usd_index": "120.50",
    "usd_index_ts": 1783987200000,
    "us_10y_yield_pct": "4.62",
    "us_10y_ts": 1783987200000
  },
  "cross_exchange": {
    "consensus_mid": "65052.77",
    "fresh_venues": 3,
    "divergence_warning": false,
    "divergence_bps": "1.20"
  },
  "market_regime": {
    "volatility_regime": "low_vol_range",
    "correlation_btc_7d": 1.0,
    "breadth_above_ema20_pct": 80.0,
    "median_return_24h_pct": 1.20
  },
  "volatility_regime": "low_vol_range",
  "decoupling_detected": false,
  "trade_cost": {
    "source": "preset",
    "fee_buy": "0.001",
    "fee_sell": "0.001",
    "spread_bps": "2.0",
    "spread_from_depth": true,
    "slip_bps": "2.0",
    "half_spread": "0.0001",
    "c_spot": "0.0025",
    "funding_rate": "0.0001",
    "c_perp": "0.0026"
  },
  "cost_risk": {
    "atr_pct": "0.28",
    "d_sl_pct": "0.56",
    "c_r": "0.446429",
    "p_be": "0.482143",
    "tradable": true,
    "params": { "k_sl": "2.0", "rr": "2.0", "tradable_c_r_max": "0.45" }
  },
  "htf": {
    "m15": { "ts": 1784178300000, "open": "…", "close": "…", "closed": true, "indicators": { "ema20": "…", "adx14": "…" } },
    "h1":  { },
    "h4":  { },
    "d1":  { }
  },
  "active_provider": "binance:spot",
  "failover": false
}
```

The field `current_bar` is **absent** from the current API (planned). When it appears — an unclosed bar 0 for fill simulation / emergency exit; ⚠️ **not for signals**.

Query override fees/risk (sandbox = Lab commission):  
`GET /v1/context/BTCUSDT?fee_buy=0.001&fee_sell=0.001&slippage=0.0002&k_sl=2&rr=2&tradable_c_r_max=0.45`

| Field | Description |
|------|----------|
| `context_scope` / `historical_safe` | Always `live_snapshot` / `false`. The whole response is a current point-in-time snapshot; do not attach it to historical bars. |
| `bar` / `ticker` | As in `/v1/latest`: signal only from `bar`, execution/UI from `ticker`. The bar also has `body`/`wick_balance`/`close_pos`/`candle_q`/`range_ratio`/`atr_pct`. |
| `depth` | Derived order-book metrics (not the raw book): spread, imbalance 0..1, nearest “walls”. Needed to estimate fill/slippage. |
| `trade_cost` | Unified round-trip `c_spot` from preset fees + half_spread + 2×slip (+ optional `c_perp`). **Not in WS.** |
| `cost_risk` | `c_r = C / (k_sl·ATR/close)`, `p_be`, `tradable`. Main gate “too expensive for the TF”. **Not in WS.** |
| `bar_count` / `ready` / `history` / `lag_sec` / `indicators_ready` | Pair readiness. `ready` (≥200) ≠ `indicators_ready` (ema20+rsi+atr). `lag_sec` — age of the closed bar from open ts (see above). |
| `data_quality` | Phase 11: score 0–100 + `flags`. For a new decision, `score >= 70` is required; `lag_sec > 120` caps the score below 70. |
| `freshness` | Per live component: `available`, `fresh`, `age_sec`, `max_age_sec`. Check before using ticker/depth/macro/regime. |
| `volatility_regime` | Phase 11: volatility-regime label for this pair (not an entry). |
| `decoupling_detected` | Phase 11: decorrelation vs BTC over ~24h vs 7d. |
| `macro_event_soon` | `true` if a high-impact event is ≤60 min — better to pause or reduce risk. |
| `next_macro_event` | Nearest calendar event or `null`. |
| `derivatives` / `liquidations` | Binance futures context (funding/OI/liqs) — only as a quality filter for a spot signal. |
| `macro_snapshot` | Global macro. All `ts`/`*_ts` are **ms UTC**; there is no server `stale` — compute age from `macro_snapshot.ts`. |
| `cross_exchange` | Consensus mid Binance/Bybit/OKX, stale, divergence — a bad-tick check. |
| `market_regime` | Correlation to BTC, vol, breadth + per-symbol regime fields. Does not replace HTF `trend_z` / RS-rank. |
| `active_provider` / `failover` | Source of canonical bars. |

Detailed formulas and decision path — section **“Edge: trade_cost and cost_risk”** above.

Nested objects match the responses of `/v1/derivatives`, `/v1/macro`, `/v1/quotes`, `/v1/market-regime`.

---

### 8. `GET /v1/derivatives/{symbol}`

**Request**

```http
GET /v1/derivatives/BTCUSDT?liquidation_limit=5
Authorization: Bearer mo_…
```

**Response (schema)**

```json
{
  "symbol": "BTCUSDT",
  "exchange": "binance",
  "market": "futures",
  "ts": 1784179230000,
  "derivatives": {
    "snapshot": {
      "ts": 1784179230000,
      "mark_price": "65045.1",
      "index_price": "65040.0",
      "funding_rate": "0.0001",
      "next_funding_ts": 1784196000000,
      "open_interest": "85000.5"
    },
    "funding_7d": {
      "samples": 21,
      "funding_ma_7d": "0.00008",
      "funding_std_7d": "0.00005"
    },
    "stale": false
  },
  "liquidations": {
    "windows": {
      "five_min": {
        "count": 3,
        "total_long_qty": "1.8",
        "total_short_qty": "0.6",
        "total_long_value": "120000",
        "total_short_value": "40000"
      },
      "one_hour": {
        "count": 40,
        "total_long_qty": "14.0",
        "total_short_qty": "8.0",
        "total_long_value": "900000",
        "total_short_value": "500000"
      }
    },
    "recent": [
      { "ts": 1784179205000, "side": "long", "qty": "1.2", "price": "65010", "total_value": "78012" }
    ]
  }
}
```

| Field | Description |
|------|----------|
| `derivatives.snapshot` | Current mark/index, predicted funding, OI. |
| `funding_7d` | MA/STD of settled funding over ~7 days — overheated funding = more caution with longs/shorts. |
| `stale` | The mark stream is stale (>~10 min) — do not trust the snapshot. |
| `liquidations.windows` | Forced-liq aggregates for 5m/1h: `count`, `total_long_qty` / `total_short_qty`, `total_long_value` / `total_short_value`. |
| `liquidations.recent[].side` | Already normalized: `long` = long liquidated, `short` = short. |

---

### 9. `GET /v1/calendar`

**Request**

```http
GET /v1/calendar?hours=48&high_only=true
Authorization: Bearer mo_…

# Sandbox / replay: events in the window after bar.ts
GET /v1/calendar?as_of=1700000000000&window_min=60&high_only=true
```

`as_of` is the anchor (ms); default `now`. `window_min` overrides `hours`. The response includes `as_of` and `historical_safe=true` when the anchor is set explicitly. The calendar accumulates in Redis (~90 days) and is not overwritten by the weekly feed.

**Response**

```json
{
  "now": 1784179200000,
  "as_of": 1784179200000,
  "to": 1784352000000,
  "count": 2,
  "historical_safe": false,
  "events": [
    {
      "ts": 1784246400000,
      "title": "CPI m/m",
      "country": "USD",
      "impact": "high",
      "forecast": "0.2%",
      "previous": "0.1%"
    }
  ]
}
```

With `?as_of=` the anchor is copied into `as_of` and `historical_safe` becomes `true`. Window: `[as_of, as_of + hours|window_min]`.

| Field | Description |
|------|----------|
| `now` / `as_of` / `to` | Anchor and window end (ms). Without `as_of` in the query — `as_of == now`. |
| `historical_safe` | `true` only if the client explicitly passed `as_of` (replay). |
| `events[].impact` | `low` / `medium` / `high`. High + nearby `ts` → reduce risk. |
| `forecast` / `previous` | Expectation and previous value (strings, may be empty). |

---

### 10. `GET /v1/macro`

**Request**

```http
GET /v1/macro?fng_days=3
Authorization: Bearer mo_…
```

**Response**

```json
{
  "ts": 1784179200000,
  "updated_ts": 1784179100000,
  "lag_sec": 100,
  "freshness": {
    "available": true,
    "fresh": true,
    "age_sec": 100,
    "max_age_sec": 7200
  },
  "snapshot": {
    "ts": 1784179100000,
    "fear_greed": { "ts": 1784073600000, "value": 25, "classification": "Extreme Fear" },
    "btc_dominance_pct": "56.27",
    "eth_dominance_pct": "12.10",
    "total_market_cap_usd": "2.4e12",
    "total_volume_24h_usd": "9.0e10",
    "stablecoin_total_usd": "3.0e11",
    "usdt_supply_usd": "1.1e11",
    "usdc_supply_usd": "3.4e10",
    "usd_index": "120.50",
    "usd_index_ts": 1783987200000,
    "us_10y_yield_pct": "4.62",
    "us_10y_ts": 1783987200000
  },
  "fear_greed_history": [
    { "ts": 1783987200000, "value": 31, "classification": "Fear" }
  ]
}
```

| Field | Description |
|------|----------|
| `fear_greed.value` | 0..100; extremes (<20 / >80) are zones of possible sentiment reversal, not a signal by themselves. |
| `btc_dominance_pct` | Rise → capital into BTC from alts; alt longs are riskier. |
| `stablecoin_*` | Stable / USDT / USDC supply — indirect market “dry powder”. |
| `usd_index` / `us_10y_yield_pct` | FRED: USD strength / rising yields = a risk-off backdrop for crypto. |
| `updated_ts` / `lag_sec` | When Oracle last successfully updated the snapshot, and how many seconds ago. |
| `freshness` | As in context: `available` / `fresh` / `age_sec` / `max_age_sec` (`2 × refresh_minutes`). |
| `usd_index_ts` / `us_10y_ts` / `snapshot.ts` | All **ms UTC**. This is the Oracle update moment (source daily granularity ≠ live tick). |
| `fear_greed_history` | Appears when `fng_days>0` (up to 90). |

---

### 11. `GET /v1/quotes/{symbol}`

**Request**

```http
GET /v1/quotes/BTCUSDT
Authorization: Bearer mo_…
```

**Response**

```json
{
  "symbol": "BTCUSDT",
  "ts": 1784179230000,
  "consensus_mid": "65052.765",
  "fresh_venues": 3,
  "divergence_warning": false,
  "venues": [
    {
      "exchange": "binance",
      "quote": { "bid": "65052.76", "ask": "65052.77", "exchange_ts": 1784179229000, "received_ts": 1784179230000 },
      "mid": "65052.765",
      "divergence_bps": "0.00",
      "age_ms": 300,
      "stale": false,
      "feed": { "provider": "binance:spot", "connected": true, "reconnect_count": 0 }
    },
    {
      "exchange": "bybit",
      "quote": { "bid": "65051.90", "ask": "65052.40", "exchange_ts": 1784179228500, "received_ts": 1784179229800 },
      "mid": "65052.150",
      "divergence_bps": "0.95",
      "age_ms": 500,
      "stale": false,
      "feed": { "provider": "bybit:spot", "connected": true, "reconnect_count": 1 }
    },
    {
      "exchange": "okx",
      "quote": { "bid": "65053.10", "ask": "65053.60", "exchange_ts": 1784179228000, "received_ts": 1784179229600 },
      "mid": "65053.350",
      "divergence_bps": "0.90",
      "age_ms": 700,
      "stale": false,
      "feed": { "provider": "okx:spot", "connected": true, "reconnect_count": 0 }
    }
  ]
}
```

| Field | Description |
|------|----------|
| `consensus_mid` | Median mid of fresh venues. A check support, not a trading signal. |
| `fresh_venues` | How many venues are not stale. Few fresh → fragile data. |
| `divergence_warning` | Someone moved far from consensus (threshold from config). Do not open a trade without a check. |
| `venues[].stale` / `age_ms` | Whether BBO is stale and by how much. |
| `venues[].divergence_bps` | Mid deviation from consensus in basis points. |
| `feed.reconnect_count` | How many times the feed reconnected — source quality. |

---

### 12. `GET /v1/market-regime`

**Request**

```http
GET /v1/market-regime
Authorization: Bearer mo_…
```

**Response — full example (in a real response `symbols` contains all active pairs)**

```json
{
  "ts": 1784179200000,
  "interval": "1h",
  "lookback_days": 7,
  "benchmark": "BTCUSDT",
  "symbol_count": 5,
  "breadth_above_ema20_pct": 80.0,
  "breadth_above_ema50_pct": 60.0,
  "median_return_1h_pct": -0.05,
  "median_return_24h_pct": 1.2,
  "decoupling_count": 1,
  "symbols": [
    {
      "symbol": "BTCUSDT",
      "correlation_btc_7d": 1.0,
      "correlation_btc_24h": 1.0,
      "realized_volatility_24h_pct": 38.5,
      "realized_volatility_7d_pct": 42.0,
      "volatility_regime": "low_vol_range",
      "decoupling_detected": false,
      "return_1h_pct": -0.05,
      "return_24h_pct": 1.25,
      "above_ema20": true,
      "above_ema50": true
    },
    {
      "symbol": "ETHUSDT",
      "correlation_btc_7d": 0.85,
      "correlation_btc_24h": 0.40,
      "realized_volatility_24h_pct": 45.0,
      "realized_volatility_7d_pct": 48.0,
      "volatility_regime": "low_vol_range",
      "decoupling_detected": true,
      "return_1h_pct": -0.1,
      "return_24h_pct": 2.5,
      "above_ema20": true,
      "above_ema50": true
    }
  ]
}
```

| Field | Description |
|------|----------|
| `breadth_above_ema*` | Share of the market above its EMAs — “broad” risk-on/off. |
| `median_return_*` | Median of returns across Oracle symbols for 1h/24h. |
| `decoupling_count` | How many pairs currently have `decoupling_detected`. |
| `symbols[].correlation_btc_7d` | Link to BTC on 1h returns (~7d); closer to 1 — the alt moves with BTC. |
| `symbols[].correlation_btc_24h` | The same metric on ~24 last aligned returns. |
| `symbols[].decoupling_detected` | Short corr fell vs 7d — a moment of independent alt dynamics. |
| `symbols[].volatility_regime` | `low_vol_range` / `breakout_expansion` / `high_vol_mean_reversion` / `crisis` — strategy regime choice. |
| `realized_volatility_*_pct` | Annualized realized vol; high = cut position size. |

---

### 13. WebSocket `/v1/stream`

**Connect**

```text
WS /v1/stream
Authorization: Bearer mo_…
```

or `?token=mo_…`

**Server → client after connect**

```json
{ "type": "hello", "version": "0.3.0", "active_provider": "binance:spot" }
```

**Client → server**

```json
{ "op": "subscribe", "symbols": ["BTCUSDT"], "microstructure": true }
```

**Bar-close event**

```json
{
  "type": "bar_close",
  "symbol": "BTCUSDT",
  "interval": "1m",
  "active_provider": "binance:spot",
  "bar": {
    "ts": 1784179200000,
    "open": "65000",
    "high": "65080",
    "low": "64950",
    "close": "65040",
    "volume": "120",
    "quote_volume": "7800000",
    "trades": 4000,
    "closed": true,
    "indicators": {
      "ema20": "64910",
      "rsi14": "54.2",
      "atr14": "180.5",
      "candle_q": "0.26",
      "atr_pct": "0.28"
    }
  }
}
```

| Field | Description |
|------|----------|
| `type` | Always `bar_close` for a trading trigger. |
| `symbol` / `interval` | Which pair and TF closed. |
| `active_provider` | Who formed the bar. |
| `bar` | Full closed bar (+ indicators on 1m, including candle shape). Dedup `` `${symbol}:${bar.ts}` ``. **Without** `trade_cost` / `cost_risk`. |

On an opt-in subscription Oracle additionally sends `microstructure_close` after the object is saved in Redis. Join it with the bar on `symbol + ts`; REST `/v1/microstructure/{symbol}` is the source of gap recovery.

After `bar_close` update local state; **`GET /v1/context/{symbol}`** — on a **60–90 s** schedule and **always before entry** (that is where `trade_cost` / `cost_risk` / freshness live). Live `depth` in context and closed minute microstructure are different entities.

Reconnect: initial **1s**, multiplier **2×**, max **60s**, jitter **±25%**. After reconnect — **serial** backfill (`history` / `bootstrap`) with pauses **≥ min_interval_ms**.

---

### 14. Errors: `key_expired` and `429`

First check **HTTP** (`res.ok` / `res.status`), then JSON **`code`** (on errors it equals `status`). Do not mix HTTP 401 with the `body.status` field.

**Expired key (any REST)**

```http
GET /v1/latest?symbols=BTCUSDT
Authorization: Bearer mo_expired…
```

HTTP `401` + body:

```json
{
  "status": "key_expired",
  "code": "key_expired",
  "error": "API key expired",
  "message": "API key expired",
  "key_id": "key_bf6ed463bc8d112f",
  "expires_at": "2026-06-01T00:00:00+00:00"
}
```

| Field | Description |
|------|----------|
| `status` / `code` | Machine code in the **body** (not HTTP status). Stop trading, request a renewal from the operator. |
| `key_id` | Which key expired. |
| `expires_at` | When exactly the term ended (ISO-8601 UTC in the error body). |

Similarly: `key_revoked`, `key_invalid`, `key_missing`, `unavailable` (HTTP 503).

**Limit exceeded** — HTTP `429`:

```json
{
  "status": "quota_exceeded",
  "code": "quota_exceeded",
  "error": "daily REST request quota exceeded",
  "message": "daily REST request quota exceeded",
  "retry_after_ms": 3600000
}
```

| Field | Description |
|------|----------|
| `status` / `code` | `too_fast` / `rate_limited` / `quota_exceeded` / `ws_limit`. `quota_exceeded` — daily **billable REST requests** are exhausted (1 HTTP = 1), not endpoint weights. For `ws_limit` on WS upgrade the `status` field may be absent — use `code`. |
| `retry_after_ms` | Recommended pause before retry (ms). Add jitter; see backoff above. |

Also look at headers `X-RateLimit-*`, `Retry-After`, and `X-Quota-Used|Limit|Remaining|Cost|Weight|Reset`.
