Version: 3.1 | Last Updated: April 27, 2026 (gamma volume24hr regression note + 2-pass collector + /api/integrity) | Status: ✅ VERIFIED WORKING
What changed in v3.1 (Apr 27, 2026): - Gamma
volume24hrfield is intermittently NULL (~50% null rate per live probe). Long-term volume fields (volume,volume1wk,volume1mo,volume1yr) remain reliable. See §1 + Finding #272. - Collector now runs 2 passes (active=true&archived=falseANDclosed=true&archived=false) to capture resolved-but-not-archived markets that the single-pass approach missed (Finding #229). - Markets-tablestatusfield now reflects Polymarket'sclosedflag, notactive(Finding #229). - New endpoint/api/integrity(§9) for live integrity-monitor surface (replaces v2/v3 watchdogs Apr 27). - New endpoint/api/architecture-findingsfor dashboard Testing Findings tab.
| Data Type | Correct Source | Wrong Source / Notes |
|---|---|---|
| Live Prices | Gamma API outcomePrices |
~~CLOB API /book~~ |
| Conviction/Probability | Gamma API outcomePrices |
~~Orderbook midpoint~~ |
| Cumulative Volume (gate) | max(markets.volume_total, Gamma "volume") |
Trader's SIGNAL_MIN_VOLUME_TOTAL=$1,000 gate (Finding #311 lowered it from $2k) uses this via effective_cumulative_volume_usd() shared module. Apr 30, 2026 (Finding #282) changed semantic from "DB else Gamma" → max(DB, Gamma) because once-per-15-min markets.volume_total sync became stale within seconds for active markets, silently rejecting tradeable signals (e.g., MRNA #2026-05-01 at $6.28 < $2k while live Gamma was $3,734). |
| 24h Volume (display only) | price_snapshots.volume_24h (DB) |
⚠️ Gamma's volume24hr field is intermittently NULL (~50% null rate per Apr 27 live probe — Finding #272). Use price_snapshots.volume_24h (collector captures it on every cycle when Gamma briefly returns it) as fallback. Do NOT use 24h volume for the trader's volume gate. |
| Orderbook Depth | CLOB API /book |
N/A |
| Historical Trades | Polymarket data-api /trades?market=<cid> (Findings #287/#288 — formerly Dome /orders until Apr 28, 2026 EOL) |
N/A |
| Historical Prices | CLOB /prices-history?market=<token>&startTs=...&endTs=... (Findings #287/#289 — formerly Dome /market-price; rate limit 1,000 req/10s) |
~~Candlesticks~~ |
| Equity 1m bars | Polygon /v2/aggs/ticker/.../range/1/minute/... |
Cached in equity_bars_1m hypertable (Finding #240, NEW Apr 26) — read via src/earnings/equity_bar_cache.py:fetch_minute_bars_cached(). |
The following Gamma API fields are JSON STRINGS, not arrays:
# These fields need json.loads() before use!
- outcomePrices: '["0.75", "0.25"]' # NOT ["0.75", "0.25"]
- clobTokenIds: '["123...", "456..."]'
- outcomes: '["Yes", "No"]'
# Correct parsing:
import json
outcome_prices = market.get('outcomePrices', '[]')
if isinstance(outcome_prices, str):
outcome_prices = json.loads(outcome_prices)
yes_price = float(outcome_prices[0]) if outcome_prices else 0.5
| Parameter | Correct | Wrong |
|---|---|---|
| Filter by condition ID | condition_ids (plural, snake_case) |
~~conditionId~~ |
| Filter closed | closed=true |
N/A |
| Filter active | active=true |
N/A |
Base URL: https://gamma-api.polymarket.com
| Endpoint | Purpose | Key Fields |
|---|---|---|
/markets |
List/search markets | conditionId, outcomePrices, volume |
/events |
List events (multi-market) | markets array |
/tags |
Get available tags | For filtering |
import requests
import json
GAMMA_URL = 'https://gamma-api.polymarket.com'
def get_market_by_condition_id(condition_id: str) -> dict:
"""Fetch market data from Gamma API."""
resp = requests.get(
f'{GAMMA_URL}/markets',
params={'condition_ids': condition_id} # NOTE: plural!
)
resp.raise_for_status()
markets = resp.json()
if not markets:
return None
market = markets[0]
# Parse JSON string fields
outcome_prices = market.get('outcomePrices', '[]')
if isinstance(outcome_prices, str):
outcome_prices = json.loads(outcome_prices)
clob_token_ids = market.get('clobTokenIds', '[]')
if isinstance(clob_token_ids, str):
clob_token_ids = json.loads(clob_token_ids)
return {
'condition_id': market.get('conditionId'),
'question': market.get('question'),
'yes_price': float(outcome_prices[0]) if outcome_prices else 0.5,
'no_price': float(outcome_prices[1]) if len(outcome_prices) > 1 else 0.5,
'volume': market.get('volume', 0),
'token_id_yes': clob_token_ids[0] if clob_token_ids else None,
'token_id_no': clob_token_ids[1] if len(clob_token_ids) > 1 else None,
'active': market.get('active', False),
'closed': market.get('closed', False),
}
def get_all_earnings_markets() -> list:
"""
Find all earnings beat markets.
NOTE: tag=Earnings filter is unreliable!
Must search ALL markets and filter by question.
CRITICAL: As of Apr 25, 2026 (Finding #229), Polymarket's
`?active=true&archived=false` filter does NOT include markets in the
`closed=true active=true` state — settled-but-not-archived earnings
markets that sit there for days/weeks before being archived. We must
run TWO passes and de-dupe by condition_id, otherwise every recently-
resolved earnings market loses its final volume_total update on the
day it resolves.
"""
markets_by_cid = {}
for params in [
{'tag_id': 1013, 'limit': 500, 'active': 'true', 'archived': 'false'},
{'tag_id': 1013, 'limit': 500, 'closed': 'true', 'archived': 'false'}, # ← critical
]:
offset = 0
while True:
resp = requests.get(
f'{GAMMA_URL}/events/pagination',
params={**params, 'offset': offset},
)
resp.raise_for_status()
data = resp.json()
events = data.get('data', []) if isinstance(data, dict) else (data or [])
if not events:
break
for event in events:
for m in event.get('markets', []):
cid = m.get('conditionId') or m.get('condition_id')
if not cid:
continue
# Closed-pass wins (latest volume settlement) since we process
# active first, closed second.
markets_by_cid[cid] = m
offset += 500
return list(markets_by_cid.values())
Status mapping (production code in continuous_collector.sync_markets_from_gamma):
# Status reflects Polymarket's `closed` flag (settled), NOT the `active` flag
# (which can be True even after settlement). Finding #229.
'status': 'closed' if m.get('closed') else 'open'
{
"conditionId": "0x13c8546e...",
"question": "Will NVIDIA (NVDA) beat quarterly earnings?",
"outcomePrices": "[\"0.75\", \"0.25\"]", // JSON STRING!
"clobTokenIds": "[\"35741...\", \"19559...\"]", // JSON STRING!
"outcomes": "[\"Yes\", \"No\"]", // JSON STRING!
"volume": 12345.67,
"liquidity": 5000.0,
"active": true,
"closed": false,
"tags": ["Finance", "Earnings"]
}
Base URL: https://clob.polymarket.com
The CLOB API /book endpoint returns order levels, NOT market prices!
| Endpoint | Purpose | Use For |
|---|---|---|
/book |
Orderbook depth | Liquidity analysis ONLY |
/price |
Current price | Backup only |
/midpoint |
Midpoint price | Backup only |
CLOB_URL = 'https://clob.polymarket.com'
def get_orderbook_depth(token_id: str) -> dict:
"""
Get orderbook for liquidity analysis.
WARNING: Do NOT use bids/asks midpoint as the market price!
"""
resp = requests.get(
f'{CLOB_URL}/book',
params={'token_id': token_id}
)
resp.raise_for_status()
orderbook = resp.json()
bids = orderbook.get('bids', [])
asks = orderbook.get('asks', [])
# Calculate depth (for liquidity scoring)
bid_depth = sum(float(b['size']) for b in bids[:5])
ask_depth = sum(float(a['size']) for a in asks[:5])
return {
'bids': bids,
'asks': asks,
'bid_depth': bid_depth,
'ask_depth': ask_depth,
'spread': float(asks[0]['price']) - float(bids[0]['price']) if bids and asks else None
}
Base URL: https://data-api.polymarket.com
Migration note (Findings #287/#288/#289, May 10, 2026): the previous "Dome API" section described
https://api.domeapi.io/v1. Dome was acquired by Polymarket and all Dome APIs reached end-of-life on April 28, 2026. All consumers (backfill_trades.py,live_trades_collector.py) now hit the new Polymarket data-api. No API key required (public endpoints).
| Endpoint | Limit | vs Dome free tier |
|---|---|---|
/trades |
200 req / 10s = 20 q/s sustained | 20× faster |
/positions, /closed-positions |
150 req / 10s | n/a |
| General data-api | 1,000 req / 10s | n/a |
| Endpoint | Purpose | Used by |
|---|---|---|
/trades?market=<cid>&limit=N&offset=M |
Historical trades for a market | backfill_trades.py (Finding #288 smart Option A) |
/trades?asset_id=<token>&limit=N |
Recent trades for a token | live_trades_collector.py (#289 cosmetic rename) |
/positions / /closed-positions |
User positions | not actively used by earnings strategy |
/activity |
User actions (REDEEM/SPLIT/MERGE) | not actively used |
For historical prices (replaces Dome /market-price):
| Endpoint | Purpose | Rate limit |
|---|---|---|
clob.polymarket.com/prices-history |
?market=<token>&startTs=N&endTs=N&interval=1m\|1h\|1d&fidelity=N |
1,000 req/10s |
clob.polymarket.com/batch-prices-history |
POST body — up to 20 markets per request | 1,000 req/10s |
Historical orderbook: NO direct replacement after Dome /orderbooks EOL.
Live capture continues via WSS wss://ws-subscriptions-clob.polymarket.com/ws/market
(via polymarket-websocket.service) and polymarket-collector.service.
/trades)Top-level array (NOT wrapped in orders like Dome was). Each record:
{
"proxyWallet": "0x...",
"side": "BUY|SELL",
"asset": "<token_id_int>",
"conditionId": "0x...",
"size": 5.03,
"price": 0.999,
"timestamp": 1768102036,
"title": "Bitcoin Up or Down — May 9, 4:05PM-4:10PM ET",
"slug": "btc-updown-...",
"outcome": "Down",
"transactionHash": "0x..."
}
Field migration vs old Dome /orders shape:
| Old Dome field | New PM data-api field |
|---|---|
| tx_hash | transactionHash (camelCase) |
| user | proxyWallet |
| condition_id | conditionId (camelCase) |
| shares_normalized | size (already normalized) |
| token_label | outcome |
| order_hash | (NOT in PM response — drop) |
| taker | (NOT in PM response — takerOnly=true default) |
import requests
DATA_API = 'https://data-api.polymarket.com'
def get_market_trades(condition_id: str, offset: int = 0, limit: int = 500) -> list:
"""Get historical trades for a market via Polymarket data-api.
No auth required. Newest-first ordering. Pagination via offset (max 10,000).
Free tier: 200 req/10s = 20 q/s sustained.
"""
resp = requests.get(
f'{DATA_API}/trades',
params={
'market': condition_id,
'limit': limit,
'offset': offset,
'takerOnly': 'true', # OpenAPI default
},
timeout=30,
)
resp.raise_for_status()
return resp.json() # top-level array; NOT wrapped
For the production-grade implementation with smart Option A targeting (T1 delta,
T2 smart re-fetch, T3 earnings-only scope, circuit breaker, CLI flags), see
src/data_tracker/collectors/backfill_trades.py (Findings #269 + #288).
| Gamma API Field | DB Table | DB Column | Notes |
|---|---|---|---|
conditionId |
markets | condition_id | Primary identifier |
clobTokenIds[0] |
markets | token_id_yes | For CLOB queries |
clobTokenIds[1] |
markets | token_id_no | For CLOB queries |
question |
markets | question | Market question |
outcomePrices[0] |
price_snapshots | yes_price | The conviction! |
outcomePrices[1] |
price_snapshots | no_price | 1 - yes_price |
volume |
markets / price_snapshots | volume_total / volume_24h | Trading volume |
active |
markets | status | 'open' or 'closed' |
price_snapshots:
time: TIMESTAMP WITH TIME ZONE -- UTC
condition_id: VARCHAR(66) -- Hex string (0x...)
yes_price: DECIMAL(10,4) -- 0.0000 to 1.0000
no_price: DECIMAL(10,4) -- 1 - yes_price
volume_24h: DECIMAL(20,2) -- USD
liquidity: DECIMAL(20,2) -- USD
| Collector | Frequency | Data | Source |
|---|---|---|---|
continuous_collector.py (polymarket-collector.service) |
Every ~1s loop | Live trades, prices, orderbook | gamma + clob (py-clob-client SDK) |
live_trades_collector.py (polymarket-live-trades.service) |
Adaptive 10/30/120s per market | Live trades w/ wallet + tx detail | data-api /trades?asset_id= |
websocket_collector.py (polymarket-websocket.service) |
Streaming | Live orderbook + price changes | WSS ws-subscriptions-clob market channel |
backfill_trades.py (polymarket-trades-backfill.timer) |
Weekly Sun 06:00 ET | Historical trades (T1 delta) | data-api /trades?market= (Finding #288 — formerly Dome) |
gamma_collector.py |
Periodic | Market metadata | Gamma /markets + /events |
polymarket-data-backfill.timer (Sun 04:00 ET) |
Weekly | Volume + conviction self-heal | gamma + data-api /trades (#231) |
polymarket-resolver.service (daily 06:00 ET) |
Daily | actual_beat outcome resolver (4-tier) | gamma + Polygon + Finnhub |
polymarket-equity-bars.service (daily 10:00 UTC) |
Daily | Equity 1m bars for upcoming-earnings symbols | Polygon /v2/aggs |
| Endpoint | Limit |
|---|---|
| General | 4,000 req/10s |
/markets |
300 req/10s |
Tiered based on account level.
Per https://docs.polymarket.com/api-reference/rate-limits.md (verified May 10, 2026):
| Endpoint | Limit |
|---|---|
/trades |
200 req / 10s = 20 q/s sustained (was: Dome 1 q/s — 20× faster) |
/positions / /closed-positions |
150 req / 10s |
| General data-api | 1,000 req / 10s |
Backfill Estimates (post-#288 migration): - ~929 earnings markets × ~10 calls each cold = ~9,000 requests / ~8 minutes (vs ~3 hours on Dome) - Warm weekly delta: ~900 calls / ~1-2 minutes (T1 delta filter trims most pages)
# WRONG - CLOB bids/asks are ORDER LEVELS, not the price!
resp = requests.get(f'{CLOB_URL}/book?token_id={token_id}')
orderbook = resp.json()
price = (float(orderbook['bids'][0]['price']) + float(orderbook['asks'][0]['price'])) / 2
# CORRECT - Use Gamma API outcomePrices
resp = requests.get(f'{GAMMA_URL}/markets', params={'condition_ids': condition_id})
market = resp.json()[0]
outcome_prices = json.loads(market['outcomePrices'])
price = float(outcome_prices[0])
# WRONG - Uses singular camelCase
resp = requests.get(url, params={'conditionId': cid})
# CORRECT - Uses plural snake_case
resp = requests.get(url, params={'condition_ids': cid})
# WRONG - Treats as array directly
outcome_prices = market['outcomePrices']
yes_price = float(outcome_prices[0]) # ERROR!
# CORRECT - Parse JSON string first
outcome_prices = market.get('outcomePrices', '[]')
if isinstance(outcome_prices, str):
outcome_prices = json.loads(outcome_prices)
yes_price = float(outcome_prices[0]) if outcome_prices else 0.5
# WRONG - tag=Earnings misses many markets!
resp = requests.get(url, params={'tag': 'Earnings'})
# CORRECT - Search by question pattern
for m in all_markets:
if 'beat' in m['question'].lower() and 'earnings' in m['question'].lower():
earnings_markets.append(m)
# WRONG - Includes resolved markets
all_markets = fetch_all_markets()
# CORRECT - Filter by open status
for m in all_markets:
if m.get('closed') == False or m.get('active') == True:
open_markets.append(m)
import requests
import json
from typing import Optional, Dict, List
GAMMA_URL = 'https://gamma-api.polymarket.com'
CLOB_URL = 'https://clob.polymarket.com'
class PolymarketClient:
"""
Polymarket API client with correct data source usage.
"""
def get_market_price(self, condition_id: str) -> Optional[Dict]:
"""
Get current price from Gamma API (CORRECT source).
"""
resp = requests.get(
f'{GAMMA_URL}/markets',
params={'condition_ids': condition_id}
)
resp.raise_for_status()
markets = resp.json()
if not markets:
return None
market = markets[0]
# Parse JSON string fields
outcome_prices = market.get('outcomePrices', '[]')
if isinstance(outcome_prices, str):
outcome_prices = json.loads(outcome_prices)
return {
'condition_id': market.get('conditionId'),
'yes_price': float(outcome_prices[0]) if outcome_prices else 0.5,
'no_price': float(outcome_prices[1]) if len(outcome_prices) > 1 else 0.5,
'volume': market.get('volume', 0),
'question': market.get('question'),
}
def get_orderbook_depth(self, token_id: str) -> Optional[Dict]:
"""
Get orderbook for DEPTH analysis only (NOT price!).
"""
resp = requests.get(
f'{CLOB_URL}/book',
params={'token_id': token_id}
)
resp.raise_for_status()
orderbook = resp.json()
bids = orderbook.get('bids', [])
asks = orderbook.get('asks', [])
return {
'bid_depth': sum(float(b['size']) for b in bids[:5]),
'ask_depth': sum(float(a['size']) for a in asks[:5]),
'spread': float(asks[0]['price']) - float(bids[0]['price']) if bids and asks else None,
}
def find_earnings_markets(self) -> List[Dict]:
"""
Find all open earnings beat markets.
NOTE: This single-pass example is illustrative only. Production
collection MUST use the 2-pass pattern (active + closed, de-duped by
condition_id) shown in §2 "Find Earnings Markets — 2-pass collector
pattern" (Finding #229), otherwise settled-but-not-archived markets
lose their final volume_total update on the day they resolve.
"""
earnings = []
offset = 0
while True:
resp = requests.get(
f'{GAMMA_URL}/markets',
params={'closed': 'false', 'limit': 500, 'offset': offset}
)
resp.raise_for_status()
markets = resp.json()
if not markets:
break
for m in markets:
question = m.get('question', '').lower()
if 'beat' in question and 'earnings' in question:
earnings.append(self.get_market_price(m['conditionId']))
offset += 500
return [e for e in earnings if e is not None]
The earnings dashboard exposes the following REST endpoints:
| Endpoint | Method | Description |
|---|---|---|
/api/health |
GET | Health check |
/api/state |
GET | Full dashboard state (mode, capital, positions, etc.) |
/api/signals |
GET | Current BMO/AMC trading signals |
/api/positions |
GET | Active positions from database |
/api/earnings-calendar |
GET | Upcoming earnings with Polymarket data |
/api/db-stats |
GET | Database statistics |
/api/conviction-feed |
GET | Recent conviction changes |
| Endpoint | Method | Description |
|---|---|---|
/api/trader-status |
GET | Earnings trader service status (running, mode, last activity) |
/api/ibkr-status |
GET | IBKR gateway connection status |
/api/testing-prod-status |
GET | TESTING_PROD mode progress |
/api/slippage-stats |
GET | Execution quality statistics |
| Endpoint | Method | Description |
|---|---|---|
/api/integrity |
GET | Live integrity-monitor snapshot. Returns {summary: {total, ok, warn, fail, unknown}, checks: [...]} for the ~47 declarative rules in src/data_tracker/integrity_monitor.py (history: 37 mid-May 2026, 34 pre-#269/#291). JSON-snapshot-first (5-min freshness), DB fallback. Phase 4 of Database Integrity Audit (Finding #259). |
/api/architecture-findings |
GET | Renders docs/FINDINGS.md for the dashboard's Testing Findings tab (per memory.md §J — single source of truth). |
Removed (Finding #467.A, Sep 26, 2026): /api/set-trading-mode, /api/testing-prod/confirm, /api/testing-prod/reset (all POST). Each was already a no-op — get_orchestrator() always returns None (the trader runs as its own systemd service; the dashboard is DB/file read-only) — but they were still unauthenticated write endpoints on the public dashboard. Change mode via docs/OPERATIONS.md "Change Trading Mode" (edit trading_mode.txt + restart the trader service).
import requests
resp = requests.get('http://your-server:8080/api/state')
state = resp.json()
print(f"Mode: {state['mode']}")
print(f"Capital: ${state['capital']:,.2f}")
print(f"Active Positions: {state['active_positions']}")
print(f"IBKR Connected: {state['ibkr_connected']}")
resp = requests.get('http://your-server:8080/api/trader-status')
status = resp.json()
print(f"Service: {status['status']}") # 'running' or 'stopped'
print(f"Mode: {status['mode']}")
print(f"Last Activity: {status['last_activity']}")
Before deploying, verify:
outcomePrices is parsed with json.loads()condition_ids (plural) parameterstatus='open' or closed=falsevolume fieldThis document consolidates: POLYMARKET_API_REFERENCE.md, data_tracker_spec.md
Last updated: April 27, 2026 (v3.1 — Gamma volume24hr regression note + 2-pass collector + /api/integrity)