{
  "schema_version": "1.0.0",
  "bot_id": "3.9",
  "bot_name": "Sports Model",
  "slug": "sports-model",
  "layer": "Strategy",
  "layer_key": "strat",
  "bot_class": "Alpha Strategy",
  "authority": [
    "Trade"
  ],
  "status": "beta",
  "readiness": "Limited live",
  "flagship": false,
  "is_reference": false,
  "public_export": false,
  "identity": {
    "layer": "Strategy",
    "bot_class": "Alpha Strategy",
    "authority": "Trade",
    "runs_before": "Risk guardrail pipeline",
    "runs_after": "SportsFeed-Adapter model price feed + Market scanner",
    "applies_to": "Polymarket sports markets (NBA, NFL, EPL, ATP/WTA, MLB, esports) where internal power-rating model price diverges from CLOB mid by >= min_edge_bps_vs_model, and lineup/injury/weather data is current",
    "default_mode": "limited_live",
    "user_visible": "Advanced details only",
    "developer_owner": "Polytraders core \u2014 Strategy pod"
  },
  "purpose": "Sports Model computes a quantitative fair-value probability for Polymarket sports markets using an internal power-rating model fed by league APIs (NBA, NFL, EPL, ATP/WTA, MLB), lineup and injury data via SportsFeed-Adapter, and weather data for outdoor events. When the CLOB mid-price diverges from the model price by more than min_edge_bps_vs_model, the bot sizes an IOC OrderIntent using a fractional-Kelly position sizing formula (kelly_fraction) bounded by max_per_bet_usd. The bot targets the Apr 2026 $5M pUSD maker rebate pool for sports+esports markets when the edge direction permits maker posting. This is a user-controlled execution tool that automates quantitative sports market trading decisions. No performance claims are made.",
  "why_it_matters": [
    {
      "failure": "Model price computed with stale lineup or injury data",
      "consequence": "A key player's injury changes the model price substantially. Trading on the stale model produces a position in the wrong direction relative to the true updated probability."
    },
    {
      "failure": "Kelly fraction set too high",
      "consequence": "Fractional Kelly with a high fraction creates large positions that overwhelm the top-of-book depth, causing significant slippage and potentially exceeding the Risk guardrail's position limits."
    },
    {
      "failure": "In-play market state not accounted for",
      "consequence": "For in-play markets, the CLOB price updates continuously with live game state. Trading on a model price calibrated for pre-game conditions during in-play produces systematically mispriced entries."
    },
    {
      "failure": "feeRateBps present on signed order (V1 pattern)",
      "consequence": "CTFExchangeV2 rejects orders with feeRateBps. Fees are operator-set at match time. The signed order must not contain this field."
    }
  ],
  "polymarket_inputs": [
    {
      "input": "Polymarket sports market mid and depth",
      "source": "ws_market (CLOB WebSocket)",
      "required": true,
      "use": "Compute CLOB mid-price; measure divergence from model price in bps; size order against available depth."
    },
    {
      "input": "In-play market state (where applicable)",
      "source": "ws_sports (Polymarket sports WebSocket)",
      "required": false,
      "use": "Detect live game state changes (score, time remaining, possession) that affect model price validity."
    },
    {
      "input": "Market open/closed/resolved status",
      "source": "clob_public",
      "required": true,
      "use": "Skip markets that are closed, resolved, or halted (in-play pause)."
    }
  ],
  "internal_inputs": [
    {
      "input": "KillSwitch active flag",
      "source": "KillSwitch",
      "required": true,
      "use": "Abort all intent emission immediately if KillSwitch is active."
    },
    {
      "input": "Power-rating model price (fair-value probability per market)",
      "source": "internal (model engine)",
      "required": true,
      "use": "Compare model_price against CLOB mid; compute edge_bps = |model_price - clob_mid| * 10000."
    },
    {
      "input": "League APIs (NBA, NFL, EPL, ATP/WTA, MLB)",
      "source": "internal (SportsFeed-Adapter)",
      "required": true,
      "use": "Current standings, team stats, and match schedules for model calibration."
    },
    {
      "input": "Lineup / injury / weather data via SportsFeed-Adapter",
      "source": "internal (SportsFeed-Adapter)",
      "required": true,
      "use": "Adjust power-rating model for confirmed lineup changes, injuries, or weather conditions for outdoor events."
    },
    {
      "input": "Builder code bytes32",
      "source": "internal config",
      "required": true,
      "use": "Injected into builder field on every signed V2 OrderIntent. Sports maker rebate pool (Apr 2026: $5M pUSD) eligible."
    }
  ],
  "raw_params": [
    "min_edge_bps_vs_model \u00b7 int (default 200)",
    "kelly_fraction \u00b7 0\u20131",
    "max_per_bet_usd \u00b7 int",
    "auto_disable_neg_sharpe_weeks \u00b7 int"
  ],
  "parameters": [
    {
      "name": "min_edge_bps_vs_model",
      "default": 200,
      "warning": 100,
      "hard": 50,
      "controls": "Minimum basis-point divergence between CLOB mid-price and internal model price required before emitting an OrderIntent.",
      "why_default_matters": "200 bps provides meaningful edge after fee drag (sports markets: 0.75% fee, ~37.5 bps at p=0.5) and expected slippage. Below 100 bps the trade is marginal; below 50 bps the bot will not fire.",
      "threshold_logic": [
        {
          "condition": ">= 200 bps",
          "action": "EMIT IOC OrderIntent"
        },
        {
          "condition": "100\u2013200 bps",
          "action": "WARN SPORTS_MODEL_EDGE_MARGINAL; emit at 50% kelly size"
        },
        {
          "condition": "< 50 bps (hard floor)",
          "action": "SKIP \u2014 SPORTS_MODEL_NO_EDGE"
        }
      ],
      "dev_check": "if edge_bps < params.hard: return skip('SPORTS_MODEL_NO_EDGE')",
      "user_facing": "The difference between the model's estimate and the market price was too small to justify a trade after fees."
    },
    {
      "name": "kelly_fraction",
      "default": 0.1,
      "warning": 0.2,
      "hard": 0.3,
      "controls": "Fraction of the full Kelly criterion bet size applied to each trade. Kelly criterion determines theoretically optimal bet fraction; this parameter scales it down to control risk.",
      "why_default_matters": "0.10 (10% Kelly) provides conservative position sizing that limits variance. Full Kelly (1.0) maximises long-run growth but produces extreme drawdowns; 0.30 hard cap prevents over-aggressive sizing.",
      "threshold_logic": [
        {
          "condition": "<= 0.10",
          "action": "Conservative Kelly sizing"
        },
        {
          "condition": "0.10\u20130.30",
          "action": "WARN SPORTS_MODEL_HIGH_KELLY; elevated per-trade size"
        },
        {
          "condition": "> 0.30 (hard cap)",
          "action": "Reject config \u2014 PARAMETER_CHANGE_REQUIRES_APPROVAL"
        }
      ],
      "dev_check": "if params.kelly_fraction > params.hard: raise ConfigError('PARAMETER_CHANGE_REQUIRES_APPROVAL')",
      "user_facing": "The trade was sized using a conservative fraction of the model's suggested bet size."
    },
    {
      "name": "max_per_bet_usd",
      "default": 500,
      "warning": 750,
      "hard": 1000,
      "controls": "Hard cap in pUSD on any single sports market bet, regardless of Kelly-derived size.",
      "why_default_matters": "500 pUSD per bet limits single-event exposure and fits within typical Polymarket sports market top-of-book depth without significant slippage.",
      "threshold_logic": [
        {
          "condition": "<= 500 pUSD",
          "action": "Normal bet sizing"
        },
        {
          "condition": "500\u20131000 pUSD",
          "action": "WARN; confirm depth supports order size"
        },
        {
          "condition": "> 1000 pUSD",
          "action": "Reject config \u2014 PARAMETER_CHANGE_REQUIRES_APPROVAL"
        }
      ],
      "dev_check": "if params.max_per_bet_usd > params.hard: raise ConfigError('PARAMETER_CHANGE_REQUIRES_APPROVAL')",
      "user_facing": "The trade size was capped at the configured maximum to limit single-event exposure."
    },
    {
      "name": "drawdown_guard_bps",
      "default": 500,
      "warning": 800,
      "hard": 1200,
      "controls": "Maximum basis-point drawdown on the running sports book P&L (mark-to-model) before the bot pauses new entries on all sports markets for the session.",
      "why_default_matters": "500 bps (5%) drawdown guard prevents runaway losses from systematic model miscalibration. Above 800 bps the guard may be too late; above 1200 bps the bot will not fire.",
      "threshold_logic": [
        {
          "condition": "< 500 bps drawdown",
          "action": "Normal trading"
        },
        {
          "condition": "500\u20131200 bps",
          "action": "WARN SPORTS_MODEL_DRAWDOWN_WARNING; reduce kelly_fraction to 50%"
        },
        {
          "condition": "> 1200 bps (hard cap)",
          "action": "SKIP all new entries \u2014 SPORTS_MODEL_DRAWDOWN_GUARD_TRIGGERED"
        }
      ],
      "dev_check": "if session_drawdown_bps >= params.hard: return skip('SPORTS_MODEL_DRAWDOWN_GUARD_TRIGGERED')",
      "user_facing": "Trading was paused because the session's mark-to-model losses reached the configured limit."
    }
  ],
  "default_config": {
    "bot_id": "strat.sports_model",
    "version": "2.1.0",
    "mode": "limited_live",
    "defaults": {
      "min_edge_bps_vs_model": 200,
      "kelly_fraction": 0.1,
      "max_per_bet_usd": 500,
      "drawdown_guard_bps": 500
    },
    "locked": {
      "min_edge_bps_vs_model": {
        "min": 50
      },
      "kelly_fraction": {
        "max": 0.3
      },
      "max_per_bet_usd": {
        "max": 1000
      },
      "drawdown_guard_bps": {
        "max": 1200
      }
    }
  },
  "implementation_flow": [
    "Check KillSwitch active flag; if active, emit no OrderIntents.",
    "Subscribe to ws_market book updates and ws_sports live state for all active sports markets.",
    "On each model update or book tick: compute CLOB mid; measure edge_bps = |model_price - clob_mid| * 10000.",
    "Check session drawdown guard: if session_drawdown_bps >= drawdown_guard_bps hard (1200 bps), skip all new entries.",
    "Gate 1 \u2014 Edge check: if edge_bps < 50 bps hard, emit sampled DecisionReport SPORTS_MODEL_NO_EDGE; skip.",
    "Gate 2 \u2014 Data freshness: confirm SportsFeed-Adapter data is current (lineup_last_updated < 30 min); if stale, emit SPORTS_MODEL_STALE_DATA; skip.",
    "Gate 3 \u2014 In-play check: if market is in-play, confirm ws_sports game state is current (< 5s); if stale, skip.",
    "Gate 4 \u2014 Market status: confirm market is open and not approaching resolution (< 15 min to close for pre-game; skip in-play if halted).",
    "If edge_bps < warning (200 bps): WARN SPORTS_MODEL_EDGE_MARGINAL; reduce kelly_fraction to 50%.",
    "Compute Kelly size: kelly_size_usd = kelly_fraction * bankroll * edge_bps / (model_price * (1 - model_price) * 10000).",
    "Set orderSize = toPusdUnits(min(kelly_size_usd, max_per_bet_usd, available_depth)) using sizeMultiplier.",
    "Determine direction: buy YES if model_price > clob_mid (market underpricing YES); buy NO otherwise.",
    "Emit OrderIntent: outcome=YES/NO, side=buy, price=best_ask, size_pUSD=orderSize, tif=IOC, builder={code, fee_bps:25}.",
    "Note: fees are operator-set at match time in V2 \u2014 feeRateBps is NOT on the signed order. Sports maker rebate pool eligible.",
    "Update session_drawdown tracking in state.",
    "Emit DecisionReport with intent_emitted=true, edge_bps, model_price, clob_mid, kelly_size_usd, reason SPORTS_MODEL_EDGE_TRADE."
  ],
  "decision_logic": {
    "approve": "edge_bps >= min_edge_bps_vs_model, data fresh, market open, session drawdown within guard, KillSwitch inactive. Emit IOC OrderIntent.",
    "reshape_required": "Not applicable \u2014 strat bots emit OrderIntents; reshaping is handled by the downstream Risk guardrail pipeline.",
    "reject": "edge_bps < 50 bps hard floor; stale SportsFeed data; market closed; drawdown guard triggered; KillSwitch active. Emit DecisionReport intent_emitted=false.",
    "warning_only": "edge_bps between 50 and 200 bps, or session drawdown between 500 and 1200 bps, triggers warning and 50% Kelly size reduction."
  },
  "decision_output_schema": "OrderIntent",
  "decision_output_example": {
    "intent_id": "oi_01HXSPM0000001A",
    "trace_id": "tr_01HXSPM000TR001",
    "market_id": "0xsportsmd00000000000000000000000000000000000000000000000000000001",
    "outcome": "YES",
    "side": "buy",
    "price": "0.512",
    "size_pUSD": "220.00",
    "tif": "IOC",
    "post_only": false,
    "builder": {
      "code": "0x706f6c7974726164657273000000000000000000000000000000000000000000",
      "fee_bps": 25
    },
    "negrisk_aware": false,
    "decision": {
      "edge_bps": 245.0,
      "model_price": 0.537,
      "clob_mid": 0.512,
      "kelly_size_usd": 220.0,
      "sport": "NBA",
      "reasons": [
        "SPORTS_MODEL_EDGE_TRADE"
      ]
    },
    "comment": "fees are operator-set at match time in V2 \u2014 feeRateBps is NOT on the signed order"
  },
  "developer_log": {
    "bot_id": "strat.sports_model",
    "market_id": "0xsportsmd00000000000000000000000000000000000000000000000000000001",
    "model_price": 0.537,
    "clob_mid": 0.512,
    "edge_bps": 245.0,
    "kelly_fraction": 0.1,
    "kelly_size_usd": 220.0,
    "max_per_bet_usd": 500,
    "sport": "NBA",
    "lineup_last_updated_min": 8.2,
    "session_drawdown_bps": 120.0,
    "intent_emitted": true,
    "reason": "SPORTS_MODEL_EDGE_TRADE",
    "emitted_at_ms": 1746790800000
  },
  "user_explanations": [
    {
      "situation": "Sports model trade placed",
      "message": "The quantitative model estimated a different probability than the current market price. An order was placed to trade the difference using conservative Kelly sizing."
    },
    {
      "situation": "No edge \u2014 no trade",
      "message": "The model's estimate and the market price were too close after fees to justify a trade. No order was placed."
    },
    {
      "situation": "Stale model data \u2014 no trade",
      "message": "The lineup, injury, or league data powering the model was not current enough. No order was placed until the data is refreshed."
    },
    {
      "situation": "Drawdown guard active \u2014 paused",
      "message": "The session's mark-to-model losses reached the configured limit. New trades are paused for the remainder of the session."
    }
  ],
  "failure_modes": {
    "main_failure_mode": "Systematic model miscalibration: the power-rating model consistently misprices a sport or event type, producing repeated losses across multiple markets until the drawdown guard triggers.",
    "false_positive_risk": "Stale lineup or injury data produces incorrect model prices, triggering trades in the wrong direction shortly before the correct data arrives and the CLOB reprices.",
    "false_negative_risk": "min_edge_bps_vs_model set too high misses genuine model edges on liquid sports markets, particularly when the fee rate is low (sports: 0.75%) and the edge is real.",
    "safe_fallback": "If ws_market feed is stale (> 5s) or SportsFeed-Adapter data is unavailable, emit STALE_MARKET_DATA and skip without emitting any OrderIntent. Drawdown guard provides session-level risk control.",
    "required_dependencies": [
      "ws_market book stream",
      "ws_sports live game state (for in-play markets)",
      "clob_public market endpoint (status + depth)",
      "Internal power-rating model engine",
      "SportsFeed-Adapter (league APIs, lineup/injury/weather)",
      "KillSwitch active flag",
      "internal builder code"
    ]
  },
  "acceptance_tests": {
    "unit": [
      {
        "test": "Emit IOC when edge_bps=245, model_price=0.537, clob_mid=0.512, data fresh",
        "setup": "min_edge_bps_vs_model=200, kelly_fraction=0.10, max_per_bet_usd=500",
        "expected": "IOC OrderIntent emitted; DecisionReport intent_emitted=true, reason=SPORTS_MODEL_EDGE_TRADE"
      },
      {
        "test": "Skip when edge_bps=30 (< hard floor 50)",
        "setup": "model_price=0.503, clob_mid=0.500",
        "expected": "No OrderIntent; sampled DecisionReport reason=SPORTS_MODEL_NO_EDGE"
      },
      {
        "test": "Skip when drawdown guard triggered",
        "setup": "session_drawdown_bps=1300 (> hard 1200)",
        "expected": "No OrderIntent; reason=SPORTS_MODEL_DRAWDOWN_GUARD_TRIGGERED"
      },
      {
        "test": "Reduce size 50% when edge marginal (120 bps)",
        "setup": "edge_bps=120, min_edge_bps_vs_model=200",
        "expected": "OrderIntent emitted at 50% kelly size; WARN SPORTS_MODEL_EDGE_MARGINAL"
      },
      {
        "test": "Skip when SportsFeed data stale (lineup_last_updated > 30 min)",
        "setup": "lineup_last_updated_min=45",
        "expected": "No OrderIntent; reason=SPORTS_MODEL_STALE_DATA"
      },
      {
        "test": "Skip when KillSwitch active",
        "setup": "killswitch.active=true",
        "expected": "No OrderIntents emitted"
      }
    ],
    "integration": [
      {
        "test": "Full cycle: model update \u2192 edge computed \u2192 IOC OrderIntent submitted on Polygon testnet",
        "expected": "Order has builder.code (bytes32), no feeRateBps, tif=IOC, EIP-712 domain version '2'"
      },
      {
        "test": "Drawdown guard accumulates across multiple trades and triggers at hard limit",
        "expected": "SPORTS_MODEL_DRAWDOWN_GUARD_TRIGGERED after session_drawdown_bps reaches 1200"
      }
    ],
    "property": [
      {
        "property": "Kelly-derived size is always bounded by max_per_bet_usd",
        "required": "Always true"
      },
      {
        "property": "feeRateBps never present on any signed OrderIntent",
        "required": "Always true \u2014 V2 fees are operator-set at match time"
      },
      {
        "property": "Bot never trades when SportsFeed-Adapter data is unavailable or stale > 30 min",
        "required": "Always true"
      }
    ]
  },
  "checklist_overrides": {},
  "legacy_goal": "Quantitative fair-value engine for sports markets; trade where Polymarket diverges from model price.",
  "legacy_pm_signals": [
    "Polymarket sports market mid & depth",
    "In-play market state (where applicable)"
  ],
  "legacy_external_feeds": [
    "League APIs (NBA, NFL, EPL, ATP/WTA, MLB)",
    "Lineup / injury / weather data via SportsFeed-Adapter",
    "Internal power-rating model"
  ],
  "reporting_groups": [
    "strategy_decision"
  ],
  "network": [
    "polygon"
  ],
  "api_surface": [
    "clob_public",
    "clob_auth",
    "ws_market",
    "ws_sports",
    "internal"
  ],
  "version": {
    "spec": "2.0.0",
    "implementation": "2.1.0",
    "schema": "2",
    "released": "2026-04-28"
  },
  "migration_history": [
    {
      "date": "2026-04-28",
      "from": "v1 (USDC.e, feeRateBps on signed order, auto_disable_neg_sharpe_weeks)",
      "to": "v2 (pUSD, fees operator-set at match time, no performance-claim parameters)",
      "reason": "CLOB V2 cutover + policy update",
      "action_taken": "Switched to py-clob-client-v2. Removed feeRateBps from all signed order construction. Updated collateral denomination to pUSD. Injected builder field (bytes32). EIP-712 Exchange domain version updated from '1' to '2'. Removed auto_disable_neg_sharpe_weeks parameter (performance claim; replaced with drawdown_guard_bps). SportsFeed-Adapter updated to V2 internal bus schema. ws_sports WebSocket added for live in-play market state."
    }
  ],
  "polymarket_v2_compat": {
    "clob_version": "v2",
    "collateral": "pUSD",
    "eip712_domain_version": "2",
    "builder_code_aware": true,
    "negrisk_aware": false,
    "multichain_ready": false,
    "sdk_used": "py-clob-client-v2",
    "settlement_contract": "CTFExchangeV2",
    "notes": "Executes IOC taker orders on Polymarket sports markets where model price diverges from CLOB mid by more than min_edge_bps_vs_model. Maker rebate pool (Apr 2026: $5M pUSD sports+esports) incentivises maker orders when feasible. feeRateBps is not present on any signed order."
  },
  "reference_implementation": {
    "summary": "Consumes model price updates from the internal power-rating engine, computes edge against CLOB mid-price, and emits IOC OrderIntents when edge exceeds min_edge_bps_vs_model using fractional-Kelly sizing bounded by max_per_bet_usd.",
    "language_note": "Pseudocode is language-agnostic. FETCH = read input. EMIT = produce output. Translate to TS/Python/Go/Rust.",
    "pseudocode": "FUNCTION onModelUpdate(market_id, modelUpdate):\n  // --- 0. KillSwitch gate ---\n  ks = FETCH internal.killswitch.status\n  IF ks.active: RETURN\n\n  // --- 1. Drawdown guard ---\n  sessionDrawdown = FETCH state.sessionDrawdownBps()\n  IF sessionDrawdown >= params.drawdown_guard_bps_hard:  // 1200 bps\n    EMIT DecisionReport(intent_emitted=false, reason='SPORTS_MODEL_DRAWDOWN_GUARD_TRIGGERED')\n    RETURN\n\n  // --- 2. Data freshness check ---\n  sportsFeed = FETCH internal.sportsfeed.marketData(market_id)\n  lineupAge = (now_ms() - sportsFeed.lineup_last_updated_ms) / 60000\n  IF lineupAge > 30:\n    EMIT DecisionReport(intent_emitted=false, reason='SPORTS_MODEL_STALE_DATA')\n    RETURN\n\n  // --- 3. In-play state check ---\n  IF market_id.is_inplay:\n    gameState = FETCH ws_sports.liveState(market_id)\n    IF isStale(gameState, maxAgeS=5): RETURN\n    IF gameState.halted: RETURN\n\n  // --- 4. Market status check ---\n  mkt = FETCH clob_public.GET('/markets/' + market_id)\n  IF mkt.closed OR mkt.resolved OR mkt.minutes_to_close < 15: RETURN\n\n  // --- 5. Edge computation ---\n  book = FETCH ws_market.book(market_id)\n  clobMid = (book.best_bid + book.best_ask) / 2\n  modelPrice = modelUpdate.model_price\n  edgeBps = abs(modelPrice - clobMid) * 10000\n\n  // --- 6. Hard floor check ---\n  IF edgeBps < params.min_edge_bps_vs_model_hard:  // 50 bps\n    IF random() < 0.01:\n      EMIT DecisionReport(intent_emitted=false, reason='SPORTS_MODEL_NO_EDGE', edge_bps=edgeBps)\n    RETURN\n\n  // --- 7. Kelly sizing ---\n  kellyMultiplier = 0.5 IF edgeBps < params.min_edge_bps_vs_model ELSE 1.0\n  IF kellyMultiplier < 1.0: WARN('SPORTS_MODEL_EDGE_MARGINAL')\n  IF sessionDrawdown > params.drawdown_guard_bps: kellyMultiplier *= 0.5  // progressive reduction\n  bankroll = FETCH internal.accountState.bankroll_usd\n  kellySizeUSD = params.kelly_fraction * bankroll * edgeBps / (modelPrice * (1 - modelPrice) * 10000)\n  depth = FETCH clob_public.depth(market_id)\n  orderSize = toPusdUnits(min(kellySizeUSD, params.max_per_bet_usd, depth) * kellyMultiplier)\n\n  // --- 8. Direction ---\n  outcome = 'YES' IF modelPrice > clobMid ELSE 'NO'\n  price = book.best_ask IF outcome == 'YES' ELSE book.best_ask_NO\n\n  // --- 9. Emit IOC OrderIntent (V2: no feeRateBps) ---\n  EMIT OrderIntent(\n    market_id = market_id, outcome = outcome, side = 'buy',\n    price = price, size_pUSD = orderSize, tif = 'IOC', post_only = false,\n    builder = {code: internal.builder_code, fee_bps: 25}\n    // sports maker rebate pool eligible when fee_bps applied to resting side\n  )\n\n  // --- 10. Update session drawdown tracking ---\n  UPDATE state.sessionDrawdownBps(market_id, orderSize, modelPrice, price)\n\n  EMIT DecisionReport(intent_emitted=true, edge_bps=edgeBps,\n                      model_price=modelPrice, clob_mid=clobMid,\n                      kelly_size_usd=kellySizeUSD, reason='SPORTS_MODEL_EDGE_TRADE')",
    "sdk_calls": [
      "ws_market.subscribe('book', [market_id])",
      "ws_sports.subscribe('live_state', [market_id])",
      "fetchClobPublic('/markets/' + market_id)",
      "internal.sportsfeed.marketData(market_id)",
      "internal.killswitch.status()",
      "internal.accountState.bankroll_usd()",
      "toPusdUnits(rawFloat)",
      "buildOrderTypedData(orderParams, { name: 'CTFExchange', version: '2', chainId: 137 })",
      "internal.builder_code"
    ],
    "complexity": "O(1) per model update or book tick per market"
  },
  "wire_examples": {
    "input": [
      {
        "label": "Model update \u2014 NBA market, edge=245 bps",
        "source": "internal (model engine)",
        "payload": {
          "market_id": "0xsportsmd00000000000000000000000000000000000000000000000000000001",
          "model_price": "0.537",
          "clob_mid": "0.512",
          "edge_bps": "245.0",
          "sport": "NBA",
          "lineup_last_updated_min": "8.2",
          "is_inplay": false,
          "received_at_ms": 1746790800000
        }
      }
    ],
    "output": [
      {
        "label": "OrderIntent \u2014 sports model IOC buy YES (builder-attributed)",
        "payload": {
          "intent_id": "oi_01HXSPM0000001A",
          "trace_id": "tr_01HXSPM000TR001",
          "market_id": "0xsportsmd00000000000000000000000000000000000000000000000000000001",
          "outcome": "YES",
          "side": "buy",
          "price": "0.512",
          "size_pUSD": "220.00",
          "tif": "IOC",
          "post_only": false,
          "builder": {
            "code": "0x706f6c7974726164657273000000000000000000000000000000000000000000",
            "fee_bps": 25
          },
          "negrisk_aware": false,
          "decision": {
            "edge_bps": 245.0,
            "model_price": 0.537,
            "clob_mid": 0.512,
            "kelly_size_usd": 220.0,
            "sport": "NBA",
            "reasons": [
              "SPORTS_MODEL_EDGE_TRADE"
            ]
          },
          "comment": "fees are operator-set at match time in V2 \u2014 feeRateBps is NOT on the signed order"
        }
      },
      {
        "label": "DecisionReport \u2014 skipped (no edge), sampled 1/100",
        "payload": {
          "report_id": "dr_01HXSPM999ZZZZ",
          "bot_id": "strat.sports_model",
          "market_id": "0xsportsmd00000000000000000000000000000000000000000000000000000001",
          "intent_emitted": false,
          "edge_bps": 22.0,
          "reasons": [
            "SPORTS_MODEL_NO_EDGE"
          ],
          "sampled": true,
          "evaluated_at_ms": 1746790801000
        }
      }
    ]
  },
  "reason_codes": [
    {
      "code": "SPORTS_MODEL_EDGE_TRADE",
      "severity": "INFO",
      "meaning": "edge_bps >= min_edge_bps_vs_model, data fresh, drawdown guard clear. IOC OrderIntent emitted.",
      "action": "Emit IOC OrderIntent; update session drawdown tracking.",
      "user_message": "The model found a pricing difference and placed a trade to capture it."
    },
    {
      "code": "SPORTS_MODEL_NO_EDGE",
      "severity": "INFO",
      "meaning": "edge_bps is below the 50 bps hard floor. Model and CLOB prices are sufficiently aligned.",
      "action": "Skip; emit sampled DecisionReport.",
      "user_message": "The model's estimate was too close to the market price to justify a trade."
    },
    {
      "code": "SPORTS_MODEL_EDGE_MARGINAL",
      "severity": "WARN",
      "meaning": "edge_bps between 50 and 200 bps. Trade is marginal; Kelly size reduced by 50%.",
      "action": "Emit IOC at 50% Kelly size; log warning.",
      "user_message": "A small pricing difference was detected. A reduced-size trade was placed."
    },
    {
      "code": "SPORTS_MODEL_STALE_DATA",
      "severity": "HARD_REJECT",
      "meaning": "SportsFeed-Adapter lineup/injury data is more than 30 minutes old. Model may be miscalibrated.",
      "action": "Skip; no OrderIntent emitted.",
      "user_message": "The sports data powering the model was not current. No trade was placed."
    },
    {
      "code": "SPORTS_MODEL_DRAWDOWN_GUARD_TRIGGERED",
      "severity": "HARD_REJECT",
      "meaning": "Session mark-to-model drawdown has exceeded drawdown_guard_bps hard limit (1200 bps). All new entries paused.",
      "action": "Skip all markets; emit DecisionReport.",
      "user_message": "Trading was paused because session losses reached the configured limit."
    },
    {
      "code": "SPORTS_MODEL_DRAWDOWN_WARNING",
      "severity": "WARN",
      "meaning": "Session drawdown between 500 and 1200 bps. Kelly fraction reduced to 50%.",
      "action": "Continue trading at 50% Kelly size; log warning.",
      "user_message": "Session losses are elevated. Trade sizes were reduced."
    },
    {
      "code": "SPORTS_MODEL_HIGH_KELLY",
      "severity": "WARN",
      "meaning": "kelly_fraction config is above the 0.20 warning threshold. Per-trade position size is elevated.",
      "action": "Allow but log warning.",
      "user_message": ""
    },
    {
      "code": "STALE_MARKET_DATA",
      "severity": "HARD_REJECT",
      "meaning": "ws_market or ws_sports feed is stale (> 5s).",
      "action": "Skip; no OrderIntent emitted.",
      "user_message": "Market data was too old to act on safely."
    },
    {
      "code": "KILL_SWITCH_ACTIVE",
      "severity": "HARD_REJECT",
      "meaning": "Global kill switch is active.",
      "action": "Skip all markets; no OrderIntents emitted.",
      "user_message": "Trading is currently paused."
    },
    {
      "code": "PARAMETER_CHANGE_REQUIRES_APPROVAL",
      "severity": "HARD_REJECT",
      "meaning": "A config change would push a parameter past its locked hard limit.",
      "action": "Reject config change; do not apply.",
      "user_message": ""
    }
  ],
  "metrics": {
    "emitted": [
      {
        "name": "polytraders_strat_sportsmodel_decisions_total",
        "type": "counter",
        "unit": "count",
        "labels": [
          "verdict",
          "reason_code",
          "sport"
        ],
        "meaning": "Total evaluation cycles by intent_emitted, reason code, and sport."
      },
      {
        "name": "polytraders_strat_sportsmodel_edge_bps",
        "type": "histogram",
        "unit": "basis_points",
        "labels": [
          "sport"
        ],
        "meaning": "Distribution of model-vs-CLOB edge in bps per sport."
      },
      {
        "name": "polytraders_strat_sportsmodel_kelly_size_usd",
        "type": "histogram",
        "unit": "pusd",
        "labels": [
          "sport"
        ],
        "meaning": "Distribution of Kelly-derived order sizes per sport."
      },
      {
        "name": "polytraders_strat_sportsmodel_session_drawdown_bps",
        "type": "gauge",
        "unit": "basis_points",
        "labels": [],
        "meaning": "Current session mark-to-model drawdown in bps."
      },
      {
        "name": "polytraders_strat_sportsmodel_intents_emitted_total",
        "type": "counter",
        "unit": "count",
        "labels": [
          "sport",
          "outcome"
        ],
        "meaning": "Total OrderIntents emitted by sport and outcome (YES/NO)."
      },
      {
        "name": "polytraders_strat_sportsmodel_eval_latency_ms",
        "type": "histogram",
        "unit": "milliseconds",
        "labels": [],
        "meaning": "Wall-clock time from model update to OrderIntent emit."
      }
    ],
    "alerts": [
      {
        "name": "SportsModelDrawdownWarning",
        "condition": "polytraders_strat_sportsmodel_session_drawdown_bps > 500",
        "severity": "warn",
        "runbook": "#runbook-sportsmodel-drawdown"
      },
      {
        "name": "SportsModelDrawdownGuardTriggered",
        "condition": "polytraders_strat_sportsmodel_session_drawdown_bps > 1200",
        "severity": "page",
        "runbook": "#runbook-sportsmodel-drawdown-guard"
      },
      {
        "name": "SportsModelStaleFeed",
        "condition": "rate(polytraders_strat_sportsmodel_decisions_total{reason_code='STALE_MARKET_DATA'}[5m]) > 0.1",
        "severity": "warn",
        "runbook": "#runbook-sportsmodel-stale-feed"
      },
      {
        "name": "SportsModelKillSwitchBlocking",
        "condition": "rate(polytraders_strat_sportsmodel_decisions_total{reason_code='KILL_SWITCH_ACTIVE'}[1m]) > 0",
        "severity": "page",
        "runbook": "#runbook-killswitch"
      }
    ],
    "dashboards": [
      "Grafana \u2014 Strategy / SportsModel edge distribution per sport",
      "Grafana \u2014 Strategy / SportsModel session drawdown and Kelly sizing"
    ],
    "log_level": "info"
  },
  "state": {
    "store": "redis",
    "shape": "Session drawdown state (total_mark_to_model_pnl_bps); per-market last model price and book tick; keyed by session_id and market_id",
    "ttl": "session-scoped (reset at midnight UTC); per-market state 60s",
    "recovery": "On cold start, session drawdown resets to 0. Market state rebuilt from first ws_market tick and model update.",
    "size_estimate": "~500 bytes for session state; ~200 bytes per tracked sports market; typically < 2 MB total"
  },
  "concurrency": {
    "execution_model": "actor-per-market",
    "max_in_flight": 40,
    "idempotency_key": "intent_id",
    "timeout_ms": 250,
    "backpressure": "drop oldest pending model update per market_id when queue depth > 3",
    "locking": "per-market_id mutex for position state; global mutex for session drawdown update"
  },
  "dependencies": {
    "depends_on": [
      {
        "bot_id": "risk.kill_switch",
        "why": "Checked first; blocks all intent emission when active."
      }
    ],
    "emits_to": [
      {
        "bot_id": "risk.portfolio_guard",
        "what": "IOC OrderIntents for risk guardrail evaluation before SmartRouter."
      },
      {
        "bot_id": "gov.builder_attribution",
        "what": "builder.code bytes32 on every OrderIntent; sports maker rebate tracking."
      }
    ],
    "sibling": [],
    "external": [
      {
        "service": "Polymarket CLOB WebSocket (ws_market)",
        "sla": "best-effort",
        "fallback": "On disconnect, halt new entries; no new OrderIntents until feed recovers."
      },
      {
        "service": "Polymarket Sports WebSocket (ws_sports)",
        "sla": "best-effort",
        "fallback": "For in-play markets, treat as halted (skip entry) if ws_sports gap > 5s."
      },
      {
        "service": "SportsFeed-Adapter (league APIs + lineup/injury/weather)",
        "sla": "internal SLA",
        "fallback": "If data older than 30 min, emit SPORTS_MODEL_STALE_DATA and skip all markets relying on that feed."
      },
      {
        "service": "Polymarket CLOB public API (depth)",
        "sla": "99.9%",
        "fallback": "Use cached depth; if cache > 10s, skip entry."
      }
    ]
  },
  "security_surfaces": {
    "signs_orders": true,
    "private_key_access": "signing-only",
    "abuse_vectors": [
      "Adversary manipulates SportsFeed-Adapter data to produce false model prices, triggering trades in the wrong direction",
      "In-play market state spoofing via ws_sports injection to trigger model recalculation",
      "Front-running: adversary infers model price by monitoring bot's IOC orders on specific markets"
    ],
    "mitigations": [
      "SportsFeed-Adapter data is authenticated from official league API sources; injection requires compromising the internal feed",
      "ws_sports data validated against clob_public market metadata before use in model",
      "IOC orders do not rest on the book; adversary cannot exploit known bid/ask placement",
      "Drawdown guard limits total session exposure from systematic model attacks",
      "V2 order timestamp(ms) invalidates replayed signed orders"
    ],
    "contract_calls": [
      {
        "contract": "CTFExchangeV2",
        "function": "matchOrders",
        "purpose": "Settlement of YES/NO sports token purchases triggered by model-price divergence."
      }
    ]
  },
  "failure_injection": [
    {
      "scenario": "STALE_SPORTSFEED_DATA",
      "how_to_inject": "Freeze SportsFeed-Adapter updates; let lineup_last_updated age beyond 30 min",
      "expected_behaviour": "SPORTS_MODEL_STALE_DATA; no OrderIntents for all markets relying on that feed",
      "recovery": "Automatic when SportsFeed-Adapter data refreshes."
    },
    {
      "scenario": "DRAWDOWN_GUARD_TRIGGER",
      "how_to_inject": "Inject sequence of losing trades until session_drawdown_bps reaches 1200",
      "expected_behaviour": "SPORTS_MODEL_DRAWDOWN_GUARD_TRIGGERED; all new entries halted; ops page fired",
      "recovery": "Manual session reset required after ops review."
    },
    {
      "scenario": "INPLAY_FEED_STALE",
      "how_to_inject": "Pause ws_sports for in-play market; let game state age beyond 5s",
      "expected_behaviour": "In-play market treated as halted; no new entries; DecisionReport emitted",
      "recovery": "Automatic when ws_sports reconnects."
    },
    {
      "scenario": "NO_EDGE_MARKET",
      "how_to_inject": "Set mock model_price=0.505, clob_mid=0.503 (edge=20 bps < 50 hard)",
      "expected_behaviour": "SPORTS_MODEL_NO_EDGE; no OrderIntent; sampled DecisionReport",
      "recovery": "Automatic when model or CLOB price diverges sufficiently."
    },
    {
      "scenario": "KILL_SWITCH_ON",
      "how_to_inject": "Set killswitch.active=true",
      "expected_behaviour": "No OrderIntents emitted for any market",
      "recovery": "Automatic on manual KillSwitch reset."
    }
  ],
  "runbook": {
    "summary": "Sports Model incidents are typically drawdown guard triggers (requiring manual session review), stale SportsFeed data (blocking all trades until feed refreshes), or stale in-play feeds. Drawdown guard triggers require ops review before session reset.",
    "oncall_actions": [
      {
        "alert": "SportsModelDrawdownWarning",
        "first_action": "Review session_drawdown_bps on Grafana. Check which markets are contributing to the drawdown.",
        "escalate_to": "Strategy pod lead if drawdown > 800 bps and increasing."
      },
      {
        "alert": "SportsModelDrawdownGuardTriggered",
        "first_action": "Halt all new entries (automatic). Review session trade history to identify if drawdown is from model miscalibration or genuine adverse market conditions.",
        "escalate_to": "Risk pod lead immediately; session reset requires explicit approval."
      },
      {
        "alert": "SportsModelStaleFeed",
        "first_action": "Check SportsFeed-Adapter connectivity and league API health.",
        "escalate_to": "Infra on-call if SportsFeed-Adapter lag > 30 min."
      },
      {
        "alert": "SportsModelKillSwitchBlocking",
        "first_action": "Confirm KillSwitch activation was intentional.",
        "escalate_to": "Risk pod lead immediately."
      }
    ],
    "manual_overrides": [
      {
        "name": "exclude_market",
        "how": "Add market_id to config.excluded_markets",
        "when": "Market is showing anomalous in-play conditions or upcoming corporate event that invalidates power-rating model."
      },
      {
        "name": "reset_session_drawdown",
        "how": "polytraders bot reset-drawdown strat.sports_model --session",
        "when": "After ops review confirms drawdown was from temporary market anomaly, not model miscalibration. Requires explicit approval."
      }
    ],
    "healthcheck": "GET /internal/health/sports-model -> 200 if ws_market feed last_seen < 5s, SportsFeed-Adapter data age < 30 min, session drawdown < 500 bps, KillSwitch inactive."
  },
  "promotion_gates": {
    "to_shadow": [
      {
        "gate": "All unit tests pass including drawdown guard trigger and stale-data block",
        "how_measured": "CI test run",
        "threshold": "100% pass"
      },
      {
        "gate": "feeRateBps absence verified; tif=IOC and kelly sizing verified in integration test",
        "how_measured": "Integration test asserting V2 order schema",
        "threshold": "Pass"
      }
    ],
    "to_limited_live": [
      {
        "gate": "p99 eval latency < 250ms over 24h",
        "how_measured": "polytraders_strat_sportsmodel_eval_latency_ms histogram",
        "threshold": "p99 < 250ms"
      },
      {
        "gate": "Session drawdown stays below 500 bps over 48h shadow run",
        "how_measured": "polytraders_strat_sportsmodel_session_drawdown_bps gauge",
        "threshold": "< 500 bps"
      }
    ],
    "to_general_live": [
      {
        "gate": "E2E: model update \u2192 edge computed \u2192 IOC OrderIntent submitted on Polygon testnet",
        "how_measured": "E2E test",
        "threshold": "Pass"
      },
      {
        "gate": "Drawdown guard verified: session halted at 1200 bps in integration test",
        "how_measured": "Integration test",
        "threshold": "Pass"
      }
    ]
  },
  "reporting": {
    "emits_kinds": [
      "DecisionReport"
    ],
    "topics": [
      "polytraders.reports.decision"
    ],
    "cadence": "every-event",
    "retention_class": "2y",
    "sampling_rule": "emit-every for emitted intents; sample-1/100 for no-edge skips",
    "bus_failure_action": "fail-closed",
    "user_visible": "summary-only",
    "consumes_kinds": [
      "ObservationReport",
      "RiskVote"
    ],
    "reporting_groups": [
      "strategy_decision"
    ]
  },
  "capital_impact": "Direct",
  "v3_status": {
    "phase": 8,
    "phase_name": "Additional strategies",
    "docs": {
      "done": 27,
      "total": 27,
      "state": "done"
    },
    "impl": {
      "done": 0,
      "total": 15,
      "state": "pending"
    },
    "runtime": {
      "done": 0,
      "total": 8,
      "state": "pending"
    },
    "overall": "pending"
  }
}