Options Strategy Council (Phase 2, observe-only)
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:
- Context analysts — Market Regime Analyst + Volatility Structure Analyst reports.
- Debate — Bull Researcher vs. Bear Researcher, using only the snapshot and the context reports.
- Risk review — Risk Analyst, using the snapshot and all prior reports.
- Manager synthesis — Options Strategy Manager produces the final
RECOMMEND_SCAN | OBSERVE | NO_TRADEdecision, 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 quality | Meaning | Permitted decision |
|---|---|---|
COMPLETE | every evidence group present | RECOMMEND_SCAN, OBSERVE, or NO_TRADE |
PARTIAL | a non-critical group missing | OBSERVE or NO_TRADE only |
UNAVAILABLE | a 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 status | Meaning |
|---|---|
AGREED | Council's recommended strategy equals MDC's selected strategy |
DISAGREED | Council and MDC selected different strategies |
MDC_NO_REGIME | MDC reached classification but selected no strategy |
COUNCIL_NO_TRADE | the active Council decision is NO_TRADE |
COUNCIL_UNAVAILABLE | no active Council decision (disabled, never run, all rows invalid, or a read failure) |
COUNCIL_EXPIRED | the newest Council decision's expires_at has passed |
NOT_COMPARABLE | the 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|SHADOWGET /api/options/v2/decision-battle/summary?mode=...&window_days=N(default7, clamped1..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.