{
  "schema_version": "1.0.0",
  "bot_id": "0.5",
  "bot_name": "NewMarketWatcher",
  "slug": "newmarketwatcher",
  "layer": "Discovery",
  "layer_key": "disc",
  "bot_class": "Signal Service",
  "authority": [
    "Read-only",
    "Recommend"
  ],
  "status": "planned",
  "readiness": "Spec started",
  "flagship": false,
  "is_reference": false,
  "public_export": false,
  "identity": {
    "layer": "Discovery",
    "bot_class": "Signal Service",
    "authority": "Read-only, Recommend",
    "runs_before": "Strategy OrderIntent generation",
    "runs_after": "Market data ingestion",
    "applies_to": "Newly listed Polymarket markets not yet in the MarketScanner candidate list",
    "default_mode": "shadow_only",
    "user_visible": "Advanced details only",
    "developer_owner": "Polytraders core \u2014 Intelligence pod"
  },
  "purpose": "Poll the Gamma API at high frequency to detect newly listed markets as soon as they appear, parse their initial metadata, and emit ObservationReports so strategies can evaluate early liquidity opportunities before the book fills.",
  "why_it_matters": [
    {
      "failure": "New markets discovered only on the next slow scan cycle",
      "consequence": "Early-entry advantage is lost; book may already be competitive by the time MarketScanner surfaces the market."
    },
    {
      "failure": "Unvalidated new market metadata forwarded to strategies",
      "consequence": "Markets with incomplete rules text or missing resolution dates may produce erroneous strategy decisions."
    }
  ],
  "polymarket_inputs": [
    {
      "input": "New condition_id metadata appearing on Gamma API",
      "source": "Gamma API",
      "required": true,
      "use": "Primary source for detecting newly listed markets via ETag-gated polling."
    },
    {
      "input": "Initial book state (or absence of book)",
      "source": "CLOB",
      "required": true,
      "use": "Assess whether any resting liquidity exists at listing time."
    },
    {
      "input": "Tick-size, neg-risk flag, and resolution metadata at listing",
      "source": "Gamma API",
      "required": true,
      "use": "Pass initial metadata to strategies via ObservationReport."
    }
  ],
  "internal_inputs": [
    {
      "input": "Known condition_id set (from MarketScanner)",
      "source": "disc.marketscanner",
      "required": true,
      "use": "Detect genuinely new markets by diffing against the known set."
    },
    {
      "input": "KillSwitch active flag",
      "source": "risk.kill_switch",
      "required": true,
      "use": "Suppress emissions when KillSwitch is active."
    }
  ],
  "raw_params": [
    "poll_interval_s \u00b7 int",
    "min_listing_age_s \u00b7 int",
    "alert_to_strategies \u00b7 list",
    "require_rule_parse \u00b7 bool"
  ],
  "parameters": [
    {
      "name": "poll_interval_s",
      "default": 10,
      "warning": 5,
      "hard": 2,
      "controls": "How often in seconds the Gamma API is polled for newly listed markets.",
      "why_default_matters": "10-second polling catches new listings within one cycle while staying well within Gamma API rate limits.",
      "threshold_logic": [
        {
          "condition": ">= 10s",
          "action": "Normal polling cadence"
        },
        {
          "condition": "5\u201310s",
          "action": "Fast poll \u2014 WARN; monitor rate limits"
        },
        {
          "condition": "< 2s",
          "action": "Reject \u2014 PARAMETER_CHANGE_REQUIRES_APPROVAL"
        }
      ],
      "dev_check": "if (s < params.hard) throw ConfigError('PARAMETER_CHANGE_REQUIRES_APPROVAL');",
      "user_facing": "New markets are checked frequently to catch early trading opportunities."
    },
    {
      "name": "min_listing_age_s",
      "default": 30,
      "warning": 10,
      "hard": 0,
      "controls": "Minimum age in seconds a new market must have before being emitted; prevents emitting markets that may be immediately delisted.",
      "why_default_matters": "A 30-second hold-off filters transient listings that are created and immediately cancelled.",
      "threshold_logic": [
        {
          "condition": ">= 30s",
          "action": "Normal hold-off"
        },
        {
          "condition": "10\u201330s",
          "action": "Short hold-off \u2014 WARN"
        },
        {
          "condition": "0s",
          "action": "No hold-off; emit immediately"
        }
      ],
      "dev_check": "if (age < params.min_listing_age_s) hold();",
      "user_facing": "New markets are held briefly before being surfaced to confirm they are stable listings."
    }
  ],
  "default_config": {
    "bot_id": "disc.new_market_watcher",
    "version": "0.1.0",
    "mode": "shadow_only",
    "defaults": {
      "poll_interval_s": 10,
      "min_listing_age_s": 30,
      "alert_to_strategies": [],
      "require_rule_parse": true
    }
  },
  "implementation_flow": [
    "On each poll cycle, send a conditional GET to Gamma API using the ETag from the previous response.",
    "If ETag matches (304 Not Modified), skip processing \u2014 no new markets.",
    "If new markets are present, diff against the known condition_id set to identify genuinely new listings.",
    "Check KillSwitch; if active, update known set but suppress emissions.",
    "For each new market, apply min_listing_age_s hold-off; hold in a pending set.",
    "After hold-off, fetch initial book state from CLOB and parse resolution metadata.",
    "If require_rule_parse=true, validate that resolution rules text and resolution date are present.",
    "Emit ObservationReport with condition_id, initial_book_state, neg_risk, tick_size, resolution_date.",
    "Add newly emitted markets to the known condition_id set.",
    "Log cycle summary: new_markets_detected, pending_count, emitted_count."
  ],
  "decision_logic": {
    "approve": "Not applicable \u2014 NewMarketWatcher emits ObservationReports, not approvals.",
    "reshape_required": "Not applicable \u2014 read-only watcher bot.",
    "reject": "Markets without resolution rules text when require_rule_parse=true receive INCOMPLETE_MARKET_METADATA and are not forwarded until metadata is present.",
    "warning_only": "Markets at or below the hold-off threshold emit a NEW_MARKET_HOLDOFF warning while pending."
  },
  "decision_output_schema": "ObservationReport",
  "decision_output_example": {
    "report_id": "0xff002244668800aabbccddee112233ff002244668800aabbccddee11223344",
    "bot_id": "disc.new_market_watcher",
    "market_id": "0xaabbccddeeff001122334455667788aabbccddeeff001122334455667788aabb",
    "listing_detected_at_ms": 1746789000000,
    "listing_age_s": 35,
    "neg_risk": false,
    "tick_size": 0.01,
    "resolution_date": "2026-09-01T00:00:00Z",
    "initial_book_state": "empty",
    "warnings": [],
    "emitted_at_ms": 1746789035000
  },
  "developer_log": {
    "bot_id": "disc.new_market_watcher",
    "poll_cycle": 4820,
    "new_markets_detected": 2,
    "pending_holdoff": 1,
    "emitted": 1,
    "suppressed_killswitch": 0,
    "known_set_size": 318,
    "polled_at": "2026-05-09T11:30:00Z"
  },
  "user_explanations": [
    {
      "situation": "New market shown immediately after launch",
      "message": "This market was just listed on Polymarket. It may have very little liquidity right now \u2014 strategies will assess whether early positioning is appropriate."
    },
    {
      "situation": "New market not yet surfaced",
      "message": "A newly listed market may be in the hold-off period while we confirm it is a stable listing before surfacing it."
    }
  ],
  "failure_modes": {
    "main_failure_mode": "ETag polling misses a burst of new listings if Gamma API returns a non-304 response that is then lost due to a network error.",
    "false_positive_risk": "A market that is listed and immediately delisted within the hold-off window may still be emitted if the hold-off is shorter than the cancellation latency.",
    "false_negative_risk": "A new market without a resolution rules text when require_rule_parse=true will be silently held until metadata appears, missing the early liquidity window.",
    "safe_fallback": "If Gamma API is unavailable, continue serving the known set from cache; do not emit speculative new-market reports on stale data.",
    "required_dependencies": [
      "Gamma API ETag-capable listing endpoint",
      "CLOB initial book snapshot",
      "MarketScanner known condition_id set",
      "KillSwitch active flag"
    ]
  },
  "acceptance_tests": {
    "unit": [
      {
        "test": "New market in Gamma API response not in known set triggers emission after hold-off",
        "setup": "condition_id=0xnew, listing_age=35s, min_listing_age_s=30",
        "expected": "ObservationReport emitted with listing_age_s=35"
      },
      {
        "test": "Market missing resolution rules text blocked when require_rule_parse=true",
        "setup": "rules_text=null, require_rule_parse=true",
        "expected": "Market held with INCOMPLETE_MARKET_METADATA; not emitted"
      },
      {
        "test": "ETag 304 response skips processing",
        "setup": "ETag matches previous response",
        "expected": "No processing; no new emissions"
      }
    ],
    "integration": [
      {
        "test": "New market detected and forwarded to MarketScanner candidate list within 2 poll cycles",
        "expected": "ObservationReport from NewMarketWatcher appears in MarketScanner's next cycle"
      },
      {
        "test": "KillSwitch active suppresses emissions but known set still updated",
        "expected": "No ObservationReports; known set includes new market_id for next cycle"
      }
    ],
    "property": [
      {
        "property": "Every emitted market_id has listing_age_s >= min_listing_age_s",
        "required": "Always true"
      },
      {
        "property": "No emission when KillSwitch is active",
        "required": "Always true"
      }
    ]
  },
  "checklist_overrides": {},
  "legacy_goal": "Detect newly listed markets and flag early liquidity opportunities before the book gets crowded.",
  "legacy_pm_signals": [
    "New condition-id metadata appearing on Gamma API",
    "Initial book state (or absence of book)",
    "Tick-size and neg-risk flag at listing"
  ],
  "legacy_external_feeds": [
    "ETag from Gamma API for cheap polling"
  ],
  "reporting_groups": [
    "pretrade_intel"
  ],
  "network": [
    "polygon"
  ],
  "api_surface": [
    "gamma",
    "clob_public",
    "internal"
  ],
  "version": {
    "spec": "2.0.0",
    "implementation": "0.1.0",
    "schema": "2",
    "released": null,
    "planned_release": "Q4-2026"
  },
  "migration_history": [
    {
      "date": "2026-04-28",
      "from": "n/a",
      "to": "v2-spec",
      "reason": "Spec drafted post-CLOB-V2 cutover; bot not yet implemented",
      "action_taken": "Designed against V2 schema (pUSD, builder codes, V2 EIP-712 domain)"
    }
  ],
  "polymarket_v2_compat": {
    "clob_version": "v2",
    "collateral": "pUSD",
    "eip712_domain_version": "2",
    "builder_code_aware": false,
    "negrisk_aware": true,
    "multichain_ready": false,
    "sdk_used": "py-clob-client-v2",
    "settlement_contract": "CTFExchangeV2",
    "notes": "Reads Gamma API enableNegRisk flag at listing time to classify new neg-risk markets; initial depth denominated in pUSD."
  },
  "reference_implementation": {
    "pseudocode": "FUNCTION pollCycle():\n  ks = FETCH internal.killswitch.status\n\n  response = gamma.GET('/markets?active=true&closed=false',\n                       headers={'If-None-Match': last_etag})\n  IF response.status == 304: RETURN  // no new listings\n\n  last_etag = response.headers['ETag']\n  all_ids = {m.condition_id FOR m IN response.markets}\n  new_ids = all_ids - known_condition_ids\n\n  FOR condition_id IN new_ids:\n    market = response.market(condition_id)\n    listing_age_s = now() - market.created_at\n    IF listing_age_s < params.min_listing_age_s:\n      pending_set.add(condition_id, market); CONTINUE\n\n    IF params.require_rule_parse AND NOT market.rules_text:\n      pending_metadata.add(condition_id); CONTINUE  // INCOMPLETE_MARKET_METADATA\n\n    book = FETCH clob_public.GET('/book?market=' + condition_id + '&depth=5')\n    initial_state = 'empty' IF book.bids == [] AND book.asks == [] ELSE 'active'\n\n    IF NOT ks.active:\n      EMIT ObservationReport(condition_id, listing_age_s, market.neg_risk,\n                             market.tick_size, market.resolution_date,\n                             initial_state)\n\n    known_condition_ids.add(condition_id)\n\n  // Also re-check pending_metadata set\n  FOR cid IN pending_metadata:\n    market = gamma.GET('/market/' + cid)\n    IF market.rules_text AND NOT ks.active:\n      EMIT ObservationReport(cid, ...)\n      known_condition_ids.add(cid)\n\n  LOG poll cycle summary",
    "sdk_calls": [
      "gamma.GET('/markets?active=true&closed=false', If-None-Match: <etag>)",
      "gamma.GET('/market/<condition_id>')",
      "fetchClobPublic('/book?market=<condition_id>&depth=5')"
    ],
    "complexity": "O(N) where N = new markets per poll cycle; amortised O(1) with ETag 304"
  },
  "wire_examples": {
    "input": [
      {
        "label": "Gamma API new market entry",
        "source": "gamma_api",
        "payload": {
          "condition_id": "0xaabbccddeeff001122334455667788aabbccddeeff001122334455667788aabb",
          "question": "Will the new legislation pass by September 2026?",
          "active": true,
          "created_at": "2026-05-09T11:29:25Z",
          "neg_risk": false,
          "tick_size": 0.01,
          "resolution_date": "2026-09-01T00:00:00Z",
          "rules_text": "Resolves YES if the bill passes both chambers by midnight UTC on 2026-09-01."
        }
      }
    ],
    "output": [
      {
        "label": "ObservationReport \u2014 new market detected",
        "payload": {
          "report_id": "0xff002244668800aabbccddee112233ff002244668800aabbccddee11223344",
          "bot_id": "disc.new_market_watcher",
          "market_id": "0xaabbccddeeff001122334455667788aabbccddeeff001122334455667788aabb",
          "listing_detected_at_ms": 1746789000000,
          "listing_age_s": 35,
          "neg_risk": false,
          "tick_size": 0.01,
          "resolution_date": "2026-09-01T00:00:00Z",
          "initial_book_state": "empty",
          "warnings": [],
          "emitted_at_ms": 1746789035000
        }
      }
    ],
    "curl": "curl -H 'If-None-Match: \"abc123\"' 'https://gamma-api.polymarket.com/markets?active=true&closed=false'"
  },
  "reason_codes": [
    {
      "code": "INCOMPLETE_MARKET_METADATA",
      "severity": "WARN",
      "meaning": "New market is missing required rules text or resolution date; held in pending set.",
      "action": "Retry on next poll cycle; emit once metadata is complete.",
      "user_message": "A new market is being evaluated but is missing some required details."
    },
    {
      "code": "NEW_MARKET_HOLDOFF",
      "severity": "INFO",
      "meaning": "Market is within the min_listing_age_s hold-off window.",
      "action": "Hold in pending set; emit after hold-off expires.",
      "user_message": "A newly listed market is being held briefly before surfacing."
    },
    {
      "code": "KILL_SWITCH_ACTIVE",
      "severity": "HARD_REJECT",
      "meaning": "KillSwitch is active; all emissions suppressed.",
      "action": "Update known set but emit no ObservationReports.",
      "user_message": ""
    },
    {
      "code": "STALE_MARKET_DATA",
      "severity": "HARD_REJECT",
      "meaning": "Gamma API unavailable; serving known set from cache only.",
      "action": "Halt new-market emissions; retry on next poll cycle.",
      "user_message": ""
    },
    {
      "code": "PARAMETER_CHANGE_REQUIRES_APPROVAL",
      "severity": "HARD_REJECT",
      "meaning": "poll_interval_s below locked hard minimum of 2s.",
      "action": "Reject config change.",
      "user_message": ""
    }
  ],
  "metrics": {
    "emitted": [
      {
        "name": "polytraders_disc_newmarketwatcher_new_markets_detected_total",
        "type": "counter",
        "unit": "count",
        "labels": [],
        "meaning": "Total new market_ids detected via Gamma API polling."
      },
      {
        "name": "polytraders_disc_newmarketwatcher_reports_emitted_total",
        "type": "counter",
        "unit": "count",
        "labels": [],
        "meaning": "ObservationReports successfully emitted for new markets."
      },
      {
        "name": "polytraders_disc_newmarketwatcher_pending_holdoff_gauge",
        "type": "gauge",
        "unit": "count",
        "labels": [],
        "meaning": "Number of new markets currently in the hold-off pending set."
      },
      {
        "name": "polytraders_disc_newmarketwatcher_poll_latency_ms",
        "type": "histogram",
        "unit": "ms",
        "labels": [],
        "meaning": "Latency of each Gamma API poll cycle."
      }
    ],
    "alerts": [
      {
        "name": "NewMarketWatcherGammaAPIDown",
        "condition": "rate(polytraders_disc_newmarketwatcher_new_markets_detected_total[5m]) == 0 AND up{job='gamma_api'} == 0",
        "severity": "P1",
        "runbook": "#runbook-newmarketwatcher-gamma-api"
      },
      {
        "name": "NewMarketWatcherHighPending",
        "condition": "polytraders_disc_newmarketwatcher_pending_holdoff_gauge > 20",
        "severity": "P2",
        "runbook": "#runbook-newmarketwatcher-high-pending"
      }
    ],
    "dashboards": [
      "Grafana \u2014 Discovery / NewMarketWatcher detection latency"
    ],
    "log_levels": {
      "DEBUG": "Per-new-market condition_id, listing_age_s, initial_book_state.",
      "INFO": "Poll cycle summary: detected, pending, emitted.",
      "WARN": "Gamma API slow; high pending holdoff count.",
      "ERROR": "Gamma API unavailable; CLOB unreachable."
    }
  },
  "state": {
    "store": "in-memory known condition_id set + pending hold-off set",
    "shape": "{ known: Set<condition_id>, pending: { condition_id -> { market, detected_at } } }",
    "ttl": "known set persists until market closes; pending set retries until emitted or 5-minute timeout",
    "recovery": "On cold start, known set is bootstrapped from MarketScanner's current candidate list.",
    "size_estimate": "~40 B per condition_id; ~1000 markets \u2192 ~40 KB"
  },
  "concurrency": {
    "execution_model": "single-threaded async loop",
    "max_in_flight": 1,
    "idempotency_key": "poll_cycle_etag",
    "timeout_ms": 4000,
    "backpressure": "drop newest",
    "locking": "none"
  },
  "dependencies": {
    "depends_on": [
      {
        "bot_id": "disc.marketscanner",
        "why": "Provides baseline known condition_id set for new-market diffing.",
        "contract": "Expects a set of active condition_ids."
      },
      {
        "bot_id": "risk.kill_switch",
        "why": "KillSwitch suppresses emissions but known set updates continue.",
        "contract": "If active, no ObservationReports emitted."
      }
    ],
    "emits_to": [
      {
        "bot_id": "disc.marketscanner",
        "why": "NewMarketWatcher feeds newly detected markets back into MarketScanner for full tradability scoring.",
        "contract": "ObservationReport includes condition_id, neg_risk, tick_size, and resolution_date."
      }
    ],
    "sibling": [
      "disc.marketqualityranker"
    ],
    "external": [
      {
        "service": "Gamma API",
        "endpoint": "https://gamma-api.polymarket.com",
        "sla": "99.9% / 500ms p99",
        "failure_mode": "Serve known set from cache; halt new-market emissions."
      }
    ]
  },
  "security_surfaces": {
    "signs_orders": false,
    "private_key_access": "none",
    "abuse_vectors": [
      "Gamma API returning spoofed new market metadata to inject fraudulent condition_ids"
    ],
    "mitigations": [
      "condition_id validated against 32-byte hex pattern before entering known set",
      "require_rule_parse=true ensures minimal metadata quality before emission"
    ]
  },
  "failure_injection": [
    {
      "scenario": "GAMMA_API_DOWN",
      "how_to_inject": "Block TCP to gamma-api.polymarket.com",
      "expected_behaviour": "No new markets detected; STALE_MARKET_DATA logged; known set frozen at last state",
      "recovery": "Automatic when API recovers on next poll cycle."
    },
    {
      "scenario": "NEW_MARKET_MISSING_RULES",
      "how_to_inject": "Create a mock market with rules_text=null and require_rule_parse=true",
      "expected_behaviour": "Market held in pending_metadata set; emitted only after rules_text appears",
      "recovery": "Automatic when Gamma API returns complete metadata."
    },
    {
      "scenario": "KILL_SWITCH_ON",
      "how_to_inject": "Set killswitch.active=true",
      "expected_behaviour": "Polling continues; known set updated; zero ObservationReports emitted",
      "recovery": "Emissions resume on next cycle after KillSwitch reset."
    }
  ],
  "runbook": {
    "summary": "NewMarketWatcher incidents are typically Gamma API outages or high hold-off backlogs. Bot is read-only; incidents delay new market discovery but do not affect active positions.",
    "oncall_actions": [
      {
        "alert": "NewMarketWatcherGammaAPIDown",
        "first_action": "Check Gamma API status; verify ETag polling is functioning.",
        "escalate_to": "Intelligence pod lead after 10 minutes."
      },
      {
        "alert": "NewMarketWatcherHighPending",
        "first_action": "Inspect pending_holdoff set; check if many new markets are missing rules text.",
        "escalate_to": "Intelligence pod lead if backlog > 50 markets."
      }
    ],
    "manual_overrides": [
      {
        "name": "disable-rule-parse",
        "how": "Set require_rule_parse=false via config update",
        "when": "Gamma API is consistently returning markets without rules text; temporarily bypass to unblock emissions."
      }
    ],
    "healthcheck": "GET /internal/health/newmarketwatcher \u2192 green if Poll cycle completed within 3\u00d7 poll_interval_s; no Gamma API errors in last 5 minutes.; red if No poll cycle in 3\u00d7 interval or consecutive Gamma API failures."
  },
  "promotion_gates": {
    "to_shadow": [
      {
        "gate": "ETag-based polling correctly identifies zero new markets on 304 responses",
        "how_measured": "Unit test suite",
        "threshold": "100% pass"
      }
    ],
    "to_limited_live": [
      {
        "gate": "New market detection latency < 30s from Gamma API listing time over 48h",
        "how_measured": "polytraders_disc_newmarketwatcher_poll_latency_ms p99",
        "threshold": "< 30s"
      }
    ],
    "to_general_live": [
      {
        "gate": "Zero spurious emissions for markets that were immediately delisted",
        "how_measured": "Manual review of emitted markets over 7 days",
        "threshold": "0 false emissions"
      }
    ]
  },
  "reporting": {
    "emits_kinds": [
      "ObservationReport"
    ],
    "topics": [
      "polytraders.reports.observation"
    ],
    "retention_class": "30d",
    "cadence": "every-N",
    "sampling_rule": "sample-1/N",
    "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"
  }
}