{
  "schema_version": "1.0.0",
  "bot_id": "0.4",
  "bot_name": "OpportunityQueue",
  "slug": "opportunityqueue",
  "layer": "Discovery",
  "layer_key": "disc",
  "bot_class": "Signal Service",
  "authority": [
    "Read-only",
    "Recommend"
  ],
  "status": "planned",
  "readiness": "Spec started",
  "flagship": true,
  "is_reference": false,
  "public_export": false,
  "identity": {
    "layer": "Discovery",
    "bot_class": "Signal Service",
    "authority": "Read-only, Recommend",
    "runs_before": "Strategy OrderIntent generation",
    "runs_after": "MarketQualityRanker and EventCalendarMapper",
    "applies_to": "All markets that have passed quality scoring",
    "default_mode": "shadow_only",
    "user_visible": "Advanced details only",
    "developer_owner": "Polytraders core \u2014 Intelligence pod"
  },
  "purpose": "Maintain a ranked queue of the best candidate markets per registered strategy type by combining MarketQualityRanker scores, EventCalendarMapper proximity signals, and per-strategy fit-scores into a single ordered list that strategies consume without re-running discovery.",
  "why_it_matters": [
    {
      "failure": "Strategies independently re-rank markets",
      "consequence": "Each strategy duplicates discovery logic, wasting compute and producing inconsistent rankings across the system."
    },
    {
      "failure": "Existing positions not suppressed",
      "consequence": "A strategy may generate a new intent on a market where the user already has an open position, causing unintended double-up exposure."
    }
  ],
  "polymarket_inputs": [
    {
      "input": "Top-of-book size and current spread",
      "source": "CLOB + ws_market",
      "required": true,
      "use": "Refresh queue entries with current execution cost estimates."
    }
  ],
  "internal_inputs": [
    {
      "input": "MarketQualityRanker ObservationReports",
      "source": "disc.marketqualityranker",
      "required": true,
      "use": "Base quality score for each queue entry."
    },
    {
      "input": "EventCalendarMapper ObservationReports",
      "source": "disc.eventcalendarmapper",
      "required": false,
      "use": "Boost ranking for markets near a calendar event."
    },
    {
      "input": "Open positions per market (double-up suppression)",
      "source": "exec.position_tracker",
      "required": false,
      "use": "Suppress markets where the user already has an open position if suppress_existing_position=true."
    },
    {
      "input": "KillSwitch active flag",
      "source": "risk.kill_switch",
      "required": true,
      "use": "Freeze queue and suppress emissions when KillSwitch is active."
    }
  ],
  "raw_params": [
    "queue_depth \u00b7 int",
    "refresh_interval_s \u00b7 int",
    "strategy_filters \u00b7 list",
    "suppress_existing_position \u00b7 bool"
  ],
  "parameters": [
    {
      "name": "queue_depth",
      "default": 20,
      "warning": 5,
      "hard": 1,
      "controls": "Maximum number of ranked market entries to maintain per strategy type.",
      "why_default_matters": "A depth of 20 gives strategies enough alternatives without overwhelming them with marginal candidates.",
      "threshold_logic": [
        {
          "condition": ">= 20",
          "action": "Normal queue depth"
        },
        {
          "condition": "5\u201320",
          "action": "Shallow queue \u2014 WARN"
        },
        {
          "condition": "< 1",
          "action": "Reject config \u2014 queue_depth must be >= 1"
        }
      ],
      "dev_check": "if (d < params.hard) throw ConfigError('PARAMETER_CHANGE_REQUIRES_APPROVAL');",
      "user_facing": "The system maintains a shortlist of the best available markets for each strategy type."
    },
    {
      "name": "refresh_interval_s",
      "default": 60,
      "warning": 15,
      "hard": 5,
      "controls": "How often the queue is re-ranked from fresh quality scores and book data.",
      "why_default_matters": "60-second refresh keeps rankings reasonably current without hammering upstream services.",
      "threshold_logic": [
        {
          "condition": ">= 60s",
          "action": "Normal refresh cadence"
        },
        {
          "condition": "15\u201360s",
          "action": "Fast refresh \u2014 WARN"
        },
        {
          "condition": "< 5s",
          "action": "Reject \u2014 PARAMETER_CHANGE_REQUIRES_APPROVAL"
        }
      ],
      "dev_check": "if (s < params.hard) throw ConfigError('PARAMETER_CHANGE_REQUIRES_APPROVAL');",
      "user_facing": "The opportunity list is updated regularly to reflect changing market conditions."
    }
  ],
  "default_config": {
    "bot_id": "disc.opportunity_queue",
    "version": "0.1.0",
    "mode": "shadow_only",
    "defaults": {
      "queue_depth": 20,
      "refresh_interval_s": 60,
      "strategy_filters": [],
      "suppress_existing_position": true
    }
  },
  "implementation_flow": [
    "On each refresh cycle, ingest latest MarketQualityRanker ObservationReports.",
    "Check KillSwitch; if active, freeze queue and suppress emissions.",
    "Optionally ingest EventCalendarMapper reports; apply hours_to_event proximity boost.",
    "If suppress_existing_position=true, fetch open positions from exec.position_tracker and exclude those markets.",
    "For each registered strategy type, compute per-strategy fit_score using strategy_filters (e.g. time-to-res, vol range, spread tolerance).",
    "Final rank = quality_score * fit_score * proximity_boost.",
    "Retain top queue_depth entries per strategy type; evict stale entries older than 2\u00d7 refresh_interval_s.",
    "Emit ObservationReport with ranked queue snapshot per strategy type.",
    "Log cycle summary with queue depth, evictions, and top entry per strategy."
  ],
  "decision_logic": {
    "approve": "Not applicable \u2014 OpportunityQueue emits ObservationReports, not approvals.",
    "reshape_required": "Not applicable \u2014 read-only ranking bot.",
    "reject": "Markets already held as open positions are suppressed when suppress_existing_position=true.",
    "warning_only": "Markets near the queue_depth warning threshold trigger shallow-queue warnings."
  },
  "decision_output_schema": "ObservationReport",
  "decision_output_example": {
    "report_id": "0xeeff33445566778899001122334455eeff33445566778899001122334455eeff",
    "bot_id": "disc.opportunity_queue",
    "strategy_type": "mean-reversion",
    "queue": [
      {
        "rank": 1,
        "market_id": "0x7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a",
        "quality_score": 0.71,
        "fit_score": 0.88,
        "proximity_boost": 1.0,
        "final_rank_score": 0.625
      }
    ],
    "queue_depth": 1,
    "refreshed_at_ms": 1746789000000
  },
  "developer_log": {
    "bot_id": "disc.opportunity_queue",
    "cycle": 88,
    "strategy_types_served": 3,
    "total_queue_entries": 45,
    "evictions": 3,
    "suppressed_existing": 2,
    "killswitch_active": false,
    "refreshed_at": "2026-05-09T11:30:00Z"
  },
  "user_explanations": [
    {
      "situation": "Fewer opportunities than expected in queue",
      "message": "The queue may have few entries because of thin markets, open position suppression, or a narrow strategy filter configuration."
    },
    {
      "situation": "Opportunity disappeared from queue",
      "message": "A market may have dropped out of the queue because its quality score declined, it was excluded due to an open position, or the queue was refreshed with new data."
    }
  ],
  "failure_modes": {
    "main_failure_mode": "Queue becomes stale if MarketQualityRanker stops emitting; strategies continue consuming an outdated ranked list.",
    "false_positive_risk": "A market with a temporarily high fit_score (e.g. unusual spread) could rank near the top, causing strategies to target a marginal market.",
    "false_negative_risk": "A market matching the strategy filter may be suppressed if the position tracker incorrectly reports an open position.",
    "safe_fallback": "If MarketQualityRanker reports are stale (>2\u00d7 refresh_interval_s), freeze queue and emit STALE_QUEUE warning rather than serving stale rankings.",
    "required_dependencies": [
      "MarketQualityRanker ObservationReports",
      "KillSwitch active flag"
    ]
  },
  "acceptance_tests": {
    "unit": [
      {
        "test": "queue_depth=1 returns only the top-ranked market per strategy type",
        "setup": "queue_depth=1, 10 candidate markets",
        "expected": "ObservationReport with queue.length=1"
      },
      {
        "test": "suppress_existing_position removes held markets",
        "setup": "suppress_existing_position=true; open position on market A",
        "expected": "Market A absent from queue output"
      },
      {
        "test": "KillSwitch freezes queue",
        "setup": "killswitch.active=true",
        "expected": "No ObservationReports emitted; existing queue frozen"
      }
    ],
    "integration": [
      {
        "test": "MarketQualityRanker \u2192 OpportunityQueue \u2192 Strategy pipeline",
        "expected": "Strategy receives ranked queue ObservationReport and generates OrderIntent using top-ranked market"
      },
      {
        "test": "Stale MarketQualityRanker reports trigger STALE_QUEUE",
        "expected": "Queue frozen; STALE_QUEUE warning emitted; strategies notified"
      }
    ],
    "property": [
      {
        "property": "Queue never exceeds queue_depth entries per strategy type",
        "required": "Always true"
      },
      {
        "property": "No market with open position appears in queue when suppress_existing_position=true",
        "required": "Always true"
      }
    ]
  },
  "checklist_overrides": {},
  "legacy_goal": "Maintain a ranked queue of \"best next trades\" per strategy type. Output = MarketQualityRanker \u00d7 per-strategy fit-scores.",
  "legacy_pm_signals": [
    "Quality score from MarketQualityRanker",
    "Top-of-book size and book depth",
    "Per-strategy fit-score inputs (vol, spread, time-to-res)",
    "Open positions per market (for double-up suppression)"
  ],
  "legacy_external_feeds": [],
  "reporting_groups": [
    "pretrade_intel"
  ],
  "network": [
    "polygon"
  ],
  "api_surface": [
    "clob_public",
    "ws_market",
    "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": "Queue entries include neg_risk flag from upstream quality reports; all size and cost estimates denominated in pUSD."
  },
  "reference_implementation": {
    "pseudocode": "FUNCTION queueRefreshCycle():\n  ks = FETCH internal.killswitch.status\n  IF ks.active: EMIT STALE_QUEUE; RETURN\n\n  quality_reports = FETCH disc.marketqualityranker.latest_reports()\n  IF quality_reports IS NULL OR age(quality_reports) > 2 * params.refresh_interval_s:\n    EMIT ObservationReport(kind='STALE_QUEUE'); RETURN\n\n  calendar_boosts = FETCH disc.eventcalendarmapper.latest_reports() OR {}\n  open_positions = FETCH exec.position_tracker.open() OR {}\n\n  FOR strategy_type IN registered_strategies:\n    candidates = []\n    FOR report IN quality_reports:\n      IF params.suppress_existing_position AND report.market_id IN open_positions:\n        CONTINUE\n      fit = computeFitScore(strategy_type, report)\n      boost = calendarBoost(report.market_id, calendar_boosts)\n      final_score = report.quality_score * fit * boost\n      candidates.append({ market_id, quality_score, fit, boost, final_score })\n\n    candidates.sort(key=final_score, desc=True)\n    queue = candidates[:params.queue_depth]\n\n    IF len(queue) < params.queue_depth.warning:\n      LOG WARN 'QUEUE_TOO_SHALLOW'\n\n    EMIT ObservationReport(strategy_type, queue, refreshed_at)\n\n  LOG cycle summary",
    "sdk_calls": [
      "fetchClobPublic('/book?market=<condition_id>&depth=1')",
      "ws_market.current_spread('<condition_id>')"
    ],
    "complexity": "O(M \u00d7 S) where M=candidate markets, S=registered strategy types"
  },
  "wire_examples": {
    "input": [
      {
        "label": "MarketQualityRanker ObservationReport consumed by queue",
        "source": "disc.marketqualityranker",
        "payload": {
          "market_id": "0x7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a",
          "quality_score": 0.71,
          "sub_scores": {
            "liquidity": 0.82,
            "rule_clarity": 0.65,
            "resolution_horizon": 0.6
          }
        }
      }
    ],
    "output": [
      {
        "label": "ObservationReport \u2014 ranked queue for mean-reversion strategy",
        "payload": {
          "report_id": "0xeeff33445566778899001122334455eeff33445566778899001122334455eeff",
          "bot_id": "disc.opportunity_queue",
          "strategy_type": "mean-reversion",
          "queue": [
            {
              "rank": 1,
              "market_id": "0x7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a",
              "quality_score": 0.71,
              "fit_score": 0.88,
              "proximity_boost": 1.0,
              "final_rank_score": 0.625
            }
          ],
          "queue_depth": 1,
          "refreshed_at_ms": 1746789000000
        }
      }
    ],
    "curl": "curl 'https://clob.polymarket.com/book?market=0x7f8a9b...&depth=1'"
  },
  "reason_codes": [
    {
      "code": "STALE_QUEUE",
      "severity": "WARN",
      "meaning": "MarketQualityRanker reports are older than 2\u00d7 refresh_interval_s; queue frozen.",
      "action": "Freeze queue; emit STALE_QUEUE warning; notify downstream strategies.",
      "user_message": "Opportunity rankings are temporarily unavailable while upstream data refreshes."
    },
    {
      "code": "QUEUE_TOO_SHALLOW",
      "severity": "WARN",
      "meaning": "Active queue depth has dropped below the warning threshold.",
      "action": "Log warning; relax strategy_filters if configured to do so.",
      "user_message": "Fewer opportunities than usual are available for your strategy type."
    },
    {
      "code": "KILL_SWITCH_ACTIVE",
      "severity": "HARD_REJECT",
      "meaning": "KillSwitch is active; queue frozen and emissions suppressed.",
      "action": "Return immediately without emitting any reports.",
      "user_message": ""
    },
    {
      "code": "PARAMETER_CHANGE_REQUIRES_APPROVAL",
      "severity": "HARD_REJECT",
      "meaning": "queue_depth or refresh_interval_s below locked hard minimum.",
      "action": "Reject config change; do not apply.",
      "user_message": ""
    }
  ],
  "metrics": {
    "emitted": [
      {
        "name": "polytraders_disc_opportunityqueue_queue_depth_gauge",
        "type": "gauge",
        "unit": "count",
        "labels": [
          "strategy_type"
        ],
        "meaning": "Current number of entries in the opportunity queue per strategy type."
      },
      {
        "name": "polytraders_disc_opportunityqueue_reports_emitted_total",
        "type": "counter",
        "unit": "count",
        "labels": [
          "strategy_type"
        ],
        "meaning": "Total queue refresh ObservationReports emitted."
      },
      {
        "name": "polytraders_disc_opportunityqueue_evictions_total",
        "type": "counter",
        "unit": "count",
        "labels": [],
        "meaning": "Markets evicted from queue due to staleness or position suppression."
      }
    ],
    "alerts": [
      {
        "name": "OpportunityQueueEmpty",
        "condition": "polytraders_disc_opportunityqueue_queue_depth_gauge == 0",
        "severity": "P2",
        "runbook": "#runbook-opportunityqueue-empty"
      },
      {
        "name": "OpportunityQueueStale",
        "condition": "time() - polytraders_disc_opportunityqueue_last_refresh_ts > 120",
        "severity": "P1",
        "runbook": "#runbook-opportunityqueue-stale"
      }
    ],
    "dashboards": [
      "Grafana \u2014 Discovery / OpportunityQueue depth and evictions"
    ],
    "log_levels": {
      "DEBUG": "Per-strategy-type queue snapshot with scores.",
      "INFO": "Cycle summary: strategy_types_served, total_entries, evictions.",
      "WARN": "Queue shallow; upstream quality reports stale.",
      "ERROR": "KillSwitch active; MarketQualityRanker unreachable."
    }
  },
  "state": {
    "store": "in-memory ranked queue per strategy type",
    "shape": "{ strategy_type -> [{ market_id, quality_score, fit_score, final_rank_score, added_at }] }",
    "ttl": "2\u00d7 refresh_interval_s per entry; evict stale entries",
    "recovery": "On cold start, queue is empty; first refresh cycle populates it.",
    "size_estimate": "~1 KB per queue entry; 20 entries \u00d7 10 strategy types \u2192 ~200 KB"
  },
  "concurrency": {
    "execution_model": "single-threaded async loop",
    "max_in_flight": 1,
    "idempotency_key": "refresh_cycle_id",
    "timeout_ms": 6000,
    "backpressure": "drop newest",
    "locking": "read-write lock on queue state"
  },
  "dependencies": {
    "depends_on": [
      {
        "bot_id": "disc.marketqualityranker",
        "why": "Primary source of quality scores for queue entries.",
        "contract": "ObservationReport must include quality_score, sub_scores, and market_id."
      },
      {
        "bot_id": "risk.kill_switch",
        "why": "KillSwitch gate freezes queue and suppresses emissions.",
        "contract": "If active, queue frozen; no reports emitted."
      }
    ],
    "emits_to": [
      {
        "bot_id": "strat.*",
        "why": "Registered strategies consume the ranked queue to select their next OrderIntent candidate.",
        "contract": "ObservationReport includes strategy_type, ranked queue entries, and refreshed_at timestamp."
      }
    ],
    "sibling": [
      "disc.eventcalendarmapper"
    ],
    "external": [
      {
        "service": "CLOB API (read)",
        "endpoint": "https://clob.polymarket.com",
        "sla": "99.95% / 200ms p99",
        "failure_mode": "Use cached spread; log warning if cache is stale."
      }
    ]
  },
  "security_surfaces": {
    "signs_orders": false,
    "private_key_access": "none",
    "abuse_vectors": [
      "Injection of crafted quality scores via compromised MarketQualityRanker to manipulate queue ranking"
    ],
    "mitigations": [
      "Queue entries clamped to [0,1] score range regardless of upstream values",
      "All outputs are ObservationReports; execution layer independently validates before acting"
    ]
  },
  "failure_injection": [
    {
      "scenario": "UPSTREAM_QUALITY_REPORTS_STALE",
      "how_to_inject": "Stop disc.marketqualityranker for >2\u00d7 refresh_interval_s",
      "expected_behaviour": "Queue frozen; STALE_QUEUE ObservationReport emitted; strategies notified",
      "recovery": "Queue resumes normal operation when quality reports resume."
    },
    {
      "scenario": "KILL_SWITCH_ON",
      "how_to_inject": "Set killswitch.active=true",
      "expected_behaviour": "Queue frozen; no ObservationReports emitted",
      "recovery": "Queue unfreezes on next cycle after KillSwitch reset."
    },
    {
      "scenario": "QUEUE_EMPTIES_DUE_TO_SUPPRESSION",
      "how_to_inject": "Set suppress_existing_position=true with all markets having open positions",
      "expected_behaviour": "Queue depth=0; OpportunityQueueEmpty alert fires",
      "recovery": "Automatic when positions close or new markets become available."
    }
  ],
  "runbook": {
    "summary": "OpportunityQueue incidents are typically upstream data staleness or KillSwitch activation. Bot is read-only; incidents delay strategy opportunity discovery but do not affect active positions.",
    "oncall_actions": [
      {
        "alert": "OpportunityQueueStale",
        "first_action": "Check disc.marketqualityranker health; check KillSwitch status.",
        "escalate_to": "Intelligence pod lead after 10 minutes."
      },
      {
        "alert": "OpportunityQueueEmpty",
        "first_action": "Inspect queue evictions log; check if suppress_existing_position is causing over-suppression.",
        "escalate_to": "Intelligence pod lead after 15 minutes."
      }
    ],
    "manual_overrides": [
      {
        "name": "disable-position-suppression",
        "how": "Set suppress_existing_position=false via config update",
        "when": "Position tracker is reporting stale data causing over-suppression."
      }
    ],
    "healthcheck": "GET /internal/health/opportunityqueue \u2192 green if Queue depth > 0 for at least one strategy type; last refresh within 2\u00d7 refresh_interval_s.; red if Queue depth = 0 for all strategy types or no refresh in 2\u00d7 interval."
  },
  "promotion_gates": {
    "to_shadow": [
      {
        "gate": "Unit tests pass for queue_depth enforcement and position suppression",
        "how_measured": "CI test run",
        "threshold": "100% pass"
      }
    ],
    "to_limited_live": [
      {
        "gate": "Queue depth stable \u22655 for primary strategy type over 48h shadow",
        "how_measured": "polytraders_disc_opportunityqueue_queue_depth_gauge",
        "threshold": "p50 >= 5"
      }
    ],
    "to_general_live": [
      {
        "gate": "Zero spurious STALE_QUEUE events during normal operation over 7 days",
        "how_measured": "Alert history",
        "threshold": "0 false firings"
      }
    ]
  },
  "reporting": {
    "emits_kinds": [
      "ObservationReport"
    ],
    "topics": [
      "polytraders.reports.observation"
    ],
    "retention_class": "30d",
    "cadence": "every-event",
    "sampling_rule": "emit-every",
    "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"
  }
}