Version: 1.0
Base URL: http://localhost:8080 (development) | https://btick-production.up.railway.app (production)
btick is a real-time Bitcoin price oracle service that aggregates prices from multiple exchanges (Binance, Coinbase, Kraken, and OKX) and produces a canonical price using multi-venue median pricing. This service is designed for prediction market settlement and real-time price feeds.
- Multi-venue median pricing — Manipulation-resistant canonical price
- Sub-second updates — Real-time trade-by-trade price changes
- 1-second snapshots — Stored for settlement and auditing
- 5-minute settlement prices — For prediction market resolution
- Quality scoring — Data freshness and source count metrics
- Outlier rejection — 1% deviation filter
- Content-Type: REST responses are
application/json;GET /metricsis Prometheus text format - CORS:
Access-Control-Allow-Origin: *— all origins permitted,GET,POST, andOPTIONSmethods allowed - Machine-readable spec: See
openapi.yamlfor the full OpenAPI 3.0 schema
When access.enabled is turned on, btick enforces API-key tiers:
| Route | Access |
|---|---|
GET /v1/health, GET /v1/health/feeds, GET /v1/price/latest, GET /v1/symbols, GET /v1/metadata, GET /metrics |
Public |
POST /v1/auth/signup |
Public when access.signup_enabled=true |
GET /v1/auth/me |
Any valid API key |
GET /v1/price/snapshots, GET /v1/price/ticks, GET /v1/price/settlement, WS /ws/price |
starter |
GET /v1/price/raw |
pro |
API keys can be sent as X-API-Key, Authorization: Bearer <key>, or api_key on the WebSocket query string.
Creates a new account and returns a generated API key.
Request:
{
"email": "operator@example.com",
"name": "Ops Team"
}Response (201):
{
"account_id": "9cb07885-2444-42d2-9e64-a2d82cf68d7d",
"email": "operator@example.com",
"name": "Ops Team",
"tier": "starter",
"api_key": "btk_...",
"api_key_prefix": "btk_12345678",
"created_at": "2026-04-13T10:15:00.000000000Z"
}Errors:
400invalid JSON or missing email409account already exists503database unavailable
If signup is disabled, the route is not registered and returns 404.
Returns the currently authenticated account bound to the supplied API key.
Headers:
X-API-Key: btk_...- or
Authorization: Bearer btk_...
Response (200):
{
"account_id": "9cb07885-2444-42d2-9e64-a2d82cf68d7d",
"email": "operator@example.com",
"name": "Ops Team",
"tier": "starter",
"api_key_prefix": "btk_12345678",
"active": true,
"created_at": "2026-04-13T10:15:00.000000000Z",
"last_used_at": "2026-04-13T10:16:04.000000000Z"
}Errors:
401missing or invalid API key503database unavailable
Returns presentation and product metadata for a canonical symbol. This is intended for UI trust surfaces, exchange cards, and integration portals.
Parameters:
symboloptional canonical symbol such asBTC/USD; defaults to the first configured symbol
Response (200):
{
"symbol": "BTC/USD",
"base_asset": "BTC",
"quote_asset": "USD",
"product_type": "price",
"product_sub_type": "reference",
"product_name": "BTC/USD-RefPrice-DS-Premium-Global-003",
"market_hours": "24/7",
"feed_id": "btick-refprice-btc-usd"
}If the symbol is unknown, the endpoint returns 404.
Prometheus-compatible process metrics for drops, latencies, writer flushes, and WebSocket load.
Response: 200 OK with Content-Type: text/plain; version=0.0.4; charset=utf-8
Example metrics:
# HELP btick_writer_flush_duration_seconds Duration of raw writer batch flushes in seconds.
# TYPE btick_writer_flush_duration_seconds histogram
# HELP btick_ws_clients Currently connected WebSocket clients.
# TYPE btick_ws_clients gauge
# HELP btick_ws_fanout_total Total WebSocket client deliveries by message type.
# TYPE btick_ws_fanout_total counter
# HELP btick_ws_type_drops_total Total dropped WebSocket messages by message type.
# TYPE btick_ws_type_drops_total counter
# HELP btick_ws_type_evictions_total Total WebSocket client evictions triggered by message type.
# TYPE btick_ws_type_evictions_total counter
# HELP btick_ws_symbol_fanout_total Total WebSocket client deliveries by message type and symbol.
# TYPE btick_ws_symbol_fanout_total counter
# HELP btick_ws_symbol_drops_total Total dropped WebSocket messages by message type and symbol.
# TYPE btick_ws_symbol_drops_total counter
# HELP btick_ws_symbol_evictions_total Total WebSocket client evictions triggered by message type and symbol.
# TYPE btick_ws_symbol_evictions_total counter
# HELP btick_ws_symbol_subscribers Currently connected WebSocket clients subscribed to each symbol.
# TYPE btick_ws_symbol_subscribers gauge
# HELP btick_ws_coalesced_total Total WebSocket updates superseded by burst coalescing before broadcast.
# TYPE btick_ws_coalesced_total counter
System health check with latest price status.
Response:
{
"status": "ok",
"timestamp": "2026-03-19T09:10:00.123456789Z",
"dependencies": {
"database": {
"ready": true
}
},
"latest_price": "70105.45",
"latest_ts": "2026-03-19T09:09:59.876543Z",
"source_count": 3
}Status values:
| Status | Description |
|---|---|
ok |
All systems healthy, fresh data |
degraded |
Fewer than minimum sources available |
stale |
No fresh data, using carry-forward |
no_data |
No price data available yet |
Note:
latest_price,latest_ts, andsource_countare omitted when status isno_data.
dependencies.database.readyreports whether persistence-backed features are initialized. During cold starts the API can return200while database readiness is stillfalse.
Per-source feed health status. Requires database.
Error: 503 with {"error":"database not available"} if no database configured.
Response (200):
[
{
"source": "binance",
"conn_state": "connected",
"last_message_ts": "2026-03-19T09:10:00.123Z",
"last_trade_ts": "2026-03-19T09:10:00.100Z",
"median_lag_ms": 45,
"reconnect_count_1h": 0,
"consecutive_errors": 0,
"stale": false,
"updated_at": "2026-03-19T09:10:00.123456789Z"
},
{
"source": "coinbase",
"conn_state": "connected",
"last_message_ts": "2026-03-19T09:10:00.089Z",
"stale": false,
"updated_at": "2026-03-19T09:10:00.123456789Z"
},
{
"source": "kraken",
"conn_state": "connected",
"last_message_ts": "2026-03-19T09:09:59.950Z",
"stale": false,
"updated_at": "2026-03-19T09:10:00.123456789Z"
},
{
"source": "okx",
"conn_state": "connected",
"last_message_ts": "2026-03-19T09:10:00.091Z",
"stale": false,
"updated_at": "2026-03-19T09:10:00.123456789Z"
}
]Optional fields:
last_message_ts,last_trade_ts, andmedian_lag_msare omitted when their values are zero/unset.
Get the current canonical BTC/USD price (from memory, lowest latency).
Response (200):
{
"symbol": "BTC/USD",
"ts": "2026-03-19T09:10:00.123456789Z",
"price": "70105.45",
"basis": "median_trade",
"is_stale": false,
"is_degraded": false,
"quality_score": 0.9556,
"source_count": 4,
"sources_used": ["binance", "coinbase", "kraken", "okx"]
}Error: 503 with {"error":"no data yet"} if no price has been computed since startup.
Field descriptions:
| Field | Type | Description |
|---|---|---|
symbol |
string | Canonical symbol (always BTC/USD) |
ts |
string | Timestamp of the price event (RFC3339Nano) |
price |
string | Canonical price in USD (decimal string for precision) |
basis |
string | How the price was computed (see below) |
is_stale |
boolean | True if data is outdated (carry-forward) |
is_degraded |
boolean | True if fewer than minimum sources |
quality_score |
float | 0-1 quality metric |
source_count |
int | Number of sources used |
sources_used |
array | List of source names |
Basis values:
| Basis | Description |
|---|---|
median_trade |
Median of trade prices from multiple venues |
median_mixed |
Median including midpoint fallbacks |
single_trade |
Only one venue had fresh trade data |
single_midpoint |
Only midpoint data available |
carry_forward |
No fresh data, using last known price |
⭐ PRIMARY ENDPOINT FOR MARKET SETTLEMENT
Get the official settlement price at a specific 5-minute boundary.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
ts |
string | Yes | Settlement timestamp in RFC3339 format. Must be on a 5-minute boundary (e.g., 2026-03-19T09:05:00Z, 2026-03-19T09:10:00Z) |
Example Request:
GET /v1/price/settlement?ts=2026-03-19T09:10:00Z
Success Response (200):
{
"settlement_ts": "2026-03-19T09:10:00Z",
"symbol": "BTC/USD",
"price": "70105.45",
"status": "confirmed",
"basis": "median_trade",
"quality_score": 0.9556,
"source_count": 4,
"sources_used": ["binance", "coinbase", "kraken", "okx"],
"finalized_at": "2026-03-19T09:10:01.251996789Z",
"source_details": "eyJiaW5hbmNlIjp7InByaWNlIjoiNzAxMDUuNDUiLCJ0cyI6IjIwMjYtMDMtMTlUMDk6MDk6NTkuOTk5WiJ9fQ=="
}
source_detailsis thesource_details_jsonJSONB column from the snapshot, base64-encoded by Go'sencoding/json(since the column type is[]byte). Decode from base64 to get JSON like:{"binance":{"price":"70105.45","ts":"2026-03-19T09:09:59.999Z"}, ...}
Status values:
| Status | Description | Action |
|---|---|---|
confirmed |
High quality, multi-source price | ✅ Safe to use for settlement |
degraded |
Fewer than minimum sources | |
stale |
No fresh data at settlement time | ❌ Consider dispute/manual resolution |
Error Responses:
| Code | Error | Description |
|---|---|---|
| 400 | ts parameter required (RFC3339 format, e.g. 2026-03-19T09:05:00Z) |
Missing timestamp parameter |
| 400 | invalid ts format, use RFC3339 (e.g. 2026-03-19T09:05:00Z) |
Use RFC3339 format |
| 400 | ts must be on a 5-minute boundary (e.g. 09:05:00, 09:10:00) |
e.g., 09:05:00, 09:10:00 |
| 400 | ts cannot be in the future |
Cannot query future prices |
| 425 | settlement price not yet finalized, wait a few seconds |
Wait at least 5 seconds after the boundary |
| 404 | settlement price not found for this timestamp |
No data for this timestamp |
Integration Example (Go):
func getSettlementPrice(marketCloseTime time.Time) (*SettlementPrice, error) {
url := fmt.Sprintf("https://btick-production.up.railway.app/v1/price/settlement?ts=%s",
marketCloseTime.UTC().Format(time.RFC3339))
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("settlement failed: %d", resp.StatusCode)
}
var result SettlementPrice
json.NewDecoder(resp.Body).Decode(&result)
// Validate quality
if result.Status == "stale" {
return nil, errors.New("settlement price is stale, manual review required")
}
return &result, nil
}Query historical 1-second snapshots.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
start |
string | Yes | Start time (RFC3339) |
end |
string | No | End time (RFC3339), defaults to now |
Example Request:
GET /v1/price/snapshots?start=2026-03-19T09:00:00Z&end=2026-03-19T09:05:00Z
Error Responses:
| Code | Error | When |
|---|---|---|
| 400 | start parameter required |
Missing start |
| 400 | invalid start time format, use RFC3339 |
Bad start format |
| 400 | invalid end time format, use RFC3339 |
Bad end format |
| 503 | database not available |
No database configured |
Response (200):
[
{
"ts_second": "2026-03-19T09:00:00Z",
"symbol": "BTC/USD",
"price": "70100.00",
"basis": "median_trade",
"is_stale": false,
"is_degraded": false,
"quality_score": 0.95,
"source_count": 4,
"sources_used": ["binance", "coinbase", "kraken", "okx"],
"finalized_at": "2026-03-19T09:00:01.250Z"
},
{
"ts_second": "2026-03-19T09:00:01Z",
"symbol": "BTC/USD",
"price": "70101.50",
"basis": "median_trade",
"quality_score": 0.94,
"source_count": 4,
"sources_used": ["binance", "coinbase", "kraken", "okx"],
"finalized_at": "2026-03-19T09:00:02.250Z"
}
]Query recent canonical price change events.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
limit |
int | No | Number of ticks to return (default: 100, max: 1000) |
Error: 503 with {"error":"database not available"} if no database configured.
Response (200):
[
{
"ts": "2026-03-19T09:10:00.123456789Z",
"symbol": "BTC/USD",
"price": "70105.45",
"basis": "median_trade",
"is_stale": false,
"is_degraded": false,
"quality_score": 0.9556,
"source_count": 4,
"sources_used": ["binance", "coinbase", "kraken", "okx"]
}
]Query raw tick data from individual exchanges (for debugging/auditing).
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
source |
string | No | Filter by source (binance, coinbase, kraken, okx) |
start |
string | No | Start time (RFC3339) |
end |
string | No | End time (RFC3339) |
limit |
int | No | Number of events (default: 100) |
Error Responses:
| Code | Error | When |
|---|---|---|
| 400 | invalid start time |
Bad start format |
| 400 | invalid end time |
Bad end format |
| 503 | database not available |
No database configured |
Response (200):
[
{
"event_id": "01956789-abcd-7000-8000-000000000001",
"source": "binance",
"event_type": "trade",
"exchange_ts": "2026-03-19T09:10:00.123456789Z",
"recv_ts": "2026-03-19T09:10:00.145678901Z",
"price": "70105.45",
"size": "0.5",
"side": "buy",
"trade_id": "123456789"
}
]ws://localhost:8080/ws/price
wss://btick-production.up.railway.app/ws/price
Optional connect-time symbol filtering is available via the symbols query parameter. The filter is applied before initial-state messages are sent, so clients only receive the requested symbols from the first handshake onward.
wss://btick-production.up.railway.app/ws/price?symbols=ETH%2FUSD,SOL%2FUSD
Multiple symbols can be passed as a comma-separated list. Unknown symbols are ignored. If none of the requested symbols currently have state, the server sends no_data_yet.
On connect, the server sends two messages before any live broadcast data:
1. Welcome message (always first):
{
"type": "welcome",
"ts": "2026-03-19T09:10:00.123456789Z",
"message": "btick/v1"
}2. Initial state (current price, or no-data indicator):
{
"type": "latest_price",
"ts": "2026-03-19T09:10:00.123456789Z",
"price": "70105.45",
"basis": "median_trade",
"quality_score": "0.9556",
"source_count": 4,
"sources_used": ["binance", "coinbase", "kraken", "okx"],
"message": "initial_state"
}If no data is available yet:
{
"type": "latest_price",
"ts": "2026-03-19T09:10:00.123456789Z",
"message": "no_data_yet"
}Welcome and initial state messages do not carry a seq field — they are per-connection, not broadcast.
After these two messages, live broadcast data begins flowing.
All broadcast messages carry a monotonically increasing seq (uint64) for gap detection.
Sent on every trade that changes the canonical price (sub-second).
{
"type": "latest_price",
"seq": 42,
"ts": "2026-03-19T09:10:00.123456789Z",
"price": "70105.45",
"basis": "median_trade",
"quality_score": "0.9556",
"source_count": 4,
"sources_used": ["binance", "coinbase", "kraken", "okx"],
"is_stale": false
}Sent every second with the finalized price for that second.
{
"type": "snapshot_1s",
"seq": 43,
"ts": "2026-03-19T09:10:00Z",
"price": "70105.45",
"basis": "median_trade",
"quality_score": "0.9556",
"source_count": 4,
"sources_used": ["binance", "coinbase", "kraken", "okx"],
"is_stale": false
}Sent at a configurable interval (default 5 seconds). Carries seq so clients can confirm their sequence is current during quiet periods.
{
"type": "heartbeat",
"seq": 44,
"ts": "2026-03-19T09:10:05.000000000Z"
}All broadcast messages (including heartbeats) share a single monotonically increasing seq counter. Clients detect gaps to know when messages were missed:
- Received
seq: 10thenseq: 13→ missed 2 messages - Action: call
GET /v1/price/latestto resync current state
With subscription filtering, clients subscribed to a subset of message types or symbols will naturally see seq gaps — this is expected and not an indication of dropped messages.
Clients can subscribe/unsubscribe from specific message types and symbols. By default, all message types and all symbols are subscribed (backward-compatible — a client that sends nothing receives everything).
For clients that already know their symbol set at connect time, prefer the symbols query parameter on the WebSocket URL so unwanted initial-state messages are never sent.
Subscribe:
{"action": "subscribe", "types": ["snapshot_1s", "latest_price", "heartbeat"]}Subscribe only selected symbols:
{"action": "subscribe", "types": ["snapshot_1s", "latest_price"], "symbols": ["ETH/USD"]}Unsubscribe:
{"action": "unsubscribe", "types": ["snapshot_1s"]}Unsubscribe a symbol while leaving all others enabled:
{"action": "unsubscribe", "symbols": ["BTC/USD"]}Available types: snapshot_1s, latest_price, heartbeat
Symbols field: optional. Omit it to keep all symbols enabled.
Unknown actions, types, and symbols are silently ignored (forward-compatible).
Example — chart client that only needs snapshots:
{"action": "unsubscribe", "types": ["latest_price", "heartbeat"]}| Feature | Default | Configurable |
|---|---|---|
| Send buffer per client | 256 messages | server.ws.send_buffer_size |
| Application heartbeat | Every 5s | server.ws.heartbeat_interval_sec |
| WebSocket ping | Every 30s | server.ws.ping_interval_sec |
| Read deadline (pong timeout) | 60s | server.ws.read_deadline_sec |
- Drop handling: If a client's send buffer fills (slow consumer), messages are dropped silently rather than blocking other clients. Drops are logged server-side every 100 occurrences.
- Burst handling: During high-frequency bursts, the server coalesces queued updates by message type and symbol/source before fan-out so clients receive the freshest state without the API loop falling behind.
- Reconnection: Client should implement exponential backoff. On reconnect, the client receives a fresh welcome + initial state.
class BTCPriceSocket {
constructor(url) {
this.url = url;
this.reconnectDelay = 1000;
this.maxReconnectDelay = 30000;
this.lastSeq = 0;
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('Connected to BTC price feed');
this.reconnectDelay = 1000;
// Optional: only subscribe to what you need
// this.ws.send(JSON.stringify({
// action: 'unsubscribe',
// types: ['snapshot_1s']
// }));
};
this.ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
// Detect gaps in sequence numbers
if (msg.seq) {
if (this.lastSeq > 0 && msg.seq > this.lastSeq + 1) {
console.warn(`Missed ${msg.seq - this.lastSeq - 1} messages`);
// Optionally resync: fetch('/v1/price/latest')
}
this.lastSeq = msg.seq;
}
switch (msg.type) {
case 'welcome':
console.log('Server:', msg.message);
break;
case 'latest_price':
if (msg.message === 'initial_state') {
this.onInitialState(msg);
} else if (msg.message === 'no_data_yet') {
console.log('Waiting for first price data...');
} else {
this.onPriceUpdate(msg);
}
break;
case 'snapshot_1s':
this.onSnapshot(msg);
break;
case 'heartbeat':
// Connection alive confirmation
break;
}
};
this.ws.onclose = () => {
console.log(`Reconnecting in ${this.reconnectDelay}ms...`);
setTimeout(() => this.connect(), this.reconnectDelay);
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
};
this.ws.onerror = (err) => {
console.error('WebSocket error:', err);
this.ws.close();
};
}
onInitialState(msg) {
console.log(`Initial price: $${msg.price} (${msg.source_count} sources)`);
}
onPriceUpdate(msg) {
console.log(`Price: $${msg.price} [seq:${msg.seq}]`);
}
onSnapshot(msg) {
console.log(`Snapshot: $${msg.price} @ ${msg.ts} [seq:${msg.seq}]`);
}
}
// Usage
const priceSocket = new BTCPriceSocket('wss://btick-production.up.railway.app/ws/price');┌──────────────────────────────────────────────────────────────────────┐
│ 5-Minute Market Lifecycle │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ 09:05:00 ─────────────────────────────────────────────── 09:10:00 │
│ │ │ │
│ │ Market Open Market Close │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ WS /ws/price → Display live price to traders │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ GET /v1/price/settlement
│ │ ?ts=2026-03-19T09:10:00Z
│ │ │ │
│ │ ▼ │
│ │ ┌──────────────┐ │
│ │ │ Settlement │ │
│ │ │ price=$70105 │ │
│ │ └──────────────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌──────────────┐ │
│ │ │ Resolve bets │ │
│ │ │ Pay winners │ │
│ │ └──────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────┘
- Wait for finalization — Call settlement endpoint at least 5 seconds after market close
- Check status — Only auto-settle if
status === "confirmed" - Handle degraded — Queue for manual review if
status === "degraded" - Handle stale — Trigger dispute flow if
status === "stale" - Store audit trail — Save
source_detailsandfinalized_atfor disputes
func settleMarket(marketID string, closeTime time.Time) error {
// Wait for finalization
time.Sleep(5 * time.Second)
// Get settlement price
settlement, err := priceOracle.GetSettlement(closeTime)
if err != nil {
return fmt.Errorf("failed to get settlement: %w", err)
}
// Validate quality
switch settlement.Status {
case "confirmed":
// Auto-settle
return db.SettleMarket(marketID, settlement.Price, settlement)
case "degraded":
// Queue for review
return db.QueueForReview(marketID, settlement, "degraded_quality")
case "stale":
// Trigger dispute
return db.TriggerDispute(marketID, settlement, "stale_price_data")
default:
return fmt.Errorf("unknown settlement status: %s", settlement.Status)
}
}The quality_score (0-1) is computed based on:
| Factor | Weight | Description |
|---|---|---|
| Source count | 50% | More sources = higher score (max at 3) |
| Data freshness | 30% | Newer data = higher score |
| Data type | 20% | Trade prices preferred over midpoints |
Recommended thresholds:
| Score | Quality | Recommended Action |
|---|---|---|
| ≥ 0.8 | High | Auto-settle |
| 0.5-0.8 | Medium | Auto-settle with monitoring |
| < 0.5 | Low | Manual review recommended |
Data is considered stale when:
- No fresh trades within the trade freshness window (default 2 seconds, configurable via
trade_freshness_window_ms) - The canonical price age exceeds the stale threshold (default 3 seconds, configurable via
canonical_stale_after_ms) - All venue connections are down
When stale, the system carries forward the last known price for up to 10 seconds (configurable via carry_forward_max_seconds).
No application-level rate limiting is currently implemented. Consider adding a reverse proxy (e.g., nginx, Caddy) for production rate limiting.
| Endpoint | Notes |
|---|---|
| REST APIs | No built-in rate limit |
| WebSocket | No connection limit enforced (slow consumers are dropped) |
All errors return JSON with an error field:
{
"error": "description of the error"
}Common HTTP status codes:
| Code | Meaning |
|---|---|
| 200 | Success |
| 400 | Bad request (invalid parameters) |
| 404 | Not found (no data for timestamp) |
| 425 | Too early (data not finalized) |
| 500 | Internal server error |
| 503 | Service unavailable (database down) |
- Provider updates: Coinbase now uses the public Exchange
ws-feed(no JWT required) - Provider updates: OKX added as a fourth market data source
- Storage: Timescale migration made compatible with the Railway TimescaleDB build in use
- WebSocket: welcome message + initial state on connect (no more ~1s wait)
- WebSocket: sequence numbers on all broadcast messages for gap detection
- WebSocket: subscription filtering (
subscribe/unsubscribeactions) - WebSocket: application-level heartbeat (default 5s, configurable)
- WebSocket: per-client drop logging
- WebSocket: configurable send buffer, ping interval, read deadline
- API:
Store/Engineinterfaces for testability (100% test coverage)
- Initial release
- Multi-venue median pricing (Binance, Coinbase, Kraken)
- WebSocket real-time feed
- Settlement price endpoint for 5-minute markets
- Quality scoring and stale detection