Decision Battle

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.

Phase 1 + Phase 2 scope. This page documents the 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

FieldMeaning
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_gatesordered list of every gate the cycle reached this run, see below
market_contextdeterministic already-available data (spot, IV, TA snapshot, etc.) — no new broker calls made to populate it
strategiesper-strategy record, one entry per registered strategy, see below
mdc_decisionMDC's structured classification snapshot, or null if MDC never classified this cycle
council_comparisonPhase 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"
}
GateReason code
Kill switchKILL_SWITCH
Preflight balance checkPREFLIGHT_BALANCE
Broker healthBROKER_HEALTH
Risk envelopeRISK_ENVELOPE
Operator pause (/stop_today)OPERATOR_PAUSE
Concurrent live-trade capCONCURRENT_TRADE_CAP
AI new-entry gateAI_NEW_ENTRY_GATE
MDC same-cycle re-entry guardSAME_CYCLE_REENTRY
Event guard (MDC / IC)EVENT_GUARD
Reconciliation guard (MDC / IC)RECONCILIATION_GUARD
Margin capMARGIN_GUARD
Portfolio Greeks gatePORTFOLIO_GREEKS
RegimeTA freshness / AI-verdict gateREGIME_TA_GATE
No sensitive data. A preflight exception's 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.

FieldValues
registeredalways true for every entry present in the row
enabledbool | null
considered_by_classifierbool — did MDC's classifier evaluate this strategy as a candidate regime
classifier_statusSELECTED | REJECTED | NOT_APPLICABLE | NOT_REACHED | null
scanner_invokedbool — was the strategy's own scanner actually called this cycle
scanner_resultCANDIDATE | REJECTED | ERROR | NOT_INVOKED
candidate_producedbool
post_scanner_statusALLOWED | BLOCKED | NOT_REACHED — margin cap or portfolio-Greeks gate outcome after a candidate was produced
execution_attemptedbool
execution_resultCREATED | 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_modeLIVE | 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:

ConditionBadge
scanner_result = CANDIDATE or execution_result = CREATEDgreen
scanner_result = REJECTED or execution_result = REJECTED | FAILEDred
scanner_result = ERRORamber (distinct icon from a margin/Greeks block)
post_scanner_status = BLOCKEDamber
scanner_result = NOT_INVOKED or classifier_status = NOT_REACHEDgrey — 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.