Options Strategy Council

Options Strategy Council (Phase 2, observe-only)

Authority: OBSERVE_ONLY, always. The Council explains, ranks, and compares strategies. It never places, approves, constructs, or modifies an order; never chooses broker security IDs, quantities, or strikes; never invokes a scanner; never alters MDC classification, routing, or scan/WebSocket/broker cadence; never changes a DRY_RUN default. Every persisted decision re-validates its own strict schema and expiry before it can ever be read as "active" — a malformed or expired row is silently treated as unavailable, never as approval.

What it does

On a fixed 30-minute schedule (disabled by default via OPTIONS_COUNCIL_ENABLED = False), a bounded four-call native Python orchestrator runs against a deterministic, hashed, read-only snapshot of already-available market/portfolio/strategy-registry evidence:

  1. Context analysts — Market Regime Analyst + Volatility Structure Analyst reports.
  2. Debate — Bull Researcher vs. Bear Researcher, using only the snapshot and the context reports.
  3. Risk review — Risk Analyst, using the snapshot and all prior reports.
  4. Manager synthesis — Options Strategy Manager produces the final RECOMMEND_SCAN | OBSERVE | NO_TRADE decision, referencing every registered strategy through the live strategy registry (unknown strategy names are rejected, not coerced).

This is exactly one bounded debate round per run — OPTIONS_COUNCIL_MAX_DEBATE_ROUNDS is fixed at 1; a different value is rejected rather than silently creating extra calls. The transport is the existing CodexRunner (arb_bot/ai/codex_runner.py) — no new LLM provider, HTTP client, or agent framework was introduced.

Prompt/schema correction (2026-07-22). arb_bot/options_council/prompts.py originally described each stage only at a high level (e.g. "Produce two strict JSON reports") without ever naming the required CouncilAgentReport/OptionsCouncilDecision field names. Every scheduled run failed closed with REJECTED_SCHEMA because the model filled in its own plausible-looking keys instead of the contract's actual ones. All four stage prompts now enumerate every required field name explicitly, and OptionsCouncilOrchestrator passes orchestrator_version/model into the per-stage message context (previously only prompt_version was echoed) so the manager stage can copy them back verbatim. tests/options_council/test_prompts.py now asserts every prompt names every field of its target contract, guarding against this regressing silently again.

Snapshot contract (options_council_snapshot.v1)

A snapshot is built solely from already-persisted, cached, or already-approved evidence — never a new broker or scanner call. It is canonical UTF-8 JSON with sorted keys and stable separators, hashed with SHA-256; equivalent dict insertion order always produces an identical hash. Nullable fields remain nullable; unavailable numeric values are never replaced with zero. No token, credential, security ID, or raw broker payload can appear in a snapshot — the builder rejects any evidence field whose key matches a forbidden pattern (token, credential, order, security_id, broker_response, ...).

Data qualityMeaningPermitted decision
COMPLETEevery evidence group presentRECOMMEND_SCAN, OBSERVE, or NO_TRADE
PARTIALa non-critical group missingOBSERVE or NO_TRADE only
UNAVAILABLEa critical group missing (underlying, market, volatility, or strategy-registry context)OBSERVE or NO_TRADE only

Decision contract (options_council.v1)

Every field is strict — unknown fields are rejected outright (Pydantic extra="forbid"). authority must be exactly OBSERVE_ONLY; any other value fails validation. confidence must be finite and within [0, 1]. NO_TRADE requires a null recommended_strategy; RECOMMEND_SCAN requires a non-null, registry-known strategy and COMPLETE data quality. Aliases (for example SDCS) are normalized to the canonical registry name (DCS_SKEW) before persistence. Duplicate rankings, duplicate alternatives, and duplicate role reports are all rejected. All six required role reports (MARKET_REGIME_ANALYST, VOLATILITY_STRUCTURE_ANALYST, BULL_RESEARCHER, BEAR_RESEARCHER, RISK_ANALYST, OPTIONS_STRATEGY_MANAGER) must be present exactly once.

Persistence and expiry

Every run — successful or not — is persisted to options_council_decisions (db/051_options_council.sql) with one of five statuses: SUCCEEDED, FAILED, EXPIRED, REJECTED_SCHEMA, REJECTED_DATA_QUALITY. A row can only ever become the "active" decision when all of the following hold simultaneously: status = SUCCEEDED, expires_at > now, the stored JSON re-validates against the strict decision schema, and the stored snapshot_id/snapshot_hash match the decision's own claimed values byte-for-byte. Any mismatch — malformed JSON, an expired timestamp, a hash that no longer matches, an authority other than OBSERVE_ONLY — makes that row permanently inactive; the reader falls back to the next-newest valid row, or to no active decision at all. A default OPTIONS_COUNCIL_DECISION_TTL_MIN = 35 means a decision naturally expires shortly after the next scheduled 30-minute run, so a scan cycle is never compared against a stale recommendation. Retention is OPTIONS_COUNCIL_RETENTION_DAYS = 180, pruned independently of the 60-day Battle log retention.

Persistence failures are isolated from trading: a write or read exception is caught, logged, and mapped to COUNCIL_UNAVAILABLE wherever the Council would otherwise be consulted. It can never become an implicit approval.

Comparison with MDC (Decision Battle council_comparison)

At the end of every scan cycle, ArbitrageBot._attach_council_comparison() performs exactly one bounded database read (the latest strictly-valid Council decision) and one pure comparator call (arb_bot.options_council.comparison.compare_council_to_battle) — no scanner, broker, runner, or order call is reachable from this path. The result is attached to the existing council_comparison field on the options_decision_battle.v2 row (reserved as null since Phase 1; see the Decision Battle page).

Comparison statusMeaning
AGREEDCouncil's recommended strategy equals MDC's selected strategy
DISAGREEDCouncil and MDC selected different strategies
MDC_NO_REGIMEMDC reached classification but selected no strategy
COUNCIL_NO_TRADEthe active Council decision is NO_TRADE
COUNCIL_UNAVAILABLEno active Council decision (disabled, never run, all rows invalid, or a read failure)
COUNCIL_EXPIREDthe newest Council decision's expires_at has passed
NOT_COMPARABLEthe cycle never reached an MDC decision to compare against

The comparison is observational only. It never changes the cycle's terminal status, MDC's selection, scanner results, or execution attribution — those are all derived earlier in the cycle and read-only by the time the comparator runs.

Read-only API

Two authenticated GET-only endpoints expose the evidence to the Cockpit:

  • GET /api/options/v2/decision-battle/latest?mode=LIVE|DRY_RUN|SHADOW
  • GET /api/options/v2/decision-battle/summary?mode=...&window_days=N (default 7, clamped 1..90)

Both responses use strict Pydantic contracts in arb_bot/option_workspace/contracts.py (OptionsDecisionBattleLatestResponse, OptionsDecisionBattleSummaryResponse). mode_projection.execution_approved is pinned to the literal false in both the backend model and the frontend TypeScript type — the wire contract itself cannot represent a claim of execution approval. A missing or unreadable Council row maps to council_status = COUNCIL_UNAVAILABLE inside a normal 200 response, never an HTTP failure.

Option Cockpit section

The Option Cockpit renders a Decision Battle section labelled GLOBAL ADVISORY / OBSERVE ONLY, positioned after Safety & Readiness and before Performance. It issues two independent GET requests (latest cycle, rolling summary) with their own source/freshness pills, loading/empty/error/retry states. A Council-strategy's scanner evidence renders as one of Not invoked this cycle, Candidate produced, Scanner rejected, or Invoked — outcome not tracked for a non-selected strategy, derived from the comparator's own fields — never inferred beyond what the backend actually observed. Null values render as an em dash, never zero. A footer link deep-links to /battle. No execution or settings control is rendered anywhere in the section.

Rollback

The entire feature is gated by OPTIONS_COUNCIL_ENABLED = False (default). Setting it back to False stops the scheduled job from registering on the next restart; no in-flight state needs to be cleaned up, because a disabled Council never writes new rows and an expired row can never become active. Removing the Cockpit section or the two read-only endpoints is a purely additive revert — no other Cockpit, Battle, scanner, or execution code path depends on their presence.

TradingAgents attribution

The four-stage debate topology (context analysts → bull/bear debate → risk review → manager synthesis) is a conceptual adaptation of the multi-agent debate pattern popularized by TauricResearch/TradingAgents (commit 01477f9afb7a47b849ed4c9259d3a9a4738d9fda, tag v0.3.1, Apache-2.0). No TradingAgents source file was copied or transcribed; this implementation uses the existing CodexRunner transport, entirely new Pydantic contracts, and a from-scratch native Python orchestrator with no LangGraph, LangChain, or TradingAgents runtime dependency. Because no source was copied or materially adapted line-for-line, no THIRD_PARTY_NOTICES.md entry is required per the project's attribution policy; this page documents the conceptual pin for traceability.