{
  "schema_version": "1.0.0",
  "bot_id": "0.1",
  "bot_name": "MarketScanner",
  "slug": "marketscanner",
  "layer": "Discovery",
  "layer_key": "disc",
  "bot_class": "Signal Service",
  "authority": [
    "Read-only",
    "Recommend"
  ],
  "status": "live",
  "readiness": "General live",
  "flagship": false,
  "is_reference": true,
  "public_export": false,
  "identity": {
    "layer": "Discovery",
    "bot_class": "Signal Service",
    "authority": "Read-only, Recommend",
    "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 \u2014 Intelligence pod"
  },
  "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 \u2014 it never submits, signs, or modifies orders. All output is a recommendation that Strategy may accept or ignore.",
  "why_it_matters": [
    {
      "failure": "Strategy allowed to consider illiquid markets",
      "consequence": "Without a tradability filter, strategies may generate intents on markets too thin to fill without severe price impact, wasting compute and risking poor execution."
    },
    {
      "failure": "Stale market list used",
      "consequence": "A market that has resolved or been paused may still appear in a static list. Acting on it wastes budget and risks failed submissions."
    },
    {
      "failure": "Wide-spread markets not filtered",
      "consequence": "Markets with unusually wide spreads have high transaction costs. Without a spread filter, strategies may target them without accounting for the extra cost."
    },
    {
      "failure": "No volume floor applied",
      "consequence": "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."
    }
  ],
  "polymarket_inputs": [
    {
      "input": "Full list of live markets with condition IDs and metadata",
      "source": "Gamma API",
      "required": true,
      "use": "Enumerate every currently active market to scan on each cycle."
    },
    {
      "input": "CLOB order book depth and last-trade timestamp per market",
      "source": "CLOB",
      "required": true,
      "use": "Compute visible depth and detect markets with no recent trading activity."
    },
    {
      "input": "Bid-ask spread per market",
      "source": "WebSocket",
      "required": true,
      "use": "Filter out markets whose current spread exceeds max_spread_bps."
    },
    {
      "input": "24-hour trading volume per market",
      "source": "Data API",
      "required": true,
      "use": "Apply the min_volume_24h_usd filter to exclude low-activity markets."
    },
    {
      "input": "Market resolution rules text and neg-risk flag",
      "source": "Gamma API",
      "required": false,
      "use": "Pass resolution metadata to Strategy so it can assess resolution risk without re-fetching."
    }
  ],
  "internal_inputs": [
    {
      "input": "KillSwitch active flag",
      "source": "KillSwitch",
      "required": true,
      "use": "Suppress all OrderIntent candidate emissions when KillSwitch is active; continue scanning passively."
    },
    {
      "input": "Strategy interest list",
      "source": "StrategyRegistry",
      "required": false,
      "use": "Prioritise markets that registered strategies have expressed interest in on each scan cycle."
    }
  ],
  "raw_params": [
    "scan_interval_s \u00b7 int",
    "min_volume_24h_usd \u00b7 int",
    "min_book_depth_usd \u00b7 int",
    "max_spread_bps \u00b7 int"
  ],
  "parameters": [
    {
      "name": "scan_interval_s",
      "default": 30,
      "warning": 10,
      "hard": 5,
      "controls": "How often in seconds the full market list is re-scanned. Shorter intervals provide fresher signals but increase API call volume.",
      "why_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": "scan_interval_s \u2265 30",
          "action": "Normal scan cadence"
        },
        {
          "condition": "10\u201330 s",
          "action": "WARN \u2014 increased API load, monitor rate limits"
        },
        {
          "condition": "< 5 s",
          "action": "Reject config change \u2014 PARAMETER_CHANGE_REQUIRES_APPROVAL"
        }
      ],
      "dev_check": "if (p.scan_interval_s < p.hard) throw new ConfigError('PARAMETER_CHANGE_REQUIRES_APPROVAL');",
      "user_facing": "Markets are checked on a regular schedule to keep the list of opportunities up to date without overloading the data feeds."
    },
    {
      "name": "min_volume_24h_usd",
      "default": 1000,
      "warning": 500,
      "hard": 100,
      "controls": "Minimum 24-hour trading volume in USD a market must have to be considered tradable.",
      "why_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": "volume_24h \u2265 1000 USD",
          "action": "Include in candidate list"
        },
        {
          "condition": "500\u20131000 USD",
          "action": "Include with low-confidence flag"
        },
        {
          "condition": "< 100 USD",
          "action": "Exclude \u2014 volume below minimum floor"
        }
      ],
      "dev_check": "if (market.volume24h < p.hard) return exclude('INSUFFICIENT_VISIBLE_DEPTH');",
      "user_facing": "Markets with very little recent trading activity are not shown as opportunities because they may be difficult to trade in and out of."
    },
    {
      "name": "min_book_depth_usd",
      "default": 500,
      "warning": 250,
      "hard": 100,
      "controls": "Minimum total USD depth across the top 50 book levels required for a market to pass the scan filter.",
      "why_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": "book_depth \u2265 500 USD",
          "action": "Include in candidate list"
        },
        {
          "condition": "100\u2013500 USD",
          "action": "Include with thin-book flag"
        },
        {
          "condition": "< 100 USD",
          "action": "Exclude \u2014 INSUFFICIENT_VISIBLE_DEPTH"
        }
      ],
      "dev_check": "if (market.bookDepthUsd < p.hard) return exclude('INSUFFICIENT_VISIBLE_DEPTH');",
      "user_facing": "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."
    },
    {
      "name": "max_spread_bps",
      "default": 400,
      "warning": 300,
      "hard": 800,
      "controls": "Maximum allowed bid-ask spread in basis points (100 bps = 1 percentage point) for a market to be considered tradable.",
      "why_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": "spread \u2264 300 bps",
          "action": "Include in candidate list"
        },
        {
          "condition": "300\u2013400 bps",
          "action": "Include with wide-spread flag"
        },
        {
          "condition": "400\u2013800 bps",
          "action": "Include with warning annotation"
        },
        {
          "condition": "> 800 bps",
          "action": "Exclude \u2014 SPREAD_TOO_WIDE"
        }
      ],
      "dev_check": "if (market.spreadBps > p.hard) return exclude('SPREAD_TOO_WIDE');",
      "user_facing": "Markets where the gap between the buy and sell price is unusually large are not surfaced, because they would cost too much to trade."
    }
  ],
  "default_config": {
    "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
      }
    }
  },
  "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."
  ],
  "decision_logic": {
    "approve": "Not applicable \u2014 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 \u2014 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."
  },
  "decision_output_schema": "OrderIntent",
  "decision_output_example": {
    "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"
  },
  "developer_log": {
    "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"
  },
  "user_explanations": [
    {
      "situation": "Fewer opportunities shown than expected",
      "message": "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."
    },
    {
      "situation": "Market not appearing in opportunities",
      "message": "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."
    },
    {
      "situation": "No opportunities during KillSwitch",
      "message": "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."
    },
    {
      "situation": "Market flagged with thin-book warning",
      "message": "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."
    }
  ],
  "failure_modes": {
    "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 \u2014 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"
    ]
  },
  "acceptance_tests": {
    "unit": [
      {
        "test": "Exclude market when volume_24h below hard floor",
        "setup": "volume_24h_usd=80, min_volume_24h_usd hard=100",
        "expected": "Market excluded with INSUFFICIENT_VISIBLE_DEPTH"
      },
      {
        "test": "Exclude market when book_depth below hard floor",
        "setup": "book_depth_usd=90, min_book_depth_usd hard=100",
        "expected": "Market excluded with INSUFFICIENT_VISIBLE_DEPTH"
      },
      {
        "test": "Exclude market when spread above hard ceiling",
        "setup": "spread_bps=900, max_spread_bps hard=800",
        "expected": "Market excluded with SPREAD_TOO_WIDE"
      },
      {
        "test": "Include market with warning when spread between warning and hard",
        "setup": "spread_bps=350, warning=300, hard=800",
        "expected": "OrderIntent candidate emitted with warnings=['SPREAD_TOO_WIDE']"
      },
      {
        "test": "Emit candidate for market passing all filters",
        "setup": "volume=5000, depth=1500, spread_bps=180",
        "expected": "OrderIntent candidate emitted with no warnings"
      },
      {
        "test": "Suppress emissions when KillSwitch is active",
        "setup": "killswitch.active=true, all filters pass",
        "expected": "Scan runs, scores computed, but no OrderIntent candidates emitted"
      },
      {
        "test": "Reject config change when scan_interval_s below hard minimum",
        "setup": "scan_interval_s=3, hard=5",
        "expected": "ConfigError PARAMETER_CHANGE_REQUIRES_APPROVAL"
      }
    ],
    "integration": [
      {
        "test": "End-to-end: candidate from MarketScanner reaches Strategy and generates valid OrderIntent",
        "expected": "Strategy receives OrderIntent candidate with tradability_score and metadata; produces strategy-level OrderIntent without re-fetching market list"
      },
      {
        "test": "Gamma API unavailability causes scan to halt and emit STALE_MARKET_DATA log",
        "expected": "No candidates emitted during outage cycle; next cycle resumes normally when API recovers"
      },
      {
        "test": "KillSwitch deactivation resumes emissions on next scan cycle",
        "expected": "Candidates resume emitting on the scan cycle immediately following KillSwitch deactivation"
      }
    ],
    "property": [
      {
        "property": "MarketScanner never submits, signs, or modifies any order",
        "required": "Always true \u2014 output is always an OrderIntent candidate recommendation only"
      },
      {
        "property": "No OrderIntent candidates are emitted when KillSwitch is active",
        "required": "Always true"
      },
      {
        "property": "No candidate is emitted when any required data source is absent or stale",
        "required": "Always true \u2014 missing data halts emissions for the affected cycle"
      }
    ]
  },
  "checklist_overrides": {},
  "legacy_goal": "Continuously scan every live Polymarket market and score it for tradability before any strategy is allowed to consider it.",
  "legacy_pm_signals": [
    "Gamma API market list with condition-id metadata",
    "CLOB book depth, spread, last-trade ts",
    "Neg-risk flag and tick-size per market",
    "Market resolution rules text"
  ],
  "legacy_external_feeds": [],
  "network": [
    "polygon"
  ],
  "api_surface": [
    "gamma_api",
    "data_api",
    "clob_public",
    "ws_market"
  ],
  "reference_implementation": {
    "summary": "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.",
    "language_note": "Pseudocode is language-agnostic. FETCH = read input. EMIT = produce output. Translate to TS/Python/Go/Rust.",
    "pseudocode": "FUNCTION scanCycle():\n  // --- 0. KillSwitch gate ---\n  ks = FETCH internal.killswitch.status\n  killswitchActive = ks.active\n\n  // --- 1. Fetch live market list ---\n  markets = FETCH gamma_api.GET('/markets?active=true&closed=false')\n  IF markets IS NULL:\n    LOG ERROR 'Gamma API unavailable \u2014 halting emission for this cycle'\n    RETURN\n\n  passed = []\n  FOR market IN markets:\n    // --- 2. Fetch per-market data ---\n    vol24h = FETCH data_api.GET('/volume?market=' + market.condition_id)\n    book   = fetchClobPublic('/book?market=' + market.condition_id)\n    spread = ws_market.current_spread(market.condition_id)\n\n    IF vol24h IS NULL OR book IS NULL OR spread IS NULL:\n      CONTINUE  // skip \u2014 missing data; do not emit stale candidate\n\n    // --- 3. Apply hard-floor filters ---\n    depthUsd = SUM(level.size * level.price FOR level IN book.asks[:50] + book.bids[:50])\n    spreadBps = spread.current_bps\n\n    IF vol24h.usd < params.min_volume_24h_usd.hard:\n      CONTINUE; reason=INSUFFICIENT_VISIBLE_DEPTH\n    IF depthUsd < params.min_book_depth_usd.hard:\n      CONTINUE; reason=INSUFFICIENT_VISIBLE_DEPTH\n    IF spreadBps > params.max_spread_bps.hard:\n      CONTINUE; reason=SPREAD_TOO_WIDE\n\n    // --- 4. Compute warnings ---\n    warnings = []\n    IF vol24h.usd < params.min_volume_24h_usd.default:\n      warnings.append('MARKET_SCANNER_LOW_VOLUME')\n    IF depthUsd < params.min_book_depth_usd.default:\n      warnings.append('MARKET_SCANNER_THIN_BOOK')\n    IF spreadBps > params.max_spread_bps.warning:\n      warnings.append('SPREAD_TOO_WIDE')\n\n    // --- 5. Neg-risk qualification ---\n    negRisk = market.neg_risk OR market.enable_neg_risk\n    IF negRisk:\n      // NegRisk markets: standard or augmented (open set)\n      // Apply stricter depth check: require 2\u00d7 min_book_depth_usd\n      IF depthUsd < params.min_book_depth_usd.default * 2:\n        warnings.append('MARKET_SCANNER_NEGRISK_THIN_BOOK')\n\n    // --- 6. Resolution metadata ---\n    resMeta = FETCH gamma_api.GET('/market/' + market.condition_id)\n    resSource = resMeta.resolution_source  // e.g. 'UMA'\n\n    // --- 7. Tradability score ---\n    score = 0.4 * min(vol24h.usd / 10000, 1.0)\n           + 0.4 * min(depthUsd / 5000, 1.0)\n           + 0.2 * max(0, 1 - spreadBps / params.max_spread_bps.default)\n\n    passed.append({\n      market_id: market.condition_id,\n      tradability_score: score,\n      volume_24h_usd: vol24h.usd,\n      book_depth_usd: depthUsd,\n      spread_bps: spreadBps,\n      neg_risk: negRisk,\n      resolution_source: resSource,\n      warnings: warnings,\n      scanned_at: now_iso()\n    })\n\n  // --- 8. Emit candidates (suppressed if KillSwitch active) ---\n  IF NOT killswitchActive:\n    FOR candidate IN passed:\n      EMIT OrderIntentCandidate(candidate)\n\n  // --- 9. Log cycle stats ---\n  LOG INFO { markets_scanned: len(markets), passed: len(passed),\n             excluded: len(markets)-len(passed), killswitch: killswitchActive }\n",
    "helpers": [
      {
        "name": "fetchClobPublic",
        "signature": "fetchClobPublic(path: str) -> JSON",
        "purpose": "Unauthenticated GET against https://clob.polymarket.com; returns parsed JSON or null on error."
      },
      {
        "name": "isStale",
        "signature": "isStale(snapshot: any, maxAgeS: int) -> bool",
        "purpose": "Returns true if snapshot was fetched more than maxAgeS seconds ago."
      },
      {
        "name": "platformFee",
        "signature": "platformFee(notional: float, prob: float, feeRate: float) -> float",
        "purpose": "Estimates platform fee C*feeRate*p*(1-p); used to annotate expected transaction cost on each candidate."
      },
      {
        "name": "toUsdcUnits",
        "signature": "toUsdcUnits(rawUsd: float) -> int",
        "purpose": "Not called directly; imported for pUSD precision consistency."
      }
    ],
    "sdk_calls": [
      "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"
  },
  "wire_examples": {
    "input": [
      {
        "label": "Gamma API market list entry",
        "source": "gamma_api",
        "payload": {
          "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": [
      {
        "label": "OrderIntent candidate \u2014 market passing all filters",
        "payload": {
          "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"
        }
      },
      {
        "label": "OrderIntent candidate \u2014 neg-risk market with thin-book warning",
        "payload": {
          "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"
        }
      }
    ],
    "curl": "curl 'https://gamma-api.polymarket.com/markets?active=true&closed=false&limit=100'"
  },
  "reason_codes": [
    {
      "code": "INSUFFICIENT_VISIBLE_DEPTH",
      "severity": "EXPLAIN",
      "meaning": "Market excluded because 24h volume or book depth is below the hard floor.",
      "action": "Exclude market from candidate list; log exclusion_breakdown.",
      "user_message": "Markets with very low trading volume or thin order books are not surfaced as opportunities."
    },
    {
      "code": "SPREAD_TOO_WIDE",
      "severity": "EXPLAIN",
      "meaning": "Market excluded because bid-ask spread exceeds the hard ceiling.",
      "action": "Exclude market from candidate list; log exclusion reason.",
      "user_message": "Markets where the gap between the buy and sell price is unusually large are not surfaced."
    },
    {
      "code": "KILL_SWITCH_ACTIVE",
      "severity": "HARD_REJECT",
      "meaning": "KillSwitch is active; candidate emissions are suppressed.",
      "action": "Complete the scan and score all markets but do not emit any OrderIntent candidates.",
      "user_message": "No market opportunities are being surfaced right now because trading has been paused system-wide."
    },
    {
      "code": "STALE_MARKET_DATA",
      "severity": "HARD_REJECT",
      "meaning": "Gamma API or Data API unavailable; this scan cycle is halted.",
      "action": "Do not emit any candidates for this cycle; log the error.",
      "user_message": ""
    },
    {
      "code": "PARAMETER_CHANGE_REQUIRES_APPROVAL",
      "severity": "HARD_REJECT",
      "meaning": "scan_interval_s is below the locked hard minimum of 5s.",
      "action": "Reject the config change; do not apply.",
      "user_message": ""
    },
    {
      "code": "MARKET_SCANNER_LOW_VOLUME",
      "severity": "WARN",
      "meaning": "Market volume is between the warning and hard floor thresholds.",
      "action": "Include candidate with LOW_VOLUME warning flag; Strategy decides whether to proceed.",
      "user_message": ""
    },
    {
      "code": "MARKET_SCANNER_THIN_BOOK",
      "severity": "WARN",
      "meaning": "Market book depth is between the warning and hard floor thresholds.",
      "action": "Include candidate with THIN_BOOK warning flag.",
      "user_message": "This market passed the minimum filters but has lower depth than usual. Additional size restrictions will apply downstream."
    },
    {
      "code": "MARKET_SCANNER_NEGRISK_THIN_BOOK",
      "severity": "WARN",
      "meaning": "NegRisk market depth is below 2\u00d7 min_book_depth_usd, indicating elevated definition-shift risk.",
      "action": "Include candidate with NEGRISK_THIN_BOOK warning flag.",
      "user_message": ""
    },
    {
      "code": "NEGRISK_CONVERT_AVAILABLE",
      "severity": "EXPLAIN",
      "meaning": "NegRisk market has enable_neg_risk=true; convert-arb may be available via NegRiskAdapter.",
      "action": "Annotate candidate with negrisk_convert_available=true.",
      "user_message": ""
    }
  ],
  "metrics": {
    "emitted": [
      {
        "name": "polytraders_disc_marketscanner_markets_scanned_total",
        "type": "counter",
        "unit": "count",
        "labels": [
          "scan_cycle"
        ],
        "meaning": "Total markets evaluated per scan cycle."
      },
      {
        "name": "polytraders_disc_marketscanner_candidates_emitted_total",
        "type": "counter",
        "unit": "count",
        "labels": [],
        "meaning": "Total OrderIntent candidates emitted to the Strategy layer."
      },
      {
        "name": "polytraders_disc_marketscanner_exclusions_total",
        "type": "counter",
        "unit": "count",
        "labels": [
          "reason_code"
        ],
        "meaning": "Markets excluded per cycle broken down by filter reason."
      },
      {
        "name": "polytraders_disc_marketscanner_tradability_score",
        "type": "histogram",
        "unit": "ratio",
        "labels": [],
        "meaning": "Distribution of tradability scores for passing markets."
      },
      {
        "name": "polytraders_disc_marketscanner_scan_latency_ms",
        "type": "histogram",
        "unit": "seconds",
        "labels": [],
        "meaning": "Wall-clock latency of a full scan cycle."
      },
      {
        "name": "polytraders_disc_marketscanner_negrisk_markets_total",
        "type": "gauge",
        "unit": "count",
        "labels": [],
        "meaning": "Number of active neg-risk markets found in the current scan."
      }
    ],
    "alerts": [
      {
        "name": "MarketScannerGammaAPIDown",
        "condition": "rate(polytraders_disc_marketscanner_markets_scanned_total[5m]) == 0",
        "severity": "P1",
        "runbook": "#runbook-marketscanner-gamma-api"
      },
      {
        "name": "MarketScannerHighLatency",
        "condition": "histogram_quantile(0.99, rate(polytraders_disc_marketscanner_scan_latency_ms_bucket[5m])) > 5000",
        "severity": "P2",
        "runbook": "#runbook-marketscanner-latency"
      },
      {
        "name": "MarketScannerZeroCandidates",
        "condition": "rate(polytraders_disc_marketscanner_candidates_emitted_total[10m]) == 0 AND polytraders_risk_killswitch_active == 0",
        "severity": "P1",
        "runbook": "#runbook-marketscanner-zero-candidates"
      },
      {
        "name": "MarketScannerAllExcluded",
        "condition": "polytraders_disc_marketscanner_candidates_emitted_total / polytraders_disc_marketscanner_markets_scanned_total < 0.01",
        "severity": "P2",
        "runbook": "#runbook-marketscanner-all-excluded"
      }
    ],
    "dashboards": [
      "Grafana \u2014 Discovery / MarketScanner cycle health",
      "Grafana \u2014 Market quality / tradability score distribution"
    ],
    "log_levels": {
      "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."
    }
  },
  "state": {
    "summary": "Stateless between scan cycles except for a short-lived in-memory market score cache.",
    "stores": [
      {
        "name": "market_score_cache",
        "kind": "in-memory",
        "key": "condition_id",
        "value": "{ tradability_score: float, warnings: str[], scanned_at: iso_ts }",
        "ttl": "scan_interval_s",
        "durability": "best-effort"
      }
    ],
    "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."
  },
  "concurrency": {
    "execution_model": "single-threaded event loop",
    "max_in_flight": 1,
    "idempotency_key": "scan_cycle_id",
    "replay_safe": true,
    "deduplication": "by scan_cycle_id \u2014 only one scan runs at a time",
    "ordering_guarantees": "no ordering \u2014 candidates are emitted in score order",
    "timeout_ms": 5000,
    "backpressure": "drop newest",
    "locking": "none"
  },
  "dependencies": {
    "depends_on": [
      {
        "bot_id": "risk.kill_switch",
        "why": "KillSwitch gate determines whether candidates are emitted.",
        "contract": "If KillSwitch active, scan continues but all emissions are suppressed."
      }
    ],
    "emits_to": [
      {
        "bot_id": "risk.liquidity_guard",
        "why": "Candidates downstream trigger LiquidityGuard checks on each OrderIntent.",
        "contract": "Candidate includes book_depth_usd for pre-qualification; LiquidityGuard re-checks at intent time."
      },
      {
        "bot_id": "risk.oracle_risk_monitor",
        "why": "Candidate includes resolution_source metadata for OracleRiskMonitor pre-qualification.",
        "contract": "Strategy passes resolution_source from candidate to OracleRiskMonitor context."
      }
    ],
    "sibling": [],
    "external": [
      {
        "service": "Gamma API",
        "endpoint": "https://gamma-api.polymarket.com",
        "sla": "99.9% / 500ms p99",
        "failure_mode": "Halt emission for this scan cycle; retry on next cycle."
      },
      {
        "service": "Data API (volume)",
        "endpoint": "https://data-api.polymarket.com",
        "sla": "99.9% / 500ms p99",
        "failure_mode": "Skip affected markets; do not emit candidates with missing volume data."
      },
      {
        "service": "CLOB API (read)",
        "endpoint": "https://clob.polymarket.com",
        "sla": "99.95% / 200ms p99",
        "failure_mode": "Skip affected markets; do not emit candidates with missing book data."
      },
      {
        "service": "WS market feed",
        "endpoint": "wss://ws-subscriptions-clob.polymarket.com/ws/market",
        "sla": "best-effort",
        "failure_mode": "Falls back to REST spread poll."
      }
    ]
  },
  "security_surfaces": {
    "summary": "MarketScanner is strictly read-only. It never signs, submits, or modifies orders.",
    "signing": "This bot does NOT sign anything.",
    "secrets": [],
    "contract_calls": [],
    "abuse_vectors": [
      "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 \u2014 downstream guardrails independently re-validate every market"
    ]
  },
  "polymarket_v2_compat": {
    "clob_version": "v2",
    "collateral": "pUSD",
    "eip712_domain_version": "2",
    "builder_code_aware": false,
    "negrisk_aware": true,
    "multichain_ready": false,
    "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."
  },
  "version": {
    "spec": "2.0.0",
    "implementation": "2.1.3",
    "schema": "2",
    "released": "2026-04-28"
  },
  "migration_history": [
    {
      "date": "2026-04-28",
      "from": "v1 (USDC.e + HMAC builder)",
      "to": "v2 (pUSD + builderCode field)",
      "reason": "Polymarket V2 cutover",
      "action_taken": "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."
    }
  ],
  "failure_injection": [
    {
      "scenario": "GAMMA_API_DOWN",
      "how_to_inject": "Block TCP to gamma-api.polymarket.com",
      "expected_behavior": "No candidates emitted for this cycle; ERROR logged; next cycle resumes when API recovers",
      "recovery": "Automatic on next scan cycle after Gamma API is reachable."
    },
    {
      "scenario": "ALL_MARKETS_EXCLUDED",
      "how_to_inject": "Set all mock markets to volume_24h=0",
      "expected_behavior": "Zero candidates emitted; MarketScannerZeroCandidates alert fires",
      "recovery": "Automatic when markets have non-zero volume."
    },
    {
      "scenario": "KILL_SWITCH_ON",
      "how_to_inject": "Set killswitch.active=true",
      "expected_behavior": "Scan runs and scores are computed but no candidates are emitted",
      "recovery": "Candidates resume emitting on the first scan cycle after KillSwitch reset."
    },
    {
      "scenario": "STALE_SPREAD_DATA",
      "how_to_inject": "Disconnect WS market feed for 120s",
      "expected_behavior": "Bot falls back to REST spread poll; if REST also fails, markets with missing spread are excluded",
      "recovery": "Automatic when WS reconnects."
    },
    {
      "scenario": "NEGRISK_THIN_BOOK",
      "how_to_inject": "Set a neg-risk market's book depth to 300 pUSD (below 2\u00d7 min_book_depth_usd=500)",
      "expected_behavior": "Candidate emitted with MARKET_SCANNER_NEGRISK_THIN_BOOK warning",
      "recovery": "Warning clears when depth exceeds threshold."
    }
  ],
  "runbook": {
    "summary": "MarketScanner incidents are usually Gamma API or Data API outages. The bot is read-only so incidents do not affect active positions \u2014 only new opportunity discovery.",
    "oncall_actions": [
      {
        "alert": "MarketScannerGammaAPIDown",
        "first_step": "Check https://gamma-api.polymarket.com status.",
        "diagnosis": "If Gamma API is down, no new market candidates will be discovered. Active positions and guardrails are unaffected.",
        "mitigation": "No immediate action required on active positions. Notify Polymarket support if outage is sustained.",
        "escalation": "Intelligence pod lead after 15 minutes."
      },
      {
        "alert": "MarketScannerZeroCandidates",
        "first_step": "Confirm KillSwitch is not active. Then check exclusion_breakdown metrics.",
        "diagnosis": "If all markets are being excluded, check whether filter thresholds are unusually tight or if market data APIs are returning abnormal values.",
        "mitigation": "Do not lower filter thresholds without Risk pod review.",
        "escalation": "Intelligence pod lead after 10 minutes."
      },
      {
        "alert": "MarketScannerHighLatency",
        "first_step": "Check scan_latency_ms p99. Identify which API calls are slow.",
        "diagnosis": "If CLOB is slow, REST book fetches are taking > 500ms per market. Consider reducing scan scope to priority markets.",
        "mitigation": "Increase scan_interval_s temporarily if needed.",
        "escalation": "Infra on-call if a specific API is > 2s p99 sustained."
      }
    ],
    "manual_overrides": [
      {
        "command": "polytraders bot pause disc.market_scanner",
        "effect": "Stops scan cycles; no new candidates are emitted. Active positions and guardrails are unaffected."
      },
      {
        "command": "polytraders bot set-param disc.market_scanner --scan-interval 60",
        "effect": "Temporarily increases scan interval to reduce API load during a degraded period."
      }
    ],
    "healthcheck": "GET /health \u2192 200 if last scan cycle completed within 2\u00d7 scan_interval_s and at least one candidate was emitted."
  },
  "promotion_gates": {
    "to_shadow": [
      {
        "gate": "Unit tests pass for all four filter cases and neg-risk qualification",
        "how_measured": "CI test run",
        "threshold": "100% pass"
      },
      {
        "gate": "Gamma API integration test: market list fetch and condition_id validation",
        "how_measured": "Integration test",
        "threshold": "Pass"
      }
    ],
    "to_limited_live": [
      {
        "gate": "Scan cycle latency p99 < 5s over 48h",
        "how_measured": "polytraders_disc_marketscanner_scan_latency_ms histogram",
        "threshold": "p99 < 5s"
      },
      {
        "gate": "NegRisk market qualification produces correct negrisk_aware annotations",
        "how_measured": "Integration test with known neg-risk markets",
        "threshold": "Pass"
      }
    ],
    "to_general_live": [
      {
        "gate": "Zero false-positive STALE_MARKET_DATA halts during normal operation over 7 days",
        "how_measured": "Grafana MarketScannerGammaAPIDown alert history",
        "threshold": "0 firings"
      },
      {
        "gate": "KillSwitch suppression: scan runs but zero candidates emitted when KillSwitch active",
        "how_measured": "Integration test",
        "threshold": "Pass"
      }
    ]
  },
  "reporting_groups": [
    "pretrade_intel"
  ],
  "capital_impact": "Indirect",
  "v3_status": {
    "phase": 2,
    "phase_name": "Data normalisation",
    "docs": {
      "done": 27,
      "total": 27,
      "state": "done"
    },
    "impl": {
      "done": 0,
      "total": 15,
      "state": "pending"
    },
    "runtime": {
      "done": 0,
      "total": 8,
      "state": "pending"
    },
    "overall": "pending"
  }
}