Decision Battle (v2 observability)
Decision Battle is the per-scan-cycle evidence ledger for the option system. Every
run_scan_cycle() call writes exactly one row to strategy_battle_log,
answering: which global gates were reached, did any of them block or error, what did MDC
classify, which sub-scanner was selected and was it invoked, did it produce a candidate or
reject or error, was a produced candidate blocked afterward, was execution attempted, and did
it create, get rejected, or fail. This is Phase 1 — observability only. It does
not change what the trading system decides or executes, and it introduces no new scanner
invocations, broker calls, thresholds, or cadence.
options_decision_battle.v2
schema. As of Phase 2, an advisory, strictly OBSERVE_ONLY multi-agent
Options Strategy Council is attached to every scan
cycle: council_comparison is null only when the Council is disabled,
has never produced a valid decision, or its latest decision has expired
(comparison_status = COUNCIL_UNAVAILABLE in that case, not a missing field). The
Council never changes cycle status, MDC's selection, scanner results, or execution attribution —
see the Council page for the full comparison semantics and the seven possible
comparison_status values.
Row lifecycle
BattleAccumulator is constructed immediately after
metrics.record_scan_cycle() — before the kill-switch check, the very first line of
the cycle — and pre-seeded with a neutral NOT_REACHED / NOT_INVOKED
entry for every strategy in the registry (DC, DCS,
DCS_SKEW, DDC, IC, MDC,
AI_MARKET, REGIMETA). The rest of the cycle runs inside a
try/except(set ERROR, re-raise)/finally(flush) wrapper, so exactly one row is
written per cycle regardless of which gate blocks, or whether an unhandled exception occurs —
including cycles that are blocked before any strategy is ever scanned. A Battle persistence
failure is isolated inside its own nested try/except in the finally
block; it is logged and never allowed to replace an in-flight trading or scanner exception that
was already propagating.
Top-level payload
| Field | Meaning |
|---|---|
schema_version | "options_decision_battle.v2" |
cycle | { id, started_at, finished_at, status } — status is one of COMPLETED, BLOCKED_PRE_SCAN, PAUSED, NO_REGIME, NO_CANDIDATE, CANDIDATE_BLOCKED, EXECUTION_ATTEMPTED, ERROR |
global_gates | ordered list of every gate the cycle reached this run, see below |
market_context | deterministic already-available data (spot, IV, TA snapshot, etc.) — no new broker calls made to populate it |
strategies | per-strategy record, one entry per registered strategy, see below |
mdc_decision | MDC's structured classification snapshot, or null if MDC never classified this cycle |
council_comparison | Phase 2: additive observe-only comparison against the latest valid Options Strategy Council decision, or null when the Council has never produced a comparable decision for this cycle |
execution_summary | { attempted, per_strategy } roll-up of the execution attribution below |
Global gate records
Each gate the cycle reaches — whether it allows or blocks — is recorded with a typed reason code, independent of whether a strategy was ever scanned:
{
"gate": "BROKER_HEALTH",
"reached": true,
"outcome": "ALLOW" | "BLOCK" | "ERROR",
"reason_code": "BROKER_HEALTH" | null,
"reason": "...",
"details": {},
"evaluated_at": "2026-07-17T10:34:12+05:30"
}
| Gate | Reason code |
|---|---|
| Kill switch | KILL_SWITCH |
| Preflight balance check | PREFLIGHT_BALANCE |
| Broker health | BROKER_HEALTH |
| Risk envelope | RISK_ENVELOPE |
Operator pause (/stop_today) | OPERATOR_PAUSE |
| Concurrent live-trade cap | CONCURRENT_TRADE_CAP |
| AI new-entry gate | AI_NEW_ENTRY_GATE |
| MDC same-cycle re-entry guard | SAME_CYCLE_REENTRY |
| Event guard (MDC / IC) | EVENT_GUARD |
| Reconciliation guard (MDC / IC) | RECONCILIATION_GUARD |
| Margin cap | MARGIN_GUARD |
| Portfolio Greeks gate | PORTFOLIO_GREEKS |
| RegimeTA freshness / AI-verdict gate | REGIME_TA_GATE |
reason field records
only the exception's class name (e.g. "RuntimeError raised during preflight"),
never the raw exception text — broker-facing exceptions can embed request/response bodies.
Per-strategy state
scanner_invoked = false is not the same as
eligible = false: a strategy that was never selected this cycle (e.g. DCS when MDC
classified DC) is distinct from a strategy that was selected and then rejected by its scanner,
which is distinct again from one whose candidate was blocked afterward by margin or Greeks.
| Field | Values |
|---|---|
registered | always true for every entry present in the row |
enabled | bool | null |
considered_by_classifier | bool — did MDC's classifier evaluate this strategy as a candidate regime |
classifier_status | SELECTED | REJECTED | NOT_APPLICABLE | NOT_REACHED | null |
scanner_invoked | bool — was the strategy's own scanner actually called this cycle |
scanner_result | CANDIDATE | REJECTED | ERROR | NOT_INVOKED |
candidate_produced | bool |
post_scanner_status | ALLOWED | BLOCKED | NOT_REACHED — margin cap or portfolio-Greeks gate outcome after a candidate was produced |
execution_attempted | bool |
execution_result | CREATED | REJECTED | FAILED | NOT_ATTEMPTED | null — maps TradeLifecycle.execute_entry()'s declared bool return: True → CREATED, anything else → REJECTED, a raised exception → FAILED (re-raised unchanged to the strategy's own existing exception handler, never swallowed) |
strategy_mode | LIVE | DRY_RUN | SHADOW | null — resolved through the strategy's own go_live_key, not its registry name. DCS_SKEW's go-live key is SDCS; every other strategy's go-live key equals its registry name. |
Legacy eligible projection
Every existing consumer (the /battle page, build_trade_journal_row(),
the AI debrief context) reads three fields that predate v2: eligible,
signal_price, and reason, plus selected and
classifier_rejections on the MDC entry. These are preserved on every
v2 row as a derived compatibility projection —
eligible = candidate_produced — so no existing reader needed to change. New code
should read the explicit v2 fields above instead of re-deriving intent from
eligible alone.
mdc_decision
MDC classifies a market regime (DC, DCS, DCS_SKEW,
DDC, or IC) and then delegates to exactly one matching sub-scanner.
MasterDCScanner.last_decision() returns a deep-copied snapshot of that
classification, independent of whether the delegated sub-scanner then produced a candidate,
rejected, or raised — so the Battle row can distinguish "MDC selected DCS but DCS rejected"
from "MDC never considered DCS this cycle". The snapshot is reset at the start of every
scan() call, including every early return before classification runs (invalid
scale-in anchor, max concurrency, missing option chain, scale-in gate rejection, missing ATM
strike), so a cycle that never reaches classification never reports a stale regime carried over
from an earlier cycle.
{
"classified_regime": "DC" | null,
"selected_strategy": "DC" | null,
"classifier_inputs": { "spot": 24500.0, "iv": 18.2, "..." : "..." },
"classifier_rejections": { "DDC": "Trend below threshold" },
"sub_scanner_invoked": true,
"sub_scanner_result": "CANDIDATE" | "REJECTED" | "ERROR" | "NOT_INVOKED",
"sub_scanner_reason_code": "no_regime" | "sub_scanner_rejected" | "sub_scanner_exception" | null,
"sub_scanner_reason": "...",
"decision_as_of": "2026-07-17T10:34:12+05:30"
}
Battle page rendering
/battle distinguishes the four outcome families instead of collapsing every
eligible = false entry into one red rejection badge:
| Condition | Badge |
|---|---|
scanner_result = CANDIDATE or execution_result = CREATED | green |
scanner_result = REJECTED or execution_result = REJECTED | FAILED | red |
scanner_result = ERROR | amber (distinct icon from a margin/Greeks block) |
post_scanner_status = BLOCKED | amber |
scanner_result = NOT_INVOKED or classifier_status = NOT_REACHED | grey — neutral, not a failure |
A historical v1 row (no v2 fields present on the entry) renders exactly as it always has —
eligible true/false mapping to green/red.
API surface
GET /api/battle/log and GET /api/battle/summary are unchanged from
Phase 1. The v2 payload keys (schema_version, cycle,
global_gates, market_context, mdc_decision,
council_comparison, execution_summary) are additive on
GET /api/battle/log's response when the underlying row has them; a v1 row's
response is byte-for-byte what it was before this change. list_battle_summary()'s
aggregation is unaffected — it only ever reads payload["strategies"].
Phase 2 adds two new, separate, read-only endpoints that project this same evidence alongside
the Council's own decisions for the Cockpit's dedicated Decision Battle section:
GET /api/options/v2/decision-battle/latest and
GET /api/options/v2/decision-battle/summary. See the
Options Strategy Council page for their contracts.
Retention
Unchanged: Config.BATTLE_LOG_RETENTION_DAYS = 60, pruned on every
append_battle_log() insert.