{
  "schema_version": "1.0.0",
  "bot_id": "1.17",
  "bot_name": "MarketHaltDetector",
  "slug": "market_halt_detector",
  "layer": "Risk",
  "layer_key": "risk",
  "bot_class": "Guardrail",
  "authority": [
    "Pause",
    "Reject"
  ],
  "status": "planned",
  "readiness": "Spec ready",
  "flagship": false,
  "is_reference": false,
  "public_export": false,
  "identity": {
    "layer": "Risk",
    "bot_class": "Guardrail",
    "authority": "Pause, Reject",
    "runs_before": "risk.killswitch, exec.smart_router",
    "runs_after": "intel.orderflowanalyzer",
    "applies_to": "Continuous",
    "default_mode": "shadow",
    "user_visible": "Yes",
    "developer_owner": "Risk pod"
  },
  "purpose": "Watches for market-level halt conditions across Polymarket \u2014 wide-spread blowouts, missing best bid/ask, locked or crossed books, and sudden order-rate collapse. When any halt condition fires, MarketHaltDetector quarantines the affected market_id so no new OrderIntent for that market can pass the Risk pipeline. It does not pause the whole system \u2014 only the affected market. Cleared automatically once conditions normalise for a configurable cool-off window, or manually via the Admin UI.",
  "why_it_matters": [
    {
      "failure": "Submitting orders into a halted book",
      "consequence": "Orders sent into a market with no two-sided liquidity end up either rejected by the CLOB or filled at runaway prices once the book reopens.",
      "worked_example": {
        "setup": "Polymarket's status page reports `markets.matching` as degraded at 21:18 UTC. CLOB V2 stops accepting new orders but the data feed still streams stale book snapshots for 90 seconds.",
        "without_bot": "Strategies read the stale snapshots as live state and post OrderIntents. Every order is rejected with platform errors. By the time the team correlates the failures, the strategies have already sent 240 rejected requests, breaching the per-minute submission budget and triggering a separate rate-limit halt.",
        "with_bot": "MarketHaltDetector reads the status page + recent reject patterns, declares `markets.matching=halted` at 21:18:14, and votes REJECT on every new OrderIntent until both the status page and the next 30s of submissions confirm a recovered state."
      }
    },
    {
      "failure": "Treating wide-spread events as normal",
      "consequence": "A market with a 30%+ inside spread is structurally untradeable; trading it anyway invites massive slippage and unfair fills."
    },
    {
      "failure": "Manual halt management",
      "consequence": "Without automated detection, ops staff must monitor every market by hand \u2014 which does not scale past a handful of markets."
    }
  ],
  "polymarket_inputs": [
    {
      "input": "Best bid/ask per market_id",
      "source": "WebSocket",
      "required": true,
      "use": "Detect missing or one-sided book; compute live spread."
    },
    {
      "input": "Last-trade timestamp per market_id",
      "source": "CLOB",
      "required": true,
      "use": "Detect sudden trade-rate collapse (no trades for trades_silent_ms with non-empty book)."
    }
  ],
  "internal_inputs": [
    {
      "input": "Recent OrderBookSnapshot history",
      "source": "intel.orderflowanalyzer",
      "required": true,
      "use": "Smoothed spread and depth context for halt-vs-noise classification."
    },
    {
      "input": "Active strategy \u2192 market_id map",
      "source": "OrderLifecycleManager",
      "required": false,
      "use": "Decide which strategies to notify when a halt fires."
    }
  ],
  "raw_params": [
    "halt_spread_pct \u00b7 0\u2013100",
    "trades_silent_ms \u00b7 1000\u2013600000",
    "cooloff_ms \u00b7 1000\u2013600000",
    "min_depth_usd \u00b7 0\u2013100000"
  ],
  "parameters": [
    {
      "name": "halt_spread_pct",
      "default": 30,
      "warning": 15,
      "hard": 30,
      "controls": "Spread percentage at which the market is considered halted.",
      "why_default_matters": "30% inside spread is well outside any tradeable regime on Polymarket binary markets.",
      "threshold_logic": [
        {
          "condition": "\u2264 15%",
          "action": "No action"
        },
        {
          "condition": "15\u201330%",
          "action": "WARN \u2014 log only"
        },
        {
          "condition": "> 30%",
          "action": "QUARANTINE market_id"
        }
      ],
      "dev_check": "if (spreadPct(book) > p.halt_spread_pct) halt.activate(marketId, 'WIDE_SPREAD');",
      "user_facing": "Trading was paused on this market because the price gap got too wide for safe orders."
    },
    {
      "name": "trades_silent_ms",
      "default": 60000,
      "warning": 30000,
      "hard": 60000,
      "controls": "Maximum allowed silent window with no recorded trades while the book is non-empty.",
      "why_default_matters": "60 seconds with a populated book and no prints is a strong signal of stale or stuck data.",
      "threshold_logic": [
        {
          "condition": "\u2264 30s",
          "action": "No action"
        },
        {
          "condition": "30\u201360s",
          "action": "WARN"
        },
        {
          "condition": "> 60s",
          "action": "QUARANTINE market_id"
        }
      ],
      "dev_check": "if (now - lastTradeMs > p.trades_silent_ms && bookHasLiquidity()) halt.activate(marketId, 'TRADE_SILENCE');",
      "user_facing": "Trading was paused on this market because no trades have happened for a long time."
    },
    {
      "name": "cooloff_ms",
      "default": 120000,
      "warning": "\u2014",
      "hard": "\u2014",
      "controls": "Cool-off window the market must remain healthy before the halt is auto-cleared.",
      "why_default_matters": "Two minutes of healthy book + recent trades is a safe minimum before resuming.",
      "threshold_logic": [
        {
          "condition": "Healthy < 120s",
          "action": "Stay quarantined"
        },
        {
          "condition": "Healthy \u2265 120s",
          "action": "Auto-clear halt"
        }
      ],
      "dev_check": "if (healthyFor(marketId) > p.cooloff_ms) halt.clear(marketId);",
      "user_facing": "Trading on this market resumed automatically after conditions stabilised."
    },
    {
      "name": "min_depth_usd",
      "default": 250,
      "warning": 100,
      "hard": 250,
      "controls": "Minimum aggregate top-of-book depth (USD) below which the book is considered too thin.",
      "why_default_matters": "Below $250 of top-of-book depth on a binary market, even small orders move the price meaningfully.",
      "threshold_logic": [
        {
          "condition": "> $250",
          "action": "OK"
        },
        {
          "condition": "$100\u2013$250",
          "action": "WARN"
        },
        {
          "condition": "< $100",
          "action": "QUARANTINE market_id"
        }
      ],
      "dev_check": "if (topDepthUsd(book) < p.min_depth_usd) halt.activate(marketId, 'THIN_BOOK');",
      "user_facing": "Trading was paused on this market because there was not enough money on the book to fill orders safely."
    }
  ],
  "default_config": {
    "halt_spread_pct": 30,
    "trades_silent_ms": 60000,
    "cooloff_ms": 120000,
    "min_depth_usd": 250
  },
  "flow": "Subscribe to per-market book + trade streams \u2192 compute spread, last-trade gap, top depth \u2192 if any halt rule fires, emit RiskVote(REJECT, reason_code=RISK_MARKET_HALT) for every OrderIntent on that market_id and post an OperationsReport. Once cool-off passes with all rules clean, clear the halt and post a recovery OperationsReport.",
  "decision_logic": {
    "approve": "On each OrderBookSnapshot tick \u2192 re-evaluate halt rules per market.",
    "reshape_required": "This bot does not reshape orders.",
    "reject": "On each OrderIntent for a quarantined market_id \u2192 REJECT with RISK_MARKET_HALT.",
    "warning_only": "Emit OperationsReport on halt activation, halt clearance, and threshold WARNs."
  },
  "decision_output_example": {
    "vote": "REJECT",
    "market_id": "0xabc",
    "reason_code": "RISK_MARKET_HALT",
    "explain": "Halted: inside spread 41% > 30% threshold for 18s.",
    "ts_ms": 1715260000000
  },
  "developer_log": "Per halt activation: market_id, rule (WIDE_SPREAD/TRADE_SILENCE/THIN_BOOK), measured value, threshold, ts_ms, expected cooloff_until_ms.",
  "user_explanations": [
    {
      "situation": "When this bot acts",
      "message": "Trading was paused on this market because conditions made it unsafe to place orders."
    },
    {
      "situation": "When this bot acts",
      "message": "Trading on this market will resume automatically once the market behaves normally again."
    }
  ],
  "failure_modes": {
    "main_failure_mode": "False quarantine on transient feed glitches that recover within seconds.",
    "false_positive_risk": "Brief WebSocket reconnects can mimic a wide spread; mitigation: require condition to hold for a minimum sustained window before halting.",
    "false_negative_risk": "A market that quietly stops printing but keeps a tight book may not trip any rule; mitigation: cross-check with Polymarket public last-trade endpoint on a slow timer.",
    "safe_fallback": "On internal failure, halt every market this bot is responsible for \u2014 fail closed, never fail open."
  },
  "acceptance_tests": {
    "unit": [
      {
        "test": "Spread computation matches expected for synthetic books.",
        "setup": "Synthetic fixture per template.",
        "expected": "Behaviour matches the rule described in the test name."
      },
      {
        "test": "Cool-off counter resets on any rule re-trip.",
        "setup": "Synthetic fixture per template.",
        "expected": "Behaviour matches the rule described in the test name."
      }
    ],
    "integration": [
      {
        "test": "Halt activates and clears against a recorded CLOB feed with an injected 90-second silent window.",
        "expected": "End-to-end behaviour matches the spec without manual intervention."
      }
    ],
    "property": [
      {
        "property": "For any sequence of book ticks, halt-active state is monotonic per market until cool-off completes.",
        "required": "Always true across all generated inputs."
      }
    ]
  },
  "reference_implementation": {
    "language": "pseudocode",
    "pseudocode": "for each book_tick:\n  if spread_pct(book) > p.halt_spread_pct: halt(market_id, 'WIDE_SPREAD'); continue\n  if now - last_trade[market_id] > p.trades_silent_ms and depth_usd(book) > 0: halt(market_id, 'TRADE_SILENCE'); continue\n  if depth_usd_top(book) < p.min_depth_usd: halt(market_id, 'THIN_BOOK'); continue\n  if all_rules_clean_for(market_id, p.cooloff_ms): clear(market_id)\nfor each OrderIntent o:\n  if is_halted(o.market_id): emit RiskVote(REJECT, RISK_MARKET_HALT); else PASS"
  },
  "wire_examples": {
    "input": {
      "intent_id": "intent_001",
      "market_id": "0xabc",
      "side": "BUY",
      "size_usd": 100
    },
    "output": {
      "vote": "REJECT",
      "reason_code": "RISK_MARKET_HALT",
      "explain": "Halted: inside spread 41% > 30% threshold."
    }
  },
  "reason_codes": [
    {
      "code": "RISK_MARKET_HALT",
      "severity": "P1",
      "meaning": "Risk Market Halt",
      "action": "See decision output and developer log for context.",
      "user_message": "Trading was paused on this market because conditions made it unsafe to place orders."
    },
    {
      "code": "RISK_MARKET_HALT_WARN",
      "severity": "P1",
      "meaning": "Risk Market Halt Warn",
      "action": "See decision output and developer log for context.",
      "user_message": "Trading was paused on this market because conditions made it unsafe to place orders."
    },
    {
      "code": "RISK_MARKET_HALT_CLEARED",
      "severity": "P1",
      "meaning": "Risk Market Halt Cleared",
      "action": "See decision output and developer log for context.",
      "user_message": "Trading was paused on this market because conditions made it unsafe to place orders."
    }
  ],
  "metrics": {
    "emitted": [
      {
        "name": "halts_active_total",
        "type": "counter",
        "unit": "event",
        "labels": [
          "market_id",
          "reason_code"
        ],
        "meaning": "Halts active total."
      },
      {
        "name": "halt_activation_count",
        "type": "counter",
        "unit": "event",
        "labels": [
          "market_id",
          "reason_code"
        ],
        "meaning": "Halt activation count."
      },
      {
        "name": "halt_clear_count",
        "type": "counter",
        "unit": "event",
        "labels": [
          "market_id",
          "reason_code"
        ],
        "meaning": "Halt clear count."
      },
      {
        "name": "rejects_with_market_halt",
        "type": "counter",
        "unit": "event",
        "labels": [
          "market_id",
          "reason_code"
        ],
        "meaning": "Rejects with market halt."
      }
    ],
    "alerts": [],
    "dashboards": [
      "1.17 overview dashboard"
    ]
  },
  "state": {
    "summary": "Per market_id: { halted: bool, halted_since_ms, last_rule, healthy_since_ms }. Persisted in fast key-value store; survives restart.",
    "stores": [
      {
        "name": "market_halt_detector_state",
        "kind": "in-memory + fast KV mirror",
        "key": "market_id",
        "value": "Per market_id: { halted: bool, halted_since_ms, last_rule, healthy_since_ms }. Persisted in fast key-value store; surviv",
        "ttl": "24h",
        "durability": "crash-safe via KV mirror"
      }
    ],
    "recovery": "Cold-start hydrates from fast KV; missing keys default to safe fallback.",
    "on_restart": "All in-flight decisions are re-evaluated; no bot decision is trusted across restart without re-emit."
  },
  "concurrency": {
    "execution_model": "Single-writer per market_id. Idempotent: same OrderIntent re-evaluated returns same RiskVote while halt is active.",
    "max_in_flight": 32,
    "idempotency_key": "order_intent_id",
    "replay_safe": true,
    "deduplication": "By idempotency_key within a 60s window.",
    "ordering_guarantees": "Per-market_id FIFO; cross-market unordered.",
    "timeout_ms": 250,
    "backpressure": "Bounded queue; oldest-dropped with metric increment when full.",
    "locking": "Per-market_id mutex; no global locks."
  },
  "dependencies": {
    "depends_on": [
      "intel.orderflowanalyzer"
    ],
    "emits_to": [
      "risk.killswitch",
      "exec.smart_router"
    ]
  },
  "graph": {
    "requires": [
      "intel.orderflowanalyzer"
    ],
    "required_before": [
      "risk.killswitch",
      "exec.smart_router"
    ],
    "consumes": [
      "OrderBookSnapshot",
      "TradeTick"
    ],
    "emits": [
      "RiskVote",
      "OperationsReport"
    ],
    "blocks": true
  },
  "mode_support": [
    "off",
    "shadow",
    "advisory",
    "enforced",
    "quarantine"
  ],
  "latency_budget_ms": {
    "p50": 5,
    "p99": 20
  },
  "data_freshness": {
    "max_market_data_age_ms": 1500,
    "max_orderbook_age_ms": 1500,
    "on_stale_data": "Treat as halted (fail closed)."
  },
  "ownership": {
    "owner": "Risk pod",
    "on_call": "risk-oncall",
    "channel": "#polytraders-risk",
    "escalation": "Head of Risk",
    "severity_class": "P1"
  },
  "human_override": {
    "allowed": true,
    "who": "Risk on-call",
    "log_event": "RISK_MARKET_HALT_OVERRIDE",
    "time_bound": "60 minutes max",
    "scope": "Single market_id",
    "second_approval": false
  },
  "security_surfaces": {
    "summary": "Reads CLOB WebSocket; emits RiskVote internally only.",
    "signing": "None \u2014 bot does not sign or submit.",
    "secrets": [],
    "contract_calls": [],
    "abuse_vectors": [
      "Admin UI 'force clear' endpoint requires Risk role + audit log entry."
    ],
    "mitigations": [
      "Rate-limit per source",
      "Audit-log every override",
      "Require role-based authz on admin paths"
    ]
  },
  "polymarket_v2_compat": {
    "clob_version": "V2",
    "collateral": "pUSD",
    "eip712_domain_version": "2",
    "builder_code_aware": true,
    "negrisk_aware": true,
    "multichain_ready": true,
    "sdk_used": "Polymarket CLOB V2 SDK",
    "settlement_contract": "CTFExchangeV2",
    "notes": "Reads CLOB V2 normalised OrderBookSnapshot; no on-chain calls."
  },
  "version": {
    "current": "0.1.0",
    "contract_version": "1.0.0",
    "last_breaking_change": "none",
    "deprecation_window_days": 30
  },
  "migration_history": [],
  "runbook": {
    "summary": "Symptoms: many simultaneous halts \u2192 check upstream feed health. Single stuck market \u2192 manually clear via Admin UI then investigate the persistent rule violation.",
    "oncall_actions": [
      {
        "alert": "1.17_anomaly",
        "first_step": "Open the bot's reporting page and confirm the alert is real (not a metric hiccup).",
        "diagnosis": "Inspect developer log entries for the affected market_id over the last 30 minutes.",
        "mitigation": "Force-clear via Admin UI if the rule is clearly stale; otherwise leave engaged and notify owner.",
        "escalation": "Risk pod"
      }
    ],
    "manual_overrides": [
      {
        "command": "polytraders bot pause 1.17",
        "effect": "Disables the bot's enforcement layer; downstream consumers fall back to safe defaults."
      }
    ],
    "healthcheck": "GET /healthz/market_halt_detector \u2192 200 if last successful evaluation < 60s ago."
  },
  "promotion_gates": {
    "to_shadow": [
      {
        "gate": "Stub",
        "how_measured": "deterministic against fixture book ticks.",
        "threshold": "Documented threshold met for the full window."
      }
    ],
    "to_limited_live": [
      {
        "gate": "Shadow",
        "how_measured": "7 days against live CLOB; halt activations logged but not enforced.",
        "threshold": "Documented threshold met for the full window."
      },
      {
        "gate": "Advisory",
        "how_measured": "7 days; halts visible to ops dashboard.",
        "threshold": "Documented threshold met for the full window."
      }
    ],
    "to_general_live": [
      {
        "gate": "Enforced",
        "how_measured": "requires Risk Lead sign-off.",
        "threshold": "Documented threshold met for the full window."
      }
    ]
  },
  "failure_injection": [
    {
      "scenario": "Inject a sustained 35% inside spread for 30s and assert halt activates",
      "how_to_inject": "Inject a sustained 35% inside spread for 30s and assert halt activates.",
      "expected_behavior": "Bot detects within its latency budget and emits the corresponding reason code.",
      "recovery": "Remove the injected fault; bot returns to healthy state within one debounce window."
    },
    {
      "scenario": "Inject a 5-minute trade silence and assert halt activates",
      "how_to_inject": "Inject a 5-minute trade silence and assert halt activates.",
      "expected_behavior": "Bot detects within its latency budget and emits the corresponding reason code.",
      "recovery": "Remove the injected fault; bot returns to healthy state within one debounce window."
    },
    {
      "scenario": "Inject feed reconnect noise and assert no spurious halt within debounce window",
      "how_to_inject": "Inject feed reconnect noise and assert no spurious halt within debounce window.",
      "expected_behavior": "Bot detects within its latency budget and emits the corresponding reason code.",
      "recovery": "Remove the injected fault; bot returns to healthy state within one debounce window."
    }
  ],
  "capital_impact": "Direct",
  "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"
  }
}