0.1 MarketScanner
MarketScanner continuously scans every live Polymarket market on each scan cycle and scores each one for tradability based on volume, book depth, spread, and resolution metadata. Markets that pass all filters emit an OrderIntent candidate to the Strategy layer for further evaluation. MarketScanner is strictly read-only — it never submits, signs, or modifies orders. All output is a recommendation that Strategy may accept or ignore.
v3 readiness
A bot is done when all four scores are. What does done mean?
1. Bot Identity
| Layer | Discovery Discovery |
|---|---|
| Bot class | Signal Service |
| Authority | Read-onlyRecommend |
| Status | LIVE |
| Readiness | General live |
| Runs before | Strategy OrderIntent generation |
| Runs after | Market data ingestion and book refresh |
| Applies to | All live Polymarket markets on every scan cycle |
| Default mode | general_live |
| User-visible | Advanced details only |
| Developer owner | Polytraders core — Intelligence pod |
2. Purpose
MarketScanner continuously scans every live Polymarket market on each scan cycle and scores each one for tradability based on volume, book depth, spread, and resolution metadata. Markets that pass all filters emit an OrderIntent candidate to the Strategy layer for further evaluation. MarketScanner is strictly read-only — it never submits, signs, or modifies orders. All output is a recommendation that Strategy may accept or ignore.
3. Why This Bot Matters
Strategy allowed to consider illiquid markets
Without a tradability filter, strategies may generate intents on markets too thin to fill without severe price impact, wasting compute and risking poor execution.
Stale market list used
A market that has resolved or been paused may still appear in a static list. Acting on it wastes budget and risks failed submissions.
Wide-spread markets not filtered
Markets with unusually wide spreads have high transaction costs. Without a spread filter, strategies may target them without accounting for the extra cost.
No volume floor applied
A market with near-zero 24-hour volume is unlikely to fill at a reasonable price. Surfacing it to Strategy without a volume check leads to wasted resources.
No worked examples on this bot yet. Worked examples are optional but strongly recommended — they turn an abstract failure mode into something a developer can verify in a fixture.
4. Required Polymarket Inputs
| Input | Source | Required? | Use |
|---|---|---|---|
| Full list of live markets with condition IDs and metadata | Gamma API | Yes | Enumerate every currently active market to scan on each cycle. |
| CLOB order book depth and last-trade timestamp per market | CLOB | Yes | Compute visible depth and detect markets with no recent trading activity. |
| Bid-ask spread per market | WebSocket | Yes | Filter out markets whose current spread exceeds max_spread_bps. |
| 24-hour trading volume per market | Data API | Yes | Apply the min_volume_24h_usd filter to exclude low-activity markets. |
| Market resolution rules text and neg-risk flag | Gamma API | No | Pass resolution metadata to Strategy so it can assess resolution risk without re-fetching. |
5. Required Internal Inputs
| Input | Source | Required? | Use |
|---|---|---|---|
| KillSwitch active flag | KillSwitch | Yes | Suppress all OrderIntent candidate emissions when KillSwitch is active; continue scanning passively. |
| Strategy interest list | StrategyRegistry | No | Prioritise markets that registered strategies have expressed interest in on each scan cycle. |
6. Parameter Guide
| Parameter | Default | Warning | Hard | What it controls |
|---|---|---|---|---|
| scan_interval_s | 30 | 10 | 5 | How often in seconds the full market list is re-scanned. Shorter intervals provide fresher signals but increase API call volume. |
| min_volume_24h_usd | 1000 | 500 | 100 | Minimum 24-hour trading volume in USD a market must have to be considered tradable. |
| min_book_depth_usd | 500 | 250 | 100 | Minimum total USD depth across the top 50 book levels required for a market to pass the scan filter. |
| max_spread_bps | 400 | 300 | 800 | Maximum allowed bid-ask spread in basis points (100 bps = 1 percentage point) for a market to be considered tradable. |
7. Detailed Parameter Instructions
scan_interval_s
What it means
How often in seconds the full market list is re-scanned. Shorter intervals provide fresher signals but increase API call volume.
Default
{ "scan_interval_s": 30 }
Why this default matters
A 30-second interval keeps market scores reasonably fresh without hammering the Gamma API or CLOB with excessive polling. Below 10 seconds, rate-limiting becomes a concern.
Threshold logic
| Condition | Action |
|---|---|
| scan_interval_s ≥ 30 | Normal scan cadence |
| 10–30 s | WARN — increased API load, monitor rate limits |
| < 5 s | Reject config change — PARAMETER_CHANGE_REQUIRES_APPROVAL |
Developer check
if (p.scan_interval_s < p.hard) throw new ConfigError('PARAMETER_CHANGE_REQUIRES_APPROVAL');
User-facing English
Markets are checked on a regular schedule to keep the list of opportunities up to date without overloading the data feeds.
min_volume_24h_usd
What it means
Minimum 24-hour trading volume in USD a market must have to be considered tradable.
Default
{ "min_volume_24h_usd": 1000 }
Why this default matters
A market with less than $1000 in daily volume is unlikely to have sufficient liquidity to fill any meaningful order without significant price impact.
Threshold logic
| Condition | Action |
|---|---|
| volume_24h ≥ 1000 USD | Include in candidate list |
| 500–1000 USD | Include with low-confidence flag |
| < 100 USD | Exclude — volume below minimum floor |
Developer check
if (market.volume24h < p.hard) return exclude('INSUFFICIENT_VISIBLE_DEPTH');
User-facing English
Markets with very little recent trading activity are not shown as opportunities because they may be difficult to trade in and out of.
min_book_depth_usd
What it means
Minimum total USD depth across the top 50 book levels required for a market to pass the scan filter.
Default
{ "min_book_depth_usd": 500 }
Why this default matters
A market with less than $500 in total visible depth has very thin resting liquidity; even modest-sized orders would consume most of it.
Threshold logic
| Condition | Action |
|---|---|
| book_depth ≥ 500 USD | Include in candidate list |
| 100–500 USD | Include with thin-book flag |
| < 100 USD | Exclude — INSUFFICIENT_VISIBLE_DEPTH |
Developer check
if (market.bookDepthUsd < p.hard) return exclude('INSUFFICIENT_VISIBLE_DEPTH');
User-facing English
Markets where there is very little money resting at the buy and sell prices are excluded because they would be hard to trade without moving the price substantially.
max_spread_bps
What it means
Maximum allowed bid-ask spread in basis points (100 bps = 1 percentage point) for a market to be considered tradable.
Default
{ "max_spread_bps": 400 }
Why this default matters
A spread above 400 bps (4 percentage points) means the cost of entering and immediately exiting a position is more than 4%, which overwhelms most expected-value edges.
Threshold logic
| Condition | Action |
|---|---|
| spread ≤ 300 bps | Include in candidate list |
| 300–400 bps | Include with wide-spread flag |
| 400–800 bps | Include with warning annotation |
| > 800 bps | Exclude — SPREAD_TOO_WIDE |
Developer check
if (market.spreadBps > p.hard) return exclude('SPREAD_TOO_WIDE');
User-facing English
Markets where the gap between the buy and sell price is unusually large are not surfaced, because they would cost too much to trade.
8. Default Configuration
{
"bot_id": "intel.market_scanner",
"version": "1.0.0",
"mode": "general_live",
"defaults": {
"scan_interval_s": 30,
"min_volume_24h_usd": 1000,
"min_book_depth_usd": 500,
"max_spread_bps": 400
},
"locked": {
"scan_interval_s": {
"min": 5
},
"min_volume_24h_usd": {
"min": 100
},
"min_book_depth_usd": {
"min": 100
}
}
}9. Implementation Flow
- On each scan cycle (every scan_interval_s seconds), fetch the full list of live markets from Gamma API.
- Check KillSwitch active flag; if active, complete the scan and update market scores but suppress all OrderIntent candidate emissions.
- For each market in the list, fetch top-50 CLOB book depth and compute total visible_depth_usd.
- For each market, fetch the current bid-ask spread from WebSocket and compute spread_bps.
- For each market, fetch 24-hour trading volume from Data API.
- Apply filters in order: (a) exclude if volume_24h < min_volume_24h_usd hard floor; (b) exclude if book_depth < min_book_depth_usd hard floor; (c) exclude if spread_bps > max_spread_bps hard ceiling.
- For markets that pass all filters, attach resolution metadata (resolution rules text, neg-risk flag) from Gamma API.
- Score each passing market on a composite tradability score based on depth, spread, and volume relative to thresholds.
- Emit an OrderIntent candidate for each passing market to the Strategy layer, including: market_id, tradability score, depth, spread, volume, neg-risk flag, and scan timestamp.
- Log the full scan results (pass/fail counts, filtered-out reason codes, and top-scoring markets) to the developer log.
10. Reference Implementation
On each scan cycle, fetches the full live market list from Gamma API, applies four tradability filters (volume, depth, spread, freshness), attaches neg-risk and resolution metadata, scores each passing market, and emits OrderIntent candidates to the Strategy layer.
Pseudocode is language-agnostic. FETCH = read input. EMIT = produce output. Translate to TS/Python/Go/Rust.
FUNCTION scanCycle():
// --- 0. KillSwitch gate ---
ks = FETCH internal.killswitch.status
killswitchActive = ks.active
// --- 1. Fetch live market list ---
markets = FETCH gamma_api.GET('/markets?active=true&closed=false')
IF markets IS NULL:
LOG ERROR 'Gamma API unavailable — halting emission for this cycle'
RETURN
passed = []
FOR market IN markets:
// --- 2. Fetch per-market data ---
vol24h = FETCH data_api.GET('/volume?market=' + market.condition_id)
book = fetchClobPublic('/book?market=' + market.condition_id)
spread = ws_market.current_spread(market.condition_id)
IF vol24h IS NULL OR book IS NULL OR spread IS NULL:
CONTINUE // skip — missing data; do not emit stale candidate
// --- 3. Apply hard-floor filters ---
depthUsd = SUM(level.size * level.price FOR level IN book.asks[:50] + book.bids[:50])
spreadBps = spread.current_bps
IF vol24h.usd < params.min_volume_24h_usd.hard:
CONTINUE; reason=INSUFFICIENT_VISIBLE_DEPTH
IF depthUsd < params.min_book_depth_usd.hard:
CONTINUE; reason=INSUFFICIENT_VISIBLE_DEPTH
IF spreadBps > params.max_spread_bps.hard:
CONTINUE; reason=SPREAD_TOO_WIDE
// --- 4. Compute warnings ---
warnings = []
IF vol24h.usd < params.min_volume_24h_usd.default:
warnings.append('MARKET_SCANNER_LOW_VOLUME')
IF depthUsd < params.min_book_depth_usd.default:
warnings.append('MARKET_SCANNER_THIN_BOOK')
IF spreadBps > params.max_spread_bps.warning:
warnings.append('SPREAD_TOO_WIDE')
// --- 5. Neg-risk qualification ---
negRisk = market.neg_risk OR market.enable_neg_risk
IF negRisk:
// NegRisk markets: standard or augmented (open set)
// Apply stricter depth check: require 2× min_book_depth_usd
IF depthUsd < params.min_book_depth_usd.default * 2:
warnings.append('MARKET_SCANNER_NEGRISK_THIN_BOOK')
// --- 6. Resolution metadata ---
resMeta = FETCH gamma_api.GET('/market/' + market.condition_id)
resSource = resMeta.resolution_source // e.g. 'UMA'
// --- 7. Tradability score ---
score = 0.4 * min(vol24h.usd / 10000, 1.0)
+ 0.4 * min(depthUsd / 5000, 1.0)
+ 0.2 * max(0, 1 - spreadBps / params.max_spread_bps.default)
passed.append({
market_id: market.condition_id,
tradability_score: score,
volume_24h_usd: vol24h.usd,
book_depth_usd: depthUsd,
spread_bps: spreadBps,
neg_risk: negRisk,
resolution_source: resSource,
warnings: warnings,
scanned_at: now_iso()
})
// --- 8. Emit candidates (suppressed if KillSwitch active) ---
IF NOT killswitchActive:
FOR candidate IN passed:
EMIT OrderIntentCandidate(candidate)
// --- 9. Log cycle stats ---
LOG INFO { markets_scanned: len(markets), passed: len(passed),
excluded: len(markets)-len(passed), killswitch: killswitchActive }
Helpers used
| Helper | Signature | Purpose |
|---|---|---|
| fetchClobPublic | fetchClobPublic(path: str) -> JSON | Unauthenticated GET against https://clob.polymarket.com; returns parsed JSON or null on error. |
| isStale | isStale(snapshot: any, maxAgeS: int) -> bool | Returns true if snapshot was fetched more than maxAgeS seconds ago. |
| platformFee | platformFee(notional: float, prob: float, feeRate: float) -> float | Estimates platform fee C*feeRate*p*(1-p); used to annotate expected transaction cost on each candidate. |
| toUsdcUnits | toUsdcUnits(rawUsd: float) -> int | Not called directly; imported for pUSD precision consistency. |
SDK calls used
fetchClobPublic('/book?market=0xabc123...&depth=50')gamma_api.GET('/markets?active=true&closed=false')gamma_api.GET('/market/0xabc123...')data_api.GET('/volume?market=0xabc123...&window=24h')ws_market.current_spread('0xabc123...')
Complexity: O(M) where M = number of live markets per scan cycle
11. Wire Examples
Input — what arrives on the wire
Gamma API market list entry — gamma_api
{
"condition_id": "0x7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a",
"question": "Will event X happen before 2026-06-01?",
"active": true,
"closed": false,
"neg_risk": false,
"enable_neg_risk": false,
"resolution_source": "UMA Optimistic Oracle",
"minimum_tick_size": 0.01,
"volume_24h": "8540.00",
"spread_bps": 210
}
Output — what the bot emits
OrderIntent candidate — market passing all filters
{
"scanner_id": "disc.market_scanner",
"market_id": "0x7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a",
"condition_id": "0x7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a",
"tradability_score": 0.78,
"volume_24h_usd": 8540,
"book_depth_usd": 3200,
"spread_bps": 210,
"neg_risk": false,
"resolution_source": "UMA Optimistic Oracle",
"warnings": [],
"scanned_at": "2026-05-09T11:30:00Z",
"type": "CANDIDATE"
}
OrderIntent candidate — neg-risk market with thin-book warning
{
"scanner_id": "disc.market_scanner",
"market_id": "0x8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b",
"condition_id": "0x8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b",
"tradability_score": 0.42,
"volume_24h_usd": 2100,
"book_depth_usd": 620,
"spread_bps": 340,
"neg_risk": true,
"resolution_source": "UMA Optimistic Oracle",
"warnings": [
"MARKET_SCANNER_NEGRISK_THIN_BOOK",
"SPREAD_TOO_WIDE"
],
"scanned_at": "2026-05-09T11:30:00Z",
"type": "CANDIDATE"
}
Reproduce locally
curl 'https://gamma-api.polymarket.com/markets?active=true&closed=false&limit=100'12. Decision Logic
APPROVE
Not applicable — MarketScanner does not issue approval votes. It emits OrderIntent candidates for markets that pass all four filters (volume, depth, spread, freshness).
RESHAPE_REQUIRED
Not applicable — MarketScanner does not reshape orders. It is read-only.
REJECT
Markets that fail any filter are excluded from the candidate list with a reason code: INSUFFICIENT_VISIBLE_DEPTH (depth or volume too low) or SPREAD_TOO_WIDE (spread above hard ceiling). KillSwitch active suppresses all emissions without excluding markets from the scan.
WARNING_ONLY
Markets between the warning and hard threshold on any parameter are included in the candidate list with a warning annotation flag set in the OrderIntent. Strategy decides whether to proceed.
13. Standard Decision Output
This bot returns a OrderIntent object. See OrderIntent schema.
{
"scanner_id": "intel.market_scanner",
"market_id": "CLOB:0xabc123",
"condition_id": "0xabc123",
"tradability_score": 0.78,
"volume_24h_usd": 8500,
"book_depth_usd": 3200,
"spread_bps": 210,
"neg_risk": false,
"resolution_source": "UMA Optimistic Oracle",
"warnings": [],
"scanned_at": "2026-05-09T11:30:00Z",
"type": "CANDIDATE"
}14. Reason Codes
| Code | Severity | Meaning | Action | User-facing message |
|---|---|---|---|---|
INSUFFICIENT_VISIBLE_DEPTH | EXPLAIN | Market excluded because 24h volume or book depth is below the hard floor. | Exclude market from candidate list; log exclusion_breakdown. | Markets with very low trading volume or thin order books are not surfaced as opportunities. |
SPREAD_TOO_WIDE | EXPLAIN | Market excluded because bid-ask spread exceeds the hard ceiling. | Exclude market from candidate list; log exclusion reason. | Markets where the gap between the buy and sell price is unusually large are not surfaced. |
KILL_SWITCH_ACTIVE | HARD_REJECT | KillSwitch is active; candidate emissions are suppressed. | Complete the scan and score all markets but do not emit any OrderIntent candidates. | No market opportunities are being surfaced right now because trading has been paused system-wide. |
STALE_MARKET_DATA | HARD_REJECT | Gamma API or Data API unavailable; this scan cycle is halted. | Do not emit any candidates for this cycle; log the error. | |
PARAMETER_CHANGE_REQUIRES_APPROVAL | HARD_REJECT | scan_interval_s is below the locked hard minimum of 5s. | Reject the config change; do not apply. | |
MARKET_SCANNER_LOW_VOLUME | WARN | Market volume is between the warning and hard floor thresholds. | Include candidate with LOW_VOLUME warning flag; Strategy decides whether to proceed. | |
MARKET_SCANNER_THIN_BOOK | WARN | Market book depth is between the warning and hard floor thresholds. | Include candidate with THIN_BOOK warning flag. | This market passed the minimum filters but has lower depth than usual. Additional size restrictions will apply downstream. |
MARKET_SCANNER_NEGRISK_THIN_BOOK | WARN | NegRisk market depth is below 2× min_book_depth_usd, indicating elevated definition-shift risk. | Include candidate with NEGRISK_THIN_BOOK warning flag. | |
NEGRISK_CONVERT_AVAILABLE | EXPLAIN | NegRisk market has enable_neg_risk=true; convert-arb may be available via NegRiskAdapter. | Annotate candidate with negrisk_convert_available=true. |
15. Metrics & Logs
Metrics emitted
| Metric | Type | Unit | Labels | Meaning |
|---|---|---|---|---|
polytraders_disc_marketscanner_markets_scanned_total | counter | count | scan_cycle | Total markets evaluated per scan cycle. |
polytraders_disc_marketscanner_candidates_emitted_total | counter | count | Total OrderIntent candidates emitted to the Strategy layer. | |
polytraders_disc_marketscanner_exclusions_total | counter | count | reason_code | Markets excluded per cycle broken down by filter reason. |
polytraders_disc_marketscanner_tradability_score | histogram | ratio | Distribution of tradability scores for passing markets. | |
polytraders_disc_marketscanner_scan_latency_ms | histogram | seconds | Wall-clock latency of a full scan cycle. | |
polytraders_disc_marketscanner_negrisk_markets_total | gauge | count | Number of active neg-risk markets found in the current scan. |
Alerts
| Alert | Condition | Severity | Runbook |
|---|---|---|---|
MarketScannerGammaAPIDown | rate(polytraders_disc_marketscanner_markets_scanned_total[5m]) == 0 | P1 | #runbook-marketscanner-gamma-api |
MarketScannerHighLatency | histogram_quantile(0.99, rate(polytraders_disc_marketscanner_scan_latency_ms_bucket[5m])) > 5000 | P2 | #runbook-marketscanner-latency |
MarketScannerZeroCandidates | rate(polytraders_disc_marketscanner_candidates_emitted_total[10m]) == 0 AND polytraders_risk_killswitch_active == 0 | P1 | #runbook-marketscanner-zero-candidates |
MarketScannerAllExcluded | polytraders_disc_marketscanner_candidates_emitted_total / polytraders_disc_marketscanner_markets_scanned_total < 0.01 | P2 | #runbook-marketscanner-all-excluded |
Dashboards
- Grafana — Discovery / MarketScanner cycle health
- Grafana — Market quality / tradability score distribution
Log levels
| Level | What gets logged |
|---|---|
| DEBUG | Per-market filter result including vol24h, depthUsd, spreadBps, and tradability score. |
| INFO | Cycle summary: markets_scanned, markets_passed, top_tradability_score, killswitch_active. |
| WARN | Gamma API or Data API slow response; market exclusion rate > 90%. |
| ERROR | Gamma API unavailable; Data API unavailable; KillSwitch status unreadable. |
16. Developer Reporting
{
"scanner_id": "intel.market_scanner",
"scan_cycle": 1428,
"markets_scanned": 312,
"markets_passed": 47,
"markets_excluded": 265,
"exclusion_breakdown": {
"INSUFFICIENT_VISIBLE_DEPTH": 189,
"SPREAD_TOO_WIDE": 76
},
"top_market": "CLOB:0xabc123",
"top_tradability_score": 0.78,
"killswitch_active": false,
"scanned_at": "2026-05-09T11:30:00Z"
}17. Plain-English Reporting
| Situation | User-facing explanation |
|---|---|
| Fewer opportunities shown than expected | Many markets were filtered out because they had low trading volume, thin order books, or wide bid-ask spreads. Only markets that meet the minimum quality thresholds are passed to strategies. |
| Market not appearing in opportunities | A specific market may have been excluded because its recent trading volume, available liquidity, or spread did not meet the current filters. These thresholds protect against trading in markets where execution would be poor. |
| No opportunities during KillSwitch | No market opportunities are being surfaced right now because trading has been paused system-wide. The scan continues in the background and will resume emitting candidates once trading is unpaused. |
| Market flagged with thin-book warning | This market passed the minimum filters but has lower depth than usual. Any strategy that uses it will apply additional size restrictions through the liquidity guardrail. |
18. Failure-Mode Block
| main_failure_mode | Emitting a candidate for a market whose data was valid at scan time but which has since dropped below the filters (e.g. a book that empties between the scan and strategy execution), causing a strategy to generate an intent on a now-illiquid market. |
|---|---|
| false_positive_risk | Including a borderline market that sits just above the volume or depth floors, which has a high probability of failing the LiquidityGuard check anyway, wasting the downstream processing. |
| false_negative_risk | Excluding a genuinely tradable market because its depth or volume was temporarily low at scan time — for example immediately after a large fill cleared the book. |
| safe_fallback | If Gamma API or Data API are unavailable, halt candidate emissions for the affected cycle with STALE_MARKET_DATA rather than emitting stale candidates. Never emit on missing data. |
| required_dependencies | Gamma API live market list and metadata, CLOB top-50 book snapshot per market, Data API 24-hour volume per market, WebSocket bid-ask spread per market, KillSwitch active flag |
19. Failure-Injection Recipes
| Scenario | How to inject | Expected behaviour | Recovery |
|---|---|---|---|
GAMMA_API_DOWN | Block TCP to gamma-api.polymarket.com | No candidates emitted for this cycle; ERROR logged; next cycle resumes when API recovers | Automatic on next scan cycle after Gamma API is reachable. |
ALL_MARKETS_EXCLUDED | Set all mock markets to volume_24h=0 | Zero candidates emitted; MarketScannerZeroCandidates alert fires | Automatic when markets have non-zero volume. |
KILL_SWITCH_ON | Set killswitch.active=true | Scan runs and scores are computed but no candidates are emitted | Candidates resume emitting on the first scan cycle after KillSwitch reset. |
STALE_SPREAD_DATA | Disconnect WS market feed for 120s | Bot falls back to REST spread poll; if REST also fails, markets with missing spread are excluded | Automatic when WS reconnects. |
NEGRISK_THIN_BOOK | Set a neg-risk market's book depth to 300 pUSD (below 2× min_book_depth_usd=500) | Candidate emitted with MARKET_SCANNER_NEGRISK_THIN_BOOK warning | Warning clears when depth exceeds threshold. |
20. State & Persistence
Stateless between scan cycles except for a short-lived in-memory market score cache.
State stores
| Name | Kind | Key | Value shape | TTL | Durability |
|---|---|---|---|---|---|
market_score_cache | in-memory | condition_id | { tradability_score: float, warnings: str[], scanned_at: iso_ts } | scan_interval_s | best-effort |
Cold-start recovery
On cold start, the cache is empty. The first scan cycle populates it.
On restart
All scores are re-computed on the first scan cycle after restart. No durable state is loaded.
21. Concurrency & Idempotency
| Aspect | Specification |
|---|---|
| Execution model | single-threaded event loop |
| Max in-flight | 1 |
| Idempotency key | scan_cycle_id |
| Replay-safe | True |
| Deduplication | by scan_cycle_id — only one scan runs at a time |
| Ordering guarantees | no ordering — candidates are emitted in score order |
| Per-call timeout (ms) | 5000 |
| Backpressure strategy | drop newest |
| Locking / mutual exclusion | none |
22. Dependencies
Depends on (must run first)
| Bot | Why | Contract |
|---|---|---|
| risk.kill_switch | KillSwitch gate determines whether candidates are emitted. | If KillSwitch active, scan continues but all emissions are suppressed. |
Emits to (downstream consumers)
| Bot | Why | Contract |
|---|---|---|
| risk.liquidity_guard | Candidates downstream trigger LiquidityGuard checks on each OrderIntent. | Candidate includes book_depth_usd for pre-qualification; LiquidityGuard re-checks at intent time. |
| risk.oracle_risk_monitor | Candidate includes resolution_source metadata for OracleRiskMonitor pre-qualification. | Strategy passes resolution_source from candidate to OracleRiskMonitor context. |
Used by (auto-aggregated)
External services
| Service | Endpoint | SLA assumed | On failure |
|---|---|---|---|
| Gamma API | https://gamma-api.polymarket.com | 99.9% / 500ms p99 | Halt emission for this scan cycle; retry on next cycle. |
| Data API (volume) | https://data-api.polymarket.com | 99.9% / 500ms p99 | Skip affected markets; do not emit candidates with missing volume data. |
| CLOB API (read) | https://clob.polymarket.com | 99.95% / 200ms p99 | Skip affected markets; do not emit candidates with missing book data. |
| WS market feed | wss://ws-subscriptions-clob.polymarket.com/ws/market | best-effort | Falls back to REST spread poll. |
23. Security Surfaces
MarketScanner is strictly read-only. It never signs, submits, or modifies orders.
Signing surface
This bot does NOT sign anything.
Abuse vectors considered
- Gamma API returning malicious market metadata to inject a fraudulent condition_id into the candidate list
Mitigations
- condition_id format validated against known 32-byte hex pattern before inclusion
- All candidates are recommendations only — downstream guardrails independently re-validate every market
24. Polymarket V2 Compatibility
| Aspect | Value |
|---|---|
| CLOB version | v2 |
| Collateral asset | pUSD |
| EIP-712 Exchange domain version | 2 |
| Aware of builderCode field | no |
| Aware of negative-risk markets | yes |
| Multi-chain ready | no |
| SDK used | @polymarket/clob-client-v2 ^2.x |
| Settlement contract | CTFExchangeV2 on Polygon |
| Notes | MarketScanner reads Gamma API's negRisk and enableNegRisk fields to qualify neg-risk markets (standard closed-set and augmented open-set). Volume and depth are denominated in pUSD. Platform fee estimator uses the V2 formula C*feeRate*p*(1-p) for annotation. |
API surfaces declared
Networks supported
25. Versioning & Migration
| Field | Value |
|---|---|
| spec | 2.0.0 |
| implementation | 2.1.3 |
| schema | 2 |
| released | 2026-04-28 |
Migration history
| Date | From | To | Reason | Action taken |
|---|---|---|---|---|
| 2026-04-28 | v1 (USDC.e + HMAC builder) | v2 (pUSD + builderCode field) | Polymarket V2 cutover | Updated Gamma API queries to include enableNegRisk flag. Volume and depth figures now denominated in pUSD. Removed any references to USDC.e balance or HMAC builder code fields in candidate payloads. |
26. Acceptance Tests
Unit Tests
| Test | Setup | Expected result |
|---|---|---|
| Exclude market when volume_24h below hard floor | volume_24h_usd=80, min_volume_24h_usd hard=100 | Market excluded with INSUFFICIENT_VISIBLE_DEPTH |
| Exclude market when book_depth below hard floor | book_depth_usd=90, min_book_depth_usd hard=100 | Market excluded with INSUFFICIENT_VISIBLE_DEPTH |
| Exclude market when spread above hard ceiling | spread_bps=900, max_spread_bps hard=800 | Market excluded with SPREAD_TOO_WIDE |
| Include market with warning when spread between warning and hard | spread_bps=350, warning=300, hard=800 | OrderIntent candidate emitted with warnings=['SPREAD_TOO_WIDE'] |
| Emit candidate for market passing all filters | volume=5000, depth=1500, spread_bps=180 | OrderIntent candidate emitted with no warnings |
| Suppress emissions when KillSwitch is active | killswitch.active=true, all filters pass | Scan runs, scores computed, but no OrderIntent candidates emitted |
| Reject config change when scan_interval_s below hard minimum | scan_interval_s=3, hard=5 | ConfigError PARAMETER_CHANGE_REQUIRES_APPROVAL |
Integration Tests
| Test | Expected result |
|---|---|
| End-to-end: candidate from MarketScanner reaches Strategy and generates valid OrderIntent | Strategy receives OrderIntent candidate with tradability_score and metadata; produces strategy-level OrderIntent without re-fetching market list |
| Gamma API unavailability causes scan to halt and emit STALE_MARKET_DATA log | No candidates emitted during outage cycle; next cycle resumes normally when API recovers |
| KillSwitch deactivation resumes emissions on next scan cycle | Candidates resume emitting on the scan cycle immediately following KillSwitch deactivation |
Property Tests
| Property | Required behaviour |
|---|---|
| MarketScanner never submits, signs, or modifies any order | Always true — output is always an OrderIntent candidate recommendation only |
| No OrderIntent candidates are emitted when KillSwitch is active | Always true |
| No candidate is emitted when any required data source is absent or stale | Always true — missing data halts emissions for the affected cycle |
27. Operational Runbook
MarketScanner incidents are usually Gamma API or Data API outages. The bot is read-only so incidents do not affect active positions — only new opportunity discovery.
On-call actions
| Alert | First step | Diagnosis | Mitigation | Escalate to |
|---|---|---|---|---|
MarketScannerGammaAPIDown | Check https://gamma-api.polymarket.com status. | If Gamma API is down, no new market candidates will be discovered. Active positions and guardrails are unaffected. | No immediate action required on active positions. Notify Polymarket support if outage is sustained. | Intelligence pod lead after 15 minutes. |
MarketScannerZeroCandidates | Confirm KillSwitch is not active. Then check exclusion_breakdown metrics. | If all markets are being excluded, check whether filter thresholds are unusually tight or if market data APIs are returning abnormal values. | Do not lower filter thresholds without Risk pod review. | Intelligence pod lead after 10 minutes. |
MarketScannerHighLatency | Check scan_latency_ms p99. Identify which API calls are slow. | If CLOB is slow, REST book fetches are taking > 500ms per market. Consider reducing scan scope to priority markets. | Increase scan_interval_s temporarily if needed. | Infra on-call if a specific API is > 2s p99 sustained. |
Manual overrides
polytraders bot pause disc.market_scanner— Stops scan cycles; no new candidates are emitted. Active positions and guardrails are unaffected.polytraders bot set-param disc.market_scanner --scan-interval 60— Temporarily increases scan interval to reduce API load during a degraded period.
Healthcheck
GET /health → 200 if last scan cycle completed within 2× scan_interval_s and at least one candidate was emitted.28. Promotion Gates
A bot does not advance to the next readiness state until every gate below is green. Gates are observable from production data — no subjective sign-off.
Promote to Shadow
| Gate | How measured | Threshold |
|---|---|---|
| Unit tests pass for all four filter cases and neg-risk qualification | CI test run | 100% pass |
| Gamma API integration test: market list fetch and condition_id validation | Integration test | Pass |
Promote to Limited live
| Gate | How measured | Threshold |
|---|---|---|
| Scan cycle latency p99 < 5s over 48h | polytraders_disc_marketscanner_scan_latency_ms histogram | p99 < 5s |
| NegRisk market qualification produces correct negrisk_aware annotations | Integration test with known neg-risk markets | Pass |
Promote to General live
| Gate | How measured | Threshold |
|---|---|---|
| Zero false-positive STALE_MARKET_DATA halts during normal operation over 7 days | Grafana MarketScannerGammaAPIDown alert history | 0 firings |
| KillSwitch suppression: scan runs but zero candidates emitted when KillSwitch active | Integration test | Pass |
29. Developer Checklist
Ready-to-ship score: 27/27 sections complete · 100%
| Requirement | Status |
|---|---|
| Purpose defined | ✓ done |
| Required inputs listed | ✓ done |
| Parameters defined | ✓ done |
| Defaults defined | ✓ done |
| Warning thresholds defined | ✓ done |
| Hard thresholds defined | ✓ done |
| Safe fallback defined | ✓ done |
| Structured output defined | ✓ done |
| Developer log defined | ✓ done |
| Plain-English explanation | ✓ done |
| Unit tests defined | ✓ done |
| Integration tests defined | ✓ done |
| Property tests defined | ✓ done |
| Failure-mode block complete | ✓ done |
| Reference implementation pseudocode | ✓ done |
| Wire examples (input + output) | ✓ done |
| Reason codes listed | ✓ done |
| Metrics & logs defined | ✓ done |
| State & persistence defined | ✓ done |
| Concurrency & idempotency defined | ✓ done |
| Dependencies declared | ✓ done |
| Security surfaces declared | ✓ done |
| Polymarket V2 compatibility declared | ✓ done |
| Version & migration history declared | ✓ done |
| Operational runbook defined | ✓ done |
| Promotion gates defined | ✓ done |
| Failure-injection recipes defined | ✓ done |