{
  "schema_version": "2.0.0",
  "bot_id": "4.5",
  "bot_name": "SportsFeed-Adapter",
  "slug": "sportsfeed-adapter",
  "layer": "Intelligence",
  "layer_key": "intel",
  "bot_class": "Signal Service",
  "authority": [
    "Read-only"
  ],
  "status": "beta",
  "readiness": "Limited live",
  "flagship": false,
  "is_reference": false,
  "public_export": false,
  "identity": {
    "layer": "Intelligence",
    "bot_class": "Signal Service",
    "authority": "Read-only",
    "runs_before": "Sports-model strategies, LiquidityGuard",
    "runs_after": "ws_sports subscription established; Gamma API sports market list loaded",
    "applies_to": "All live Polymarket sports markets matched to enabled_sports feed coverage",
    "default_mode": "limited_live",
    "user_visible": "Advanced details only",
    "developer_owner": "Polytraders core \u2014 Intelligence pod"
  },
  "purpose": "SportsFeed-Adapter ingests structured sports data from league APIs (NBA, NFL, EPL, ATP/WTA, MLB) and odds-feed providers, normalises it into a canonical SportsFeedEvent schema, and emits an ObservationReport for each qualifying update (lineup changes, injury reports, score updates, and pre-game odds shifts). It supplements primary API feeds with ws_sports for low-latency in-play state and falls back to web extraction for sports without direct API coverage. Output feeds sports-model strategies with the event data they need to price Polymarket sports markets. SportsFeed-Adapter is strictly read-only \u2014 it never submits or signs orders.",
  "why_it_matters": [
    {
      "failure": "Injury report not ingested before market open",
      "consequence": "Sports-model strategy prices a market without a key player absence; takes a position at a probability that is stale by a significant amount and gets adversely selected."
    },
    {
      "failure": "Score update delayed by > refresh_interval_s",
      "consequence": "In-play market probability estimate diverges from ground truth; strategy holds a stale position through a goal or score change, realising avoidable losses."
    },
    {
      "failure": "Primary league API unavailable and fallback_to_web disabled",
      "consequence": "No sports data flows to the model; strategy falls back to last-known odds and risks trading on stale information for the duration of the outage."
    },
    {
      "failure": "Odds from wrong provider used due to preferred_provider misconfiguration",
      "consequence": "Systematically biased odds feed primes the model with incorrect pre-game probability estimates, degrading pricing accuracy across all sports markets in the affected sport."
    }
  ],
  "polymarket_inputs": [
    {
      "input": "Sports market event metadata (event_id, sport, teams, start_time, condition_id)",
      "source": "Gamma API (ws_sports supplement)",
      "required": true,
      "use": "Map incoming feed events to Polymarket condition_ids for ObservationReport payloads."
    },
    {
      "input": "Real-time in-play state updates (score, game_clock, possession)",
      "source": "ws_sports",
      "required": false,
      "use": "Low-latency supplement to league API polling for in-play markets."
    }
  ],
  "internal_inputs": [
    {
      "input": "KillSwitch active flag",
      "source": "KillSwitch",
      "required": true,
      "use": "Continue ingesting feed data but suppress ObservationReport emissions when KillSwitch is active."
    },
    {
      "input": "Sports model interest list",
      "source": "SportsModel config",
      "required": false,
      "use": "Prioritise feed polling for sports and events that the model has active coverage on."
    }
  ],
  "raw_params": [
    "enabled_sports \u00b7 list",
    "preferred_provider \u00b7 enum",
    "refresh_interval_s \u00b7 int",
    "fallback_to_web \u00b7 bool"
  ],
  "parameters": [
    {
      "name": "refresh_interval_s",
      "default": 30,
      "warning": 10,
      "hard": 5,
      "controls": "How often in seconds the primary league API is polled for updates per tracked event.",
      "why_default_matters": "30 s is sufficient for pre-game data; in-play updates are supplemented by ws_sports which runs continuously.",
      "threshold_logic": [
        {
          "condition": "interval \u2265 30 s",
          "action": "Normal"
        },
        {
          "condition": "10\u201330 s",
          "action": "WARN \u2014 increased API load; monitor rate limits"
        },
        {
          "condition": "< 5 s",
          "action": "Reject \u2014 PARAMETER_CHANGE_REQUIRES_APPROVAL"
        }
      ],
      "dev_check": "if (p.refresh_interval_s < p.hard) throw ConfigError('PARAMETER_CHANGE_REQUIRES_APPROVAL');",
      "user_facing": "Sports data is refreshed regularly to keep event information current without overloading data providers."
    },
    {
      "name": "stale_feed_threshold_s",
      "default": 120,
      "warning": 60,
      "hard": 300,
      "controls": "Seconds since last successful feed update after which a STALE_DATA warning is emitted and the affected sport is flagged as unreliable.",
      "why_default_matters": "120 s gives two full refresh cycles as tolerance before alerting, avoiding false positives from brief API hiccups.",
      "threshold_logic": [
        {
          "condition": "age \u2264 120 s",
          "action": "Normal"
        },
        {
          "condition": "120\u2013300 s",
          "action": "WARN \u2014 feed stale; downstream model uses last-known data with STALE_DATA flag"
        },
        {
          "condition": "> 300 s",
          "action": "Hard stale \u2014 emit STALE_DATA and halt ObservationReport emissions for affected sport"
        }
      ],
      "dev_check": "if (feed_age_s > p.hard) emit_stale_and_halt(sport);",
      "user_facing": "If sports data has not been updated within the expected window, affected markets are flagged as using potentially stale information."
    },
    {
      "name": "min_odds_shift_bps",
      "default": 50,
      "warning": 20,
      "hard": 5,
      "controls": "Minimum shift in odds (in basis points) required to trigger an ObservationReport for an odds-update event.",
      "why_default_matters": "50 bps filters out micro-fluctuations in odds that carry no meaningful model signal, preventing bus flooding during high-frequency pre-game odds movement.",
      "threshold_logic": [
        {
          "condition": "shift \u2265 50 bps",
          "action": "Normal \u2014 emit ObservationReport"
        },
        {
          "condition": "20\u201350 bps",
          "action": "WARN \u2014 marginal shift; emit with SPORTSFEED_MINOR_ODDS_SHIFT warning"
        },
        {
          "condition": "< 5 bps",
          "action": "Discard \u2014 noise floor; do not emit"
        }
      ],
      "dev_check": "if (abs(odds_shift_bps) < p.hard) return; // noise floor",
      "user_facing": "Only meaningful odds movements generate updates to avoid signal noise."
    }
  ],
  "default_config": {
    "bot_id": "intel.sportsfeed-adapter",
    "version": "2.1.0",
    "mode": "limited_live",
    "defaults": {
      "enabled_sports": [
        "NBA",
        "NFL",
        "EPL",
        "ATP",
        "WTA",
        "MLB"
      ],
      "preferred_provider": "primary_league_api",
      "refresh_interval_s": 30,
      "stale_feed_threshold_s": 120,
      "min_odds_shift_bps": 50,
      "fallback_to_web": true
    },
    "locked": {
      "refresh_interval_s": {
        "min": 5
      },
      "stale_feed_threshold_s": {
        "max": 300
      },
      "min_odds_shift_bps": {
        "min": 5
      }
    }
  },
  "implementation_flow": [
    "On startup, subscribe to ws_sports for real-time in-play state updates for all enabled_sports.",
    "On each refresh cycle (refresh_interval_s), poll preferred_provider league API for each tracked event: lineups, injuries, odds, score, game_clock.",
    "If preferred_provider unavailable and fallback_to_web=true, attempt web extraction for affected sport/event.",
    "Normalise raw feed payload into canonical SportsFeedEvent schema: {event_id, sport, home_team, away_team, condition_id, event_type, payload, source_provider, feed_age_s}.",
    "Check feed_age_s: if > stale_feed_threshold_s hard (300 s), emit STALE_DATA WARN and halt ObservationReports for that sport.",
    "For odds-update events, compute odds_shift_bps vs last emitted odds. If abs(odds_shift_bps) < min_odds_shift_bps hard (5 bps), discard as noise.",
    "Check KillSwitch. If active, continue ingesting and normalising but suppress ObservationReport emissions.",
    "For qualifying events, emit ObservationReport with: report_id, trace_id, condition_id, event_type, sport, event_id, normalised_payload, source_provider, feed_age_s, odds_shift_bps (if applicable), warnings.",
    "Apply sampling: emit-every for LINEUP_CHANGE, INJURY_UPDATE, GAME_START, GAME_END; sample-1/5 for routine score-tick updates.",
    "Log per-cycle summary: events_polled, events_emitted, events_discarded_noise, stale_sports, fallback_used."
  ],
  "decision_logic": {
    "approve": "Not applicable \u2014 SportsFeed-Adapter is read-only; it never approves or submits orders.",
    "reshape_required": "Not applicable.",
    "reject": "Observations are suppressed only when KillSwitch is active (KILL_SWITCH_ACTIVE) or when feed data is hard-stale (> stale_feed_threshold_s hard). Noise-floor odds shifts (< 5 bps) are silently discarded.",
    "warning_only": "SPORTSFEED_MINOR_ODDS_SHIFT is included for shifts between 20\u201350 bps. STALE_DATA is included when feed age is between 120\u2013300 s."
  },
  "decision_output_schema": "ObservationReport",
  "decision_output_example": {
    "report_id": "rep_sfa_NBA_0xcc99_1746704000000",
    "trace_id": "trc_0xdead0102030405060708",
    "bot_id": "intel.sportsfeed-adapter",
    "kind": "ObservationReport",
    "condition_id": "0xcc990000000000000000000000000000000000000000000000000000000000000000",
    "event_type": "INJURY_UPDATE",
    "sport": "NBA",
    "event_id": "NBA_2026_PO_G3_TOR_MIA",
    "normalised_payload": {
      "player": "Player A",
      "team": "TOR",
      "status": "OUT",
      "reason": "ankle sprain",
      "updated_at_ms": 1746704000000
    },
    "source_provider": "primary_league_api",
    "feed_age_s": 8,
    "odds_shift_bps": null,
    "sampling_applied": false,
    "warnings": [],
    "emitted_at_ms": 1746704000095
  },
  "developer_log": {
    "bot_id": "intel.sportsfeed-adapter",
    "refresh_cycle": 2847,
    "events_polled": 42,
    "events_emitted": 6,
    "events_discarded_noise": 11,
    "stale_sports": [],
    "fallback_used": false,
    "killswitch_active": false
  },
  "user_explanations": [
    {
      "situation": "Market odds adjusted after injury news",
      "message": "A player injury was reported before the game. The system updated its assessment of the market based on this new information."
    },
    {
      "situation": "In-play market showing rapid updates",
      "message": "Live sports data is being processed to keep the market assessment current as the game progresses."
    },
    {
      "situation": "Sports market flagged as using stale data",
      "message": "The data feed for this sport has not updated recently. The system is using the most recent available information, but it may not reflect the latest conditions."
    }
  ],
  "failure_modes": {
    "main_failure_mode": "Primary league API outage during a live game causes score and in-play state updates to stop flowing; sports-model strategies continue pricing from the last known state, accumulating pricing error until the feed recovers or fallback_to_web activates.",
    "false_positive_risk": "Minor odds fluctuations below the 50 bps default threshold occasionally carry genuine model signal (e.g. sharp-money moves) that gets filtered as noise, causing the model to miss a meaningful odds shift.",
    "false_negative_risk": "A major injury or lineup change arrives via a secondary source not covered by the preferred_provider before the primary API is updated; the bot does not emit an ObservationReport until the primary source confirms the update.",
    "safe_fallback": "If all feed sources (primary API and web fallback) are unavailable for > stale_feed_threshold_s hard (300 s), emit STALE_DATA WARN and halt ObservationReport emissions for the affected sport. Downstream strategies treat the last-known SportsFeedEvent as authoritative but annotate positions as STALE_FEED.",
    "required_dependencies": [
      "League API (or web fallback) for at least one enabled sport",
      "Gamma API for event_id \u2192 condition_id mapping",
      "ws_sports subscription for in-play supplements",
      "KillSwitch active flag readable"
    ]
  },
  "acceptance_tests": {
    "unit": [
      {
        "test": "INJURY_UPDATE event from league API emits ObservationReport emit-every",
        "setup": "Mock INJURY_UPDATE payload for NBA event; refresh_interval_s=30",
        "expected": "ObservationReport emitted with event_type=INJURY_UPDATE, sampling_applied=False"
      },
      {
        "test": "Odds shift below noise floor (3 bps) discarded silently",
        "setup": "odds_shift_bps=3, min_odds_shift_bps hard=5",
        "expected": "No ObservationReport; events_discarded_noise counter incremented"
      },
      {
        "test": "Stale feed > 300 s halts emissions for affected sport",
        "setup": "feed_age_s=350, stale_feed_threshold_s hard=300",
        "expected": "STALE_DATA WARN logged; no ObservationReport emitted for that sport; stale_sports=['NBA']"
      },
      {
        "test": "KillSwitch suppresses ObservationReport emissions",
        "setup": "killswitch.active=true; LINEUP_CHANGE event arrives",
        "expected": "Event ingested and normalised; no ObservationReport emitted; KILL_SWITCH_ACTIVE logged"
      },
      {
        "test": "Primary API down triggers fallback_to_web for affected sport",
        "setup": "primary_league_api returns 503; fallback_to_web=true",
        "expected": "Web extraction used; ObservationReport emitted with source_provider=web_fallback"
      },
      {
        "test": "Routine score-tick sampled 1/5",
        "setup": "5 consecutive score-tick events, sampling rule sample-1/5",
        "expected": "Approximately 1 ObservationReport emitted with sampling_applied=True"
      }
    ],
    "integration": [
      {
        "test": "Full lifecycle: INJURY_UPDATE from league API reaches sports-model strategy",
        "expected": "Strategy receives ObservationReport with INJURY_UPDATE and correct condition_id; reprices market"
      },
      {
        "test": "ws_sports in-play state update supplements API polling with lower latency",
        "expected": "ObservationReport emitted within 2 s of ws_sports score event; source_provider=ws_sports"
      },
      {
        "test": "All enabled sports polled correctly on startup without errors",
        "expected": "All sports in enabled_sports list polled; no stale_sports on first cycle"
      }
    ],
    "property": [
      {
        "property": "SportsFeed-Adapter never submits, signs, or modifies any order",
        "required": "Always true"
      },
      {
        "property": "No ObservationReport emitted when KillSwitch is active",
        "required": "Always true"
      },
      {
        "property": "No ObservationReport emitted when feed_age_s > stale_feed_threshold_s hard",
        "required": "Always true \u2014 stale data must never produce a fresh observation"
      }
    ]
  },
  "checklist_overrides": {},
  "legacy_goal": "Wire structured sports data into Sports Model.",
  "legacy_pm_signals": [],
  "legacy_external_feeds": [
    "League APIs (NBA, NFL, EPL, ATP/WTA, MLB)",
    "Odds-feed providers (lineup / injury / weather)",
    "Web fallbacks for sports without APIs"
  ],
  "reporting_groups": [
    "pretrade_intel"
  ],
  "network": [
    "polygon"
  ],
  "api_surface": [
    "gamma_api",
    "ws_sports",
    "ws_rtds",
    "internal"
  ],
  "version": {
    "spec": "2.0.0",
    "implementation": "2.1.0",
    "schema": "2",
    "released": "2026-04-28"
  },
  "migration_history": [
    {
      "date": "2026-04-28",
      "from": "v1",
      "to": "v2",
      "reason": "CLOB V2 cutover \u2014 ws_sports V2 message format and pUSD denomination in model context",
      "action_taken": "Updated ws_sports subscription to CLOB V2 message format. ObservationReport payloads now reference pUSD-denominated market context. Removed legacy nonce from any internal message plumbing. No feeRateBps or signed-order plumbing in this bot."
    }
  ],
  "polymarket_v2_compat": {
    "clob_version": "v2",
    "collateral": "pUSD",
    "eip712_domain_version": "2",
    "builder_code_aware": false,
    "negrisk_aware": false,
    "multichain_ready": false,
    "sdk_used": "py-clob-client-v2",
    "settlement_contract": "CTFExchangeV2",
    "notes": "SportsFeed-Adapter consumes ws_sports V2 in-play state updates and references condition_ids from Gamma API V2; all model-context amounts are denominated in pUSD."
  },
  "reference_implementation": {
    "summary": "Polls league APIs and ws_sports for sports events, normalises to canonical SportsFeedEvent schema, applies staleness and noise-floor filters, applies sampling for routine score ticks, and emits ObservationReports to the sports-model strategy layer.",
    "language_note": "Pseudocode is language-agnostic. FETCH = read input. EMIT = produce output.",
    "pseudocode": "// --- Initialisation ---\nws_sports.subscribe(handler=onInPlayEvent)\nlast_odds   = {}   // event_id -> odds_value\nlast_feed_ts = {}  // sport -> timestamp_ms\nscore_tick_counter = {}  // event_id -> int\n\nFUNCTION refreshCycle():\n  // --- 0. KillSwitch gate ---\n  ks = FETCH internal.killswitch.status\n\n  FOR sport IN params.enabled_sports:\n    // --- 1. Poll primary provider ---\n    feed = FETCH league_api[sport].getEvents(active=true)\n\n    IF feed IS NULL AND params.fallback_to_web:\n      feed = FETCH web_extractor[sport].getEvents()\n      provider = 'web_fallback'\n    ELSE:\n      provider = params.preferred_provider\n\n    IF feed IS NULL:\n      feed_age_s = (now_ms() - last_feed_ts.get(sport, 0)) / 1000\n      IF feed_age_s > params.stale_feed_threshold_s.hard:\n        LOG WARN 'STALE_DATA \u2014 ' + sport + ' feed hard stale'\n        stale_sports.add(sport)\n      CONTINUE\n\n    last_feed_ts[sport] = now_ms()\n    stale_sports.discard(sport)\n\n    FOR event IN feed.events:\n      condition_id = gamma_api.GET('/sports-event/' + event.event_id + '/condition')\n      IF condition_id IS NULL:\n        CONTINUE\n\n      // --- 2. Classify event type ---\n      FOR update IN event.updates:\n        event_type = classifyUpdate(update)  // INJURY_UPDATE | LINEUP_CHANGE | ODDS_UPDATE | SCORE_TICK | GAME_START | GAME_END\n\n        // --- 3. Noise filter for odds updates ---\n        IF event_type == 'ODDS_UPDATE':\n          shift_bps = abs(update.new_odds - last_odds.get(event.event_id, update.new_odds)) * 10000\n          IF shift_bps < params.min_odds_shift_bps.hard:\n            events_discarded_noise_counter += 1\n            CONTINUE\n          last_odds[event.event_id] = update.new_odds\n\n        // --- 4. Staleness check ---\n        feed_age_s = (now_ms() - update.provider_ts_ms) / 1000\n        warnings = []\n        IF feed_age_s > params.stale_feed_threshold_s.default:\n          warnings.append('STALE_DATA')\n          IF feed_age_s > params.stale_feed_threshold_s.hard:\n            CONTINUE  // halt emission\n\n        // --- 5. Sampling for routine ticks ---\n        sampling_applied = False\n        IF event_type == 'SCORE_TICK':\n          score_tick_counter[event.event_id] = score_tick_counter.get(event.event_id, 0) + 1\n          IF score_tick_counter[event.event_id] % 5 != 0:\n            CONTINUE  // sample-1/5\n          sampling_applied = True\n\n        // --- 6. KillSwitch suppress ---\n        IF ks.active:\n          LOG INFO 'KILL_SWITCH_ACTIVE \u2014 suppressing ObservationReport'\n          CONTINUE\n\n        // --- 7. Emit ---\n        EMIT ObservationReport {\n          report_id:         'rep_sfa_' + sport + '_' + condition_id[:6] + '_' + now_ms(),\n          trace_id:          new_trace_id(),\n          bot_id:            'intel.sportsfeed-adapter',\n          kind:              'ObservationReport',\n          condition_id:      condition_id,\n          event_type:        event_type,\n          sport:             sport,\n          event_id:          event.event_id,\n          normalised_payload: normalise(update),\n          source_provider:   provider,\n          feed_age_s:        feed_age_s,\n          odds_shift_bps:    shift_bps IF event_type == 'ODDS_UPDATE' ELSE None,\n          sampling_applied:  sampling_applied,\n          warnings:          warnings,\n          emitted_at_ms:     now_ms()\n        }\n\nFUNCTION onInPlayEvent(ws_event):\n  // ws_sports supplement \u2014 same normalise + emit pipeline, source_provider='ws_sports'\n  condition_id = gamma_api.GET('/sports-event/' + ws_event.event_id + '/condition')\n  IF condition_id IS NULL: RETURN\n  // ... same emit logic with event_type from ws_sports message type",
    "sdk_calls": [
      "league_api[sport].getEvents(active=true)",
      "ws_sports.subscribe(handler=onInPlayEvent)",
      "gamma_api.GET('/sports-event/<event_id>/condition')",
      "web_extractor[sport].getEvents()"
    ],
    "complexity": "O(S \u00d7 E) per refresh cycle where S = enabled sports count, E = average events per sport"
  },
  "wire_examples": {
    "input": {
      "label": "League API injury update payload",
      "source": "primary_league_api",
      "payload": {
        "sport": "NBA",
        "event_id": "NBA_2026_PO_G3_TOR_MIA",
        "updates": [
          {
            "type": "injury",
            "player": "Player A",
            "team": "TOR",
            "status": "OUT",
            "reason": "ankle sprain",
            "provider_ts_ms": 1746704000000
          }
        ]
      }
    },
    "output": {
      "label": "ObservationReport \u2014 INJURY_UPDATE for NBA market",
      "payload": {
        "report_id": "rep_sfa_NBA_0xcc99_1746704000000",
        "trace_id": "trc_0xdead0102030405060708",
        "bot_id": "intel.sportsfeed-adapter",
        "kind": "ObservationReport",
        "condition_id": "0xcc990000000000000000000000000000000000000000000000000000000000000000",
        "event_type": "INJURY_UPDATE",
        "sport": "NBA",
        "event_id": "NBA_2026_PO_G3_TOR_MIA",
        "normalised_payload": {
          "player": "Player A",
          "team": "TOR",
          "status": "OUT",
          "reason": "ankle sprain",
          "updated_at_ms": 1746704000000
        },
        "source_provider": "primary_league_api",
        "feed_age_s": 8,
        "odds_shift_bps": null,
        "sampling_applied": false,
        "warnings": [],
        "emitted_at_ms": 1746704000095
      }
    }
  },
  "reason_codes": [
    {
      "code": "SPORTSFEED_INJURY_UPDATE",
      "severity": "WARN",
      "meaning": "A player injury or status change was detected for a tracked sports event.",
      "action": "Emit ObservationReport emit-every; sports-model strategy reprices the market.",
      "user_message": "An injury report was detected for a player in this market\u2019s event."
    },
    {
      "code": "SPORTSFEED_LINEUP_CHANGE",
      "severity": "WARN",
      "meaning": "Team lineup or starting roster change detected before game start.",
      "action": "Emit ObservationReport emit-every; sports-model strategy updates pre-game probability estimate.",
      "user_message": "A lineup change was reported for this market\u2019s event."
    },
    {
      "code": "SPORTSFEED_MINOR_ODDS_SHIFT",
      "severity": "WARN",
      "meaning": "Odds shift is between 20\u201350 bps \u2014 marginal but above noise floor.",
      "action": "Emit ObservationReport with SPORTSFEED_MINOR_ODDS_SHIFT warning; strategy applies lower weight.",
      "user_message": ""
    },
    {
      "code": "STALE_DATA",
      "severity": "WARN",
      "meaning": "Feed age is > stale_feed_threshold_s for this sport; data may not reflect current conditions.",
      "action": "Include in ObservationReport warnings if between default\u2013hard threshold; halt emissions if > hard threshold.",
      "user_message": "Sports data for this event may be slightly delayed."
    },
    {
      "code": "KILL_SWITCH_ACTIVE",
      "severity": "HARD_REJECT",
      "meaning": "KillSwitch active; ObservationReport emissions suppressed.",
      "action": "Continue ingesting feed data but suppress all emissions.",
      "user_message": "Sports data updates are paused while trading is suspended."
    },
    {
      "code": "SPORTSFEED_FALLBACK_ACTIVE",
      "severity": "WARN",
      "meaning": "Primary league API unavailable; web fallback in use for this sport.",
      "action": "Emit ObservationReport with source_provider=web_fallback; downstream strategy applies lower data-quality weight.",
      "user_message": ""
    },
    {
      "code": "MARKET_CLOSED",
      "severity": "EXPLAIN",
      "meaning": "Feed event received for a condition_id that is already closed or resolved.",
      "action": "Skip emission; log for audit trail only.",
      "user_message": ""
    },
    {
      "code": "PARAMETER_CHANGE_REQUIRES_APPROVAL",
      "severity": "HARD_REJECT",
      "meaning": "A parameter change violates a locked bound (e.g. refresh_interval_s < 5).",
      "action": "Reject config change; do not apply.",
      "user_message": ""
    }
  ],
  "metrics": {
    "emitted": [
      {
        "name": "polytraders_intel_sportsfeedadapter_events_polled_total",
        "type": "counter",
        "unit": "count",
        "labels": [
          "sport"
        ],
        "meaning": "Total sports events polled from all feed sources per refresh cycle, broken down by sport."
      },
      {
        "name": "polytraders_intel_sportsfeedadapter_observations_emitted_total",
        "type": "counter",
        "unit": "count",
        "labels": [
          "sport",
          "event_type"
        ],
        "meaning": "ObservationReports emitted broken down by sport and event_type."
      },
      {
        "name": "polytraders_intel_sportsfeedadapter_noise_discarded_total",
        "type": "counter",
        "unit": "count",
        "labels": [
          "sport"
        ],
        "meaning": "Odds updates discarded as noise (below min_odds_shift_bps hard floor)."
      },
      {
        "name": "polytraders_intel_sportsfeedadapter_feed_age_s",
        "type": "gauge",
        "unit": "seconds",
        "labels": [
          "sport"
        ],
        "meaning": "Age of the most recent successful feed update per sport."
      },
      {
        "name": "polytraders_intel_sportsfeedadapter_fallback_activations_total",
        "type": "counter",
        "unit": "count",
        "labels": [
          "sport"
        ],
        "meaning": "Number of times web fallback was activated due to primary API unavailability per sport."
      },
      {
        "name": "polytraders_intel_sportsfeedadapter_stale_sports_gauge",
        "type": "gauge",
        "unit": "count",
        "labels": [],
        "meaning": "Number of sports currently in hard-stale state (feed_age_s > stale_feed_threshold_s hard)."
      }
    ],
    "alerts": [
      {
        "name": "SportsFeedAdapterStaleFeed",
        "condition": "polytraders_intel_sportsfeedadapter_feed_age_s > 300 for any sport label",
        "severity": "page",
        "runbook": "#runbook-sportsfeedadapter-stale-feed"
      },
      {
        "name": "SportsFeedAdapterFallbackActive",
        "condition": "rate(polytraders_intel_sportsfeedadapter_fallback_activations_total[10m]) > 0",
        "severity": "warn",
        "runbook": "#runbook-sportsfeedadapter-fallback"
      },
      {
        "name": "SportsFeedAdapterZeroEmissions",
        "condition": "rate(polytraders_intel_sportsfeedadapter_observations_emitted_total[15m]) == 0 AND polytraders_risk_killswitch_active == 0",
        "severity": "warn",
        "runbook": "#runbook-sportsfeedadapter-zero-emissions"
      },
      {
        "name": "SportsFeedAdapterHighNoise",
        "condition": "rate(polytraders_intel_sportsfeedadapter_noise_discarded_total[5m]) > 50",
        "severity": "warn",
        "runbook": "#runbook-sportsfeedadapter-high-noise"
      }
    ],
    "dashboards": [
      "Grafana \u2014 Intelligence / SportsFeed-Adapter feed age per sport",
      "Grafana \u2014 Intelligence / event type distribution and emissions rate"
    ],
    "log_level": "info"
  },
  "state": {
    "store": "redis",
    "shape": "last_odds per event_id; last_feed_ts per sport; score_tick_counter per event_id; stale_sports set. Persisted in Redis with short TTL.",
    "ttl": "last_odds: 24 h (game lifetime); last_feed_ts: 10 min; score_tick_counter: game lifetime (24 h max)",
    "recovery": "On cold start, Redis state reloaded. First refresh cycle repopulates any missing keys. score_tick_counter resets to 0 for all events.",
    "size_estimate": "~500 bytes per active event; ~30 KB for 60 concurrent sports events across 6 sports"
  },
  "concurrency": {
    "execution_model": "single-threaded event loop",
    "max_in_flight": 6,
    "idempotency_key": "event_id + update_type + provider_ts_ms",
    "timeout_ms": 10000,
    "backpressure": "drop-after-buffer \u2014 if refresh cycle takes longer than refresh_interval_s, next cycle is skipped",
    "locking": "none \u2014 per-sport state accessed only from single event loop"
  },
  "dependencies": {
    "depends_on": [
      {
        "bot_id": "risk.kill_switch",
        "why": "KillSwitch gate suppresses ObservationReport emissions."
      }
    ],
    "emits_to": [
      {
        "bot_id": "strat.sports_model",
        "what": "ObservationReport with INJURY_UPDATE, LINEUP_CHANGE, ODDS_UPDATE, SCORE_TICK for sports market pricing"
      },
      {
        "bot_id": "risk.liquidity_guard",
        "what": "ObservationReport with GAME_START and GAME_END events for position lifecycle management on sports markets"
      }
    ],
    "sibling": [],
    "external": [
      {
        "service": "League APIs (NBA, NFL, EPL, ATP/WTA, MLB)",
        "endpoint": "Sport-specific league API endpoints",
        "sla": "variable per provider; assumed 99% / 500 ms p99",
        "fallback": "Activate fallback_to_web for affected sport if primary unavailable"
      },
      {
        "service": "Polymarket ws_sports",
        "endpoint": "wss://ws-subscriptions-clob.polymarket.com/ws/sports",
        "sla": "best-effort",
        "fallback": "Fall back to REST polling at refresh_interval_s cadence"
      },
      {
        "service": "Gamma API (event_id -> condition_id)",
        "endpoint": "https://gamma-api.polymarket.com",
        "sla": "99.9% / 500 ms p99",
        "fallback": "Skip events with unresolved condition_ids; retry on next cycle"
      }
    ]
  },
  "security_surfaces": {
    "signs_orders": false,
    "private_key_access": "none",
    "abuse_vectors": [
      "Compromised league API provider injecting false injury or lineup data to manipulate sports-model strategy pricing",
      "Web fallback scraping page with adversarially crafted content to produce incorrect normalised_payload values"
    ],
    "mitigations": [
      "Only preferred league API sources are used by default; web fallback requires explicit fallback_to_web=true config",
      "normalised_payload schema is strictly validated; unexpected fields are stripped before emission",
      "All ObservationReports are informational only \u2014 downstream strategies and risk bots independently validate market state before acting"
    ],
    "contract_calls": []
  },
  "failure_injection": [
    {
      "scenario": "PRIMARY_API_DOWN",
      "how_to_inject": "Block TCP to primary league API for 200 s",
      "expected_behaviour": "Fallback to web extractor if fallback_to_web=true; SPORTSFEED_FALLBACK_ACTIVE WARN logged; SportsFeedAdapterFallbackActive alert fires",
      "recovery": "Primary API recovered; primary provider resumes; fallback deactivated"
    },
    {
      "scenario": "HARD_STALE_FEED",
      "how_to_inject": "Block all feed sources (primary + web) for 350 s (> 300 s hard threshold)",
      "expected_behaviour": "STALE_DATA WARN emitted; stale_sports includes affected sport; SportsFeedAdapterStaleFeed alert fires; ObservationReports halted for affected sport",
      "recovery": "Automatic on next successful feed response; stale_sports cleared"
    },
    {
      "scenario": "KILL_SWITCH_ON",
      "how_to_inject": "Set killswitch.active=true; inject INJURY_UPDATE event",
      "expected_behaviour": "Event ingested and normalised; no ObservationReport emitted; KILL_SWITCH_ACTIVE logged",
      "recovery": "Emissions resume on first refresh cycle after KillSwitch reset"
    },
    {
      "scenario": "NOISE_ODDS_FLOOD",
      "how_to_inject": "Inject 100 odds updates of 2 bps each for a single event",
      "expected_behaviour": "All 100 discarded as below hard floor (5 bps); noise_discarded_total += 100; SportsFeedAdapterHighNoise alert fires",
      "recovery": "Automatic; no action required"
    },
    {
      "scenario": "WS_SPORTS_DISCONNECT",
      "how_to_inject": "Drop ws_sports TCP connection",
      "expected_behaviour": "In-play supplement falls back to REST polling at refresh_interval_s; latency increases but emissions continue from REST source",
      "recovery": "Automatic ws_sports reconnect with exponential back-off"
    }
  ],
  "runbook": {
    "summary": "SportsFeed-Adapter incidents are usually league API outages or hard-stale feeds. Since sports markets can move rapidly around game events, stale feeds during live games should be paged immediately. The fallback_to_web mechanism provides a safety net during primary API outages.",
    "oncall_actions": [
      {
        "alert": "SportsFeedAdapterStaleFeed",
        "first_action": "Check feed_age_s per sport. Verify primary league API availability. If primary down, confirm fallback_to_web is enabled and web extractor is operational.",
        "escalate_to": "Intelligence pod lead immediately if stale during live game window"
      },
      {
        "alert": "SportsFeedAdapterFallbackActive",
        "first_action": "Identify which sport triggered fallback. Check primary league API endpoint. Verify web extractor is returning correct schema.",
        "escalate_to": "Intelligence pod lead within 15 minutes"
      },
      {
        "alert": "SportsFeedAdapterZeroEmissions",
        "first_action": "Confirm KillSwitch is not active. Check events_polled rate. Verify all enabled_sports APIs are reachable.",
        "escalate_to": "Intelligence pod lead within 10 minutes"
      },
      {
        "alert": "SportsFeedAdapterHighNoise",
        "first_action": "Check noise_discarded_total rate by sport. High noise may indicate a feed provider sending frequent micro-updates. Consider increasing min_odds_shift_bps temporarily.",
        "escalate_to": "Intelligence pod lead if noise rate sustained > 30 min"
      }
    ],
    "manual_overrides": [
      {
        "name": "disable_sport",
        "how": "Remove sport from config.enabled_sports to stop polling for a specific sport",
        "when": "A league API is known to be down for an extended period and fallback_to_web is not desired"
      }
    ],
    "healthcheck": "GET /internal/health/sportsfeed-adapter -> 200 if All enabled_sports have feed_age_s < stale_feed_threshold_s AND ws_sports connected AND observations_emitted_total rate > 0 in last 5 min. RED if Any sport in stale_sports (hard stale) OR ws_sports disconnected > 60 s AND primary API also down OR observations_emitted_total rate == 0 for > 15 min."
  },
  "promotion_gates": {
    "to_shadow": [
      {
        "gate": "Unit tests pass for INJURY_UPDATE, LINEUP_CHANGE, ODDS_UPDATE noise filter, stale threshold, and KillSwitch suppression",
        "how_measured": "CI test run",
        "threshold": "100% pass"
      },
      {
        "gate": "Integration test: all 6 enabled sports polled successfully with correct normalised_payload schema",
        "how_measured": "Integration test against staging league APIs",
        "threshold": "Pass"
      }
    ],
    "to_limited_live": [
      {
        "gate": "feed_age_s p99 < 45 s for all enabled sports over 48 h",
        "how_measured": "polytraders_intel_sportsfeedadapter_feed_age_s gauge",
        "threshold": "p99 < 45 s"
      },
      {
        "gate": "INJURY_UPDATE and LINEUP_CHANGE correctly classified and emitted for known historical test events",
        "how_measured": "Back-test against labelled historical feed data",
        "threshold": "100% recall on test set"
      }
    ],
    "to_general_live": [
      {
        "gate": "Zero hard-stale incidents during live game windows over 14 days",
        "how_measured": "SportsFeedAdapterStaleFeed alert history filtered to game windows",
        "threshold": "0 firings during live game windows"
      },
      {
        "gate": "KillSwitch suppression: zero ObservationReports when KillSwitch active",
        "how_measured": "Integration test",
        "threshold": "Pass"
      },
      {
        "gate": "Fallback_to_web activates correctly during simulated primary API outage",
        "how_measured": "Chaos test: block primary API for 60 s; verify web fallback active and emitting",
        "threshold": "Pass"
      }
    ]
  },
  "reporting": {
    "emits_kinds": [
      "ObservationReport"
    ],
    "topics": [
      "polytraders.reports.observation"
    ],
    "cadence": "every-event",
    "retention_class": "30d",
    "retention_notes": "Full fidelity for 30 d; rolled-up summary retained for 1 y",
    "sampling_rule": "emit-every for INJURY_UPDATE, LINEUP_CHANGE, GAME_START, GAME_END; sample-1/5 for routine SCORE_TICK",
    "bus_failure_action": "drop-after-buffer",
    "user_visible": "summary-only",
    "consumes_kinds": []
  },
  "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"
  }
}