Skip to content

feat(decision): analyst options enablement phase 1 — chain tool + IV context + doctrine + premium validation (ALP-948) - #353

Merged
jatassi merged 4 commits into
mainfrom
jackson/alp-948-analyst-options-enablement-phase-1-long-single-leg-chain
Jun 11, 2026
Merged

feat(decision): analyst options enablement phase 1 — chain tool + IV context + doctrine + premium validation (ALP-948)#353
jatassi merged 4 commits into
mainfrom
jackson/alp-948-analyst-options-enablement-phase-1-long-single-leg-chain

Conversation

@jatassi

@jatassi jatassi commented Jun 11, 2026

Copy link
Copy Markdown
Owner

Summary

  • Makes the analyst able to propose a valid long single-leg option in production: every recorded invocation ran options-enabled yet all 35 archived proposals were equity — no contract-level data, no doctrine, no premium validation.
  • New retrieve_options_chain MCP tool backed by a chain-slice read over options_contract_snapshots (config-owned strike band / DTE window / OI floor / contract cap; drops named in an omitted note), mounted for analyst and strategist; subprocess transport via a pickle shim rehydrated in the worker.
  • Per-ticker IV context (read-only q3 IV-rank reuse — no baseline upsert — plus liquid expirations) decorates the analyst's REFERENCE PRICES lines; analyst prompt gains structural when-options doctrine, an NBBO anchoring rule, and a worked long single-leg example; strategist prompt gains the held-option marks note.
  • validate_analyst_output gains three InstrumentOption rejections (option_contract_unknown, option_premium_staleness, option_expiration_before_deadline) anchored to the same chain reader the tool mounts; new options_chain config section (15/5/45/100/24/10) with resolved-config snapshot re-pin. Reader + context are gated on options_enabled and pipeline mode.

Closes https://linear.app/alphamind-jatassi/issue/ALP-948/analyst-options-enablement-phase-1-long-single-leg-chain-tool-iv

Test plan

  • CI (.github/workflows/ci.yml) green on the PR — lint on Linux + full pytest on Windows
  • Local lint chain clean (ruff check, ruff format --check, mypy, lint-imports)
  • ALP-948 acceptance criteria all met (scoped suites: tests/decision/, tests/state/, tests/config/, tests/scheduler/, tests/pipeline/, tests/risk_guardrails/ — 3,800+ tests green)

🤖 Generated with Claude Code

jatassi and others added 2 commits June 10, 2026 21:14
…ontext, doctrine, premium validation (ALP-948)

Make the analyst able to propose a valid long single-leg option in
production. Every recorded invocation ran options-enabled, yet all 35
archived proposals were equity — the analyst had no contract-level
options data, no doctrine, and no premium validation. Hybrid delivery
per the 2026-06-10 design brief:

- (A) src/alphamind/state/repository/options_chain_read.py: pure
  select_chain_slice over ContractQuote tuples (config-owned strike
  band / DTE window / OI floor / contract cap, drops named in
  ChainSlice.omitted), OptionsChainReader Protocol, SqlOptionsChainReader
  over options_contract_snapshots (latest snapshot per contract,
  sql_option_price_provider session conventions).
- (B) retrieve_options_chain MCP tool (options_chain_tool_mcp.py),
  mounted for analyst + strategist: harness wiring + prefixes,
  runner tool-name tuples, AVAILABLE TOOLS render. Single required
  input: underlying. No-chain-data returns an explicit message.
  Subprocess transport via _OptionsChainReaderShim; the worker
  rehydrates a real reader against its own DATABASE_PATH session.
- (C) TickerOptionsContext (IV rank via read-only q3 compute reuse —
  no baseline upsert — plus filter-surviving expirations) rendered
  into the analyst REFERENCE PRICES line as
  `TICKER: price | IVr NN | exp: MM-DD`; threaded from the
  orchestrator alongside the reader.
- (D) analyst prompt: structural when-options doctrine (no numeric
  thresholds), NBBO anchoring rule, worked long single-leg example
  consistent with a chain-tool excerpt; strategist prompt: held-option
  current-marks note.
- (E) validate_analyst_output gains an OptionsChainReader and three
  InstrumentOption rejections: option_contract_unknown,
  option_premium_staleness (entry limit vs NBBO midpoint, config
  tolerance), option_expiration_before_deadline.
- (F) options_chain config section (models/YAML/resolver,
  15/5/45/100/24/10) + resolved-config snapshot re-pin.

Closes ALP-948.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Confirmed findings from the high-effort code-review pass:

- Gate the options chain reader + context on the resolved profile's
  options_enabled flag (tool unmounted + validator quote checks inert
  when options are disabled, matching the prompt's Hard blocks signal),
  and skip the per-ticker context build in halt mode where the
  watchlist bundle never renders it.
- latest_quote now selects the latest USABLE snapshot (SQL filter
  mirroring _quote_from_row) so a newer garbage row cannot mask the
  older usable quote the chain tool anchored against.
- _quote_from_row and the chain_slice spot extraction read row fields
  by column label instead of position.
- Reference-price render keeps the bare price line for a context with
  no surviving expirations (no dangling "exp:").
- options_context shares one session across the chain read and the
  IV-history read (halves per-ticker session count).
- Dedup: shared decision/_shared.surface_tool_names replaces the
  per-runner _tool_names copies; _parse_snapshot_ts imported from
  sql_option_price_provider; ChainFilterParams.from_config replaces
  the orchestrator's manual field unpack; _build_decision_kwargs uses
  concrete types for the new params.

Refuted (no action, evidence on file): options_context subprocess-drop
claim (rendered into user_message in the parent), Z-suffix timestamp
drift (mirrors the canonical q3 caller), strike>=100k OCC overflow
(graceful rejection; no such strikes in universe), shim private-import
fragility (established _SqlIvProviderShim convention), unconditional
expiration check (pure data check), raw tool-name constant misuse (no
such consumer), q3 IV-constant promotion (blocked by magic-number
audit; documented restatement stands).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@linear

linear Bot commented Jun 11, 2026

Copy link
Copy Markdown
ALP-948 Analyst options enablement (phase 1, long single-leg): chain tool + IV context in reference prices + doctrine + premium validation

Analyst options enablement — phase 1: long single-leg

Goal

Make the analyst able to propose a valid long single-leg option (call or put, net-debit) in production. The 2026-06-10 investigation established the gap end-to-end. Every recorded production invocation ran with options_enabled: true (medium profile; invocations.feature_flags_snapshot_json in both the live and pre-cutover DBs), the guardrail header renders an Options headroom block every invocation, and all 35 analyst proposals across all archived invocations are "asset_type": "equity". The analyst's context contains no contract-level options data — its only tools are validate_guardrail and retrieve_brief — and prompts/decision/analyst.md carries no options doctrine and an equity-only worked example (line 141). Everything downstream (guardrail greeks rules with BS + IV sourcing, OpenCommand floor and resting-limit validators, OMS options dispatch, OCC symbol construction) is complete and unexercised.

Operator-decided design (2026-06-10 design brief): hybrid delivery — push per-ticker IV context into REFERENCE PRICES, pull contract detail via a new chain tool — and phase-1 scope of long single-leg only. The four touchpoints below deliver value only together: a chain tool without doctrine sits unused; doctrine without data invites fabricated premiums.

Depends on

Nothing open. Merged substrate this builds on: ALP-856 (broker-enforced options capital floor), ALP-866 (options entries constrained to resting orders), distillation q3 ATM-IV baseline + rank compute.

Reading

  • docs/design/04-decision-layer/analyst.md — analyst role, conviction scale; the doctrine in (D) extends this agent's prompt
  • docs/design/04-decision-layer/analyst-output-schema.md — the InstrumentOption proposal fields
  • src/alphamind/decision/analyst/models.py:90-146 — as-built InstrumentOption / InstrumentStrategy discriminated union; EntryOrder at 154; premium_at_risk at 199
  • src/alphamind/decision/analyst/input_bundle.py:40-170 — REFERENCE PRICES render (_render_reference_prices, assemble_input_bundle_normal); the (C) extension lands here
  • src/alphamind/decision/analyst/runner.py:79-80 and :211 and :275 — allowed-tools list, underlying_prices threading, bundle-assembly call; (B) and (C) mount points
  • src/alphamind/decision/analyst/harness.py:84-92 — MCP server prefixes the harness permits
  • src/alphamind/decision/analyst/validation.py:76-83, 314-329, 452-512 — the ALP-742 equity reference-price staleness check the (E) options checks mirror; note non-equity instruments are currently skipped at 329
  • src/alphamind/risk_guardrails/state_delivery/validation_tool_mcp.py — the per-invocation SDK MCP server factory pattern (B) mirrors
  • src/alphamind/analysis/synthesizer/retrieval_tools.py — second tool-server precedent
  • src/alphamind/state/repository/sql_option_price_provider.py — existing state-layer read over options snapshots; (A) follows its conventions
  • src/alphamind/persistence/models.py:743, 767-801DistillationTickerBaseline (composite key ticker/baseline_kind/as_of; kind "atm_iv" per _BASELINE_KINDS) and OptionsContractSnapshots
  • src/alphamind/distillation/q3/atm_iv_baseline_compute.py:48-115 — pure compute_atm_iv_baseline(s) returning AtmIvBaselineResult.rank: CalibratedValue; (C) reuses this read-only
  • src/alphamind/distillation/q3/atm_iv_baseline_loaders.py:157-165 — how the refreshing loader queries baseline rows (ATM_IV_BASELINE_KIND); the (C) loader copies the query shape without the upsert
  • src/alphamind/scheduler/fill_collection_inputs.py:551-575underlying_prices merge the (C) options-context mapping parallels
  • src/alphamind/scheduler/orchestrator.py:891 — where market inputs thread into the decision stage
  • config/collector_schedule.yaml:10 — options snapshot cadence (twice hourly during RTH); sets quote-age expectations
  • prompts/decision/analyst.md and prompts/decision/strategist.md — the prompts (D) extends
  • tests/config/test_snapshot.py — resolved-config snapshot re-pin required by (F)

Scope

(A) Chain-slice read + selection core. New module src/alphamind/state/repository/options_chain_read.py. Types: OccSymbol = NewType("OccSymbol", str); frozen slotted dataclass ContractQuote with fields occ_symbol: OccSymbol, underlying: str, expiration: date, strike: float, contract_type: Literal["call", "put"], bid: float, ask: float, implied_volatility: float, delta: float, open_interest: int, snapshot_ts: datetime (aware); frozen slotted dataclass ChainSlice with underlying: str, spot: float, as_of: datetime, contracts: tuple[ContractQuote, ...], omitted: str | None (human-readable note naming what the filters dropped — never silently truncate). Pure function select_chain_slice(rows: tuple[ContractQuote, ...], *, spot: float, params: ChainFilterParams) -> ChainSlice applies the strike band, expiration window, open-interest floor, and contract cap from config. Protocol OptionsChainReader with chain_slice(underlying: str) -> ChainSlice | None, latest_quote(occ: OccSymbol) -> ContractQuote | None, and options_context(underlying: str) -> TickerOptionsContext | None. Implementation SqlOptionsChainReader reads the latest snapshot per contract for an underlying from options_contract_snapshots, following the session conventions of sql_option_price_provider.py.

(B) MCP tool retrieve_options_chain. New module src/alphamind/state/repository/options_chain_tool_mcp.py exporting build_retrieve_options_chain_mcp_server(reader: OptionsChainReader), mirroring the per-invocation factory shape of build_validate_guardrail_mcp_server. Tool input schema has exactly one required string property underlying — filter values are config-owned, never LLM-supplied. The result renders one header line (underlying, spot, as-of) plus one line per contract (OCC symbol, expiration, strike, C/P, bid/ask, IV, delta, OI, snapshot age) plus the omitted note. An underlying with no usable chain data returns an explicit no-chain-data message, not an error. Mount for analyst and strategist: extend the allowed-tools tuples (decision/analyst/runner.py:79-80, strategist equivalent), the harness prefix lists (analyst/harness.py:91-92, strategist equivalent), and the AVAILABLE TOOLS render in input_bundle.py.

(C) Per-ticker options context in REFERENCE PRICES. Frozen slotted dataclass TickerOptionsContext with iv_rank: float | None and liquid_expirations: tuple[date, ...], defined in options_chain_read.py. The iv_rank value reuses the q3 pure compute: a read-only load of the "atm_iv" baseline rows plus the latest ATM IV, fed to compute_atm_iv_baseline — explicitly not load_and_refresh_atm_iv_baselines, which upserts and must not run twice per invocation. liquid_expirations are the expirations surviving the (A) filters. assemble_input_bundle_normal (input_bundle.py:50) gains a parameter options_context: Mapping[str, TickerOptionsContext]; the per-ticker line renders as TICKER: price | IVr NN | exp: MM-DD, MM-DD when context exists and stays the bare price line when it does not. Thread the mapping from the scheduler alongside underlying_prices (fill_collection_inputs.py:551-575orchestrator.py:891runner.py:211); analyst, strategist, and PM share the bundle function, so all three see the line.

(D) Prompt doctrine. prompts/decision/analyst.md gains three additions. First, when-options guidance stated structurally — a long single-leg option expresses a directional thesis with defined risk (worst case = premium paid), read IV context from the IVr column before choosing the instrument, prefer the equity expression when the options surface gives no edge — with no numeric thresholds anywhere in the prose. Second, a worked long single-leg JSON example parallel to the existing equity example at line 141: resting limit entry, pl-denominated target, capital_protection_floor, and a hard invalidation leg, with premiums consistent with a chain-tool result shown alongside. Third, the anchoring rule: entry limit and floor premiums anchor to the chain tool's NBBO exactly as equity brackets anchor to REFERENCE PRICES. prompts/decision/strategist.md gains the minimal held-option note: managing an open single-leg position uses the same chain tool for current marks.

(E) Options validation closure. decision/analyst/validation.py gains InstrumentOption checks mirroring the ALP-742 staleness-check shape (validation.py:478-512), with an OptionsChainReader injected as a new parameter of validate_analyst_output. Three rejections, each with its own rule id following the existing validation_results naming conventions: the proposed contract has no resolvable latest quote; the entry limit premium drifts beyond the configured tolerance from the latest NBBO midpoint; the expiration falls before the entry-window deadline. Non-equity recommendations stop being skipped at validation.py:329 for these checks.

(F) Config. New options_chain section in the config models, YAML, and resolver with keys strike_band_pct, min_days_to_expiration, max_days_to_expiration, min_open_interest, max_contracts_rendered, premium_staleness_tolerance_pct. Starting values 15 / 5 / 45 / 100 / 24 / 10 — operator-tunable configuration, not contract. Adding the section shifts the resolved-config snapshot hash; re-pin tests/config/test_snapshot.py.

Out of scope

Multi-leg strategies — InstrumentStrategy stays schema-supported but receives no doctrine, no validation, and no tool affordance in this issue. Mounting the chain tool for the PM. Live broker quotes at decision or dispatch time — the collector snapshot is the premium anchor; systematic non-fills would reopen this in the execution layer, not here. Short or net-credit options doctrine.

Acceptance criteria

  • select_chain_slice filters to the configured strike band, expiration window, and open-interest floor, and the returned ChainSlice.omitted names any dropped contracts — unit-tested on plain ContractQuote tuples with no DB session
  • SqlOptionsChainReader.chain_slice returns the latest snapshot per contract for an underlying from options_contract_snapshots, tested against a seeded SQLite database
  • retrieve_options_chain renders the per-contract lines (OCC symbol, expiration, strike, type, NBBO, IV, delta, OI, snapshot age) for an underlying with data, and the explicit no-chain-data message for one without
  • The tool is callable by analyst and strategist — allowed-tools tuples, harness prefixes, and the AVAILABLE TOOLS bundle line all include it
  • A REFERENCE PRICES line renders price, IV rank, and liquid expirations for a ticker with options context, and the bare price line for a ticker without
  • Assembling options_context performs no write to distillation_ticker_baseline (read-only IV-rank reuse)
  • The analyst prompt contains the when-options doctrine free of numeric thresholds, the worked single-leg example, and the NBBO anchoring rule; the strategist prompt contains the held-option note
  • validate_analyst_output rejects an InstrumentOption recommendation for an unknown contract, an out-of-tolerance premium, and an expiration before the entry-window deadline — three distinct rule ids, each covered by a test that fails for that reason alone
  • The options_chain config section resolves end-to-end and tests/config/test_snapshot.py is re-pinned
  • Lint chain green: ruff check, ruff format, mypy, lint-imports

Verification

Run uv run pytest tests/decision/ -n auto (the full directory — new cross-field validator parameters have historically broken fixtures across many decision-test files, not just the named ones), uv run pytest tests/state/ -n auto, and uv run pytest tests/config/test_snapshot.py. Lint chain per CLAUDE.md. The prompt-doctrine criterion is verified by inspection at review (structural framing, no numeric anchors, example consistency with the chain data shown beside it); all other criteria are test-verified. CI on Windows remains the authoritative full-suite gate.

Review in Linear

jatassi and others added 2 commits June 10, 2026 21:41
- [blocker] Restore the decision-not-execution import contract: inline
  _parse_snapshot_ts in options_chain_read instead of importing it from
  sql_option_price_provider, whose transitive sql_repository import
  reaches execution-layer aggregates (same kept-local rationale as the
  greeks_refresh iv_provider copy). lint-imports: 12 kept, 0 broken.
- [suggested] The analyst/strategist prompts now state that
  retrieve_options_chain is mounted only when the active profile
  enables options and that the per-turn AVAILABLE TOOLS block is the
  authoritative surface — the static prompt no longer contradicts the
  tool list on options-disabled profiles (small/micro).

Rejected nits with reason: non-OCC contract_ticker surfacing as
option_contract_unknown (corner data-quality case; redraft guidance
still actionable), no snapshot-staleness floor (phase-1 intentional —
age is rendered per contract and the validator anchors to the same
snapshot the tool showed), _NoChainReader duplicated across two test
modules (reviewer-acknowledged acceptable).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test_same_regime_in_window_is_not_superseded and
test_reports_zero_when_nothing_supersedes watched the real
prompts/decision/strategist.md path with repo_root unpinned, so
detect_supersessions walked the LIVE repo's git log — any genuine
commit to that prompt inside the hard-coded 2026-06-01..06-22 window
fires concurrent_edit_on_watched_artifact and breaks both tests.
ALP-948's prompt doctrine edits surfaced this: the full suite failed
in the worktree and would have failed on main right after merge.

Both tests now watch a never-tracked artifact path, so the trigger
cannot fire spuriously regardless of real prompt activity; the other
zero-marked tests are unaffected (their rows are filtered out before
the git trigger evaluates).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jatassi
jatassi merged commit ac2e6dc into main Jun 11, 2026
0 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant