From 1e10ba90f2b0aa5e9ddc6643599e3c9f8d910d86 Mon Sep 17 00:00:00 2001 From: Jackson Atassi Date: Wed, 10 Jun 2026 21:14:33 -0600 Subject: [PATCH 1/4] =?UTF-8?q?feat(decision):=20analyst=20options=20enabl?= =?UTF-8?q?ement=20phase=201=20=E2=80=94=20chain=20tool,=20IV=20context,?= =?UTF-8?q?=20doctrine,=20premium=20validation=20(ALP-948)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- config/options_chain.yaml | 15 + prompts/decision/analyst.md | 107 +++- prompts/decision/strategist.md | 6 +- src/alphamind/analysis/_sdk_subprocess.py | 57 ++ .../analysis/_sdk_subprocess_worker.py | 31 ++ src/alphamind/config/load.py | 4 + src/alphamind/config/models/__init__.py | 2 + src/alphamind/config/models/options_chain.py | 34 ++ src/alphamind/config/resolver.py | 5 + src/alphamind/decision/analyst/harness.py | 51 +- .../decision/analyst/input_bundle.py | 34 +- src/alphamind/decision/analyst/runner.py | 52 +- src/alphamind/decision/analyst/validation.py | 142 ++++- src/alphamind/decision/strategist/harness.py | 26 +- src/alphamind/decision/strategist/runner.py | 25 +- .../breach_loop/production_substrate.py | 4 + src/alphamind/pipeline/decision.py | 11 + src/alphamind/scheduler/orchestrator.py | 39 ++ .../state/repository/options_chain_read.py | 505 ++++++++++++++++++ .../repository/options_chain_tool_mcp.py | 122 +++++ tests/analysis/test_sdk_subprocess.py | 2 + tests/config/test_cross_reference.py | 3 + tests/config/test_models_package.py | 1 + tests/config/test_resolver.py | 3 + tests/config/test_semantic_validation.py | 3 + tests/config/test_snapshot.py | 9 +- tests/decision/analyst/test_harness.py | 54 ++ tests/decision/analyst/test_input_bundle.py | 46 +- tests/decision/analyst/test_validation.py | 202 +++++++ tests/decision/strategist/test_harness.py | 54 ++ .../guardrail_evaluation/test_e2e_golden.py | 3 + .../test_effective_limits_and_feature_gate.py | 3 + .../regime_adaptation/conftest.py | 2 + .../regime_adaptation/test_orchestrator.py | 2 + .../rules_and_limits/test_registry.py | 2 + tests/state/test_options_chain_read.py | 442 +++++++++++++++ tests/state/test_options_chain_tool_mcp.py | 141 +++++ 37 files changed, 2212 insertions(+), 32 deletions(-) create mode 100644 config/options_chain.yaml create mode 100644 src/alphamind/config/models/options_chain.py create mode 100644 src/alphamind/state/repository/options_chain_read.py create mode 100644 src/alphamind/state/repository/options_chain_tool_mcp.py create mode 100644 tests/state/test_options_chain_read.py create mode 100644 tests/state/test_options_chain_tool_mcp.py diff --git a/config/options_chain.yaml b/config/options_chain.yaml new file mode 100644 index 000000000..c6480c681 --- /dev/null +++ b/config/options_chain.yaml @@ -0,0 +1,15 @@ +# Options chain-slice selection policy + premium validation tolerance (ALP-948). +# Governs the decision layer's contract-level options read: which contracts the +# retrieve_options_chain tool renders for the analyst/strategist, and how far an +# option entry's limit premium may drift from the latest chain-snapshot NBBO +# midpoint before the analyst validator rejects it for redraft. Filter values +# are config-owned — the tool's input is the underlying ticker only. +# +# Starting values are operator-tunable configuration, not contract. + +strike_band_pct: 15 # contracts within ±15% of spot +min_days_to_expiration: 5 # skip imminent expiries (gamma/pin risk dominates the 4-72h horizon) +max_days_to_expiration: 45 # beyond ~45 DTE the premium decay profile stops fitting the swing horizon +min_open_interest: 100 # liquidity floor — thinner books quote untradeable NBBOs +max_contracts_rendered: 24 # per-underlying render cap; nearest-the-money win, drops are named in the omitted note +premium_staleness_tolerance_pct: 10 # entry limit premium vs latest NBBO midpoint, % of midpoint diff --git a/prompts/decision/analyst.md b/prompts/decision/analyst.md index d3f85460d..4d5460294 100644 --- a/prompts/decision/analyst.md +++ b/prompts/decision/analyst.md @@ -18,7 +18,7 @@ You are the analyst in a systematic trading pipeline. You read a synthesized mar - Your user turn contains, in order: (1) a guardrail state header block, (2) a `=== REFERENCE PRICES (authoritative bracket anchors) ===` block listing the current per-ticker reference price, (3) an `=== AVAILABLE TOOLS ===` reminder, (4) a `=== SYNTHESIZER BRIEF PREVIEW (full brief via retrieve_brief) ===` section — the synthesizer's framing preface, with each cited finding's full content available via `retrieve_brief(ref_id)`. - Your output is consumed by a deterministic proposal pre-processor (reads structured fields) and a portfolio manager LLM (reads narrative fields). It is not read by humans. - Source references in the synthesizer brief use typed prefixes: `SA-TECH` (tech/semis researcher), `SA-FIN` (financials researcher), `SA-ENERGY` (energy researcher), `QR` (baseline qualitative research), `AR` (adaptive research threads), `CR` (correlation/regime brief). No other prefixes exist. -- Two tools are callable: `validate_guardrail` and `retrieve_brief`. No other tools. +- Three tools are callable: `validate_guardrail`, `retrieve_brief`, and `retrieve_options_chain`. No other tools. - Your proposals will be re-evaluated and may be rejected, resized, or modified by the portfolio manager. Your job is not to advocate; it is to present complete, honest, falsifiable theses and let the PM decide. - Portfolio state can drift between your guardrail check and execution; the execution layer performs a final authoritative guardrail check. You are not the last line of defense — but every proposal must be compliant at the time you validate it. - The analyst is responsible for new entries only. Existing position management — hold/reduce/close/adjust/add — is the strategist's domain. Do not emit position-management actions. @@ -37,6 +37,8 @@ You are the analyst in a systematic trading pipeline. You read a synthesized mar 2. Reference prices (`=== REFERENCE PRICES (authoritative bracket anchors) ===` block). One `TICKER: price` line per active-universe ticker — the current authoritative price for that name. This is the single source of truth for every absolute price level you emit: anchor each candidate's entry, target, and protective-stop to its ticker's line here. It is also the exact number your brackets are validated against, so a level drawn from anywhere else (a price quoted in a brief, a 52-week extreme, a figure carried over from memory) will be rejected. If a ticker you want to propose is absent, you do not have an authoritative price for it — drop the proposal. + A ticker with a usable options surface carries an extended line: `TICKER: price | IVr NN | exp: MM-DD, MM-DD`. `IVr` is the ATM implied-volatility percentile rank against that name's own trailing history (`n/a` while the baseline is still calibrating); the `exp:` dates are the expirations with liquid contracts inside the configured selection window. This is the screening signal for instrument choice — contract-level detail (strikes, premiums, greeks) comes from `retrieve_options_chain`, never from memory. A ticker with no extended line has no usable options surface this invocation: propose equity only for that name. + 3. Synthesizer brief preview (markdown). A guide to which typed source references warrant retrieval — not the brief itself. The brief deliberately surfaces contradictions and uncertainties without resolving them; resolving them inside a thesis is part of your job. You do not receive full thesis records, position-level P/L, or activity log; those are in the strategist's and PM's contexts. What you see of the existing book is the `Held positions` block (for dedup), the `Abandoned openings` block (for re-evaluation on current grounds), and aggregate sector headroom (for self-sizing). @@ -86,6 +88,10 @@ For each candidate opportunity, construct a thesis with these required elements. Ground the bracket in the reference price. Anchor entry, target, and the protective-stop invalidation level to the underlying's price in the `=== REFERENCE PRICES (authoritative bracket anchors) ===` block — not to a level quoted earlier in a brief or carried over from a prior session. That block is the exact number your bracket is validated against; sizing against any other figure is rejected downstream before it can fill. For a short, the protective stop sits above the reference price and the target below it; for a long, the target sits above and the stop below. Read the reference price for your specific ticker — never reuse a number associated with a different name. +Choosing the instrument: equity vs long single-leg option. A long single-leg option (call for a long thesis, put for a short one) expresses a directional thesis with defined risk — the worst case is the premium paid, which makes it the natural expression when the thesis has a hard catalyst inside the contract's life and the open-ended tail of an equity position is the part you do not want. Before choosing the instrument, read the IV context: the `IVr` column tells you where this name's implied volatility sits against its own history. Buying premium when the name's IV sits high in its own range means paying up for the move and losing to vol compression even when the direction is right; a low or moderate rank means the surface is not already charging for your thesis. Prefer the equity expression when the options surface gives no edge — when `IVr` is `n/a` or elevated, when the liquid expirations do not cover the catalyst window, or when `retrieve_options_chain` shows wide or thin markets at the strikes you would want. An option is a sharper tool only when the surface cooperates; it is never the default. + +Ground option premiums in the chain tool. For an option proposal, the entry limit premium anchors to the NBBO bid/ask of the specific contract as returned by `retrieve_options_chain` — exactly as equity brackets anchor to REFERENCE PRICES. Propose only contracts the tool returned; price the resting entry limit at or inside that contract's NBBO. A premium drawn from anywhere else (a rough mental model of option pricing, a figure from a brief, a stale chain) is rejected downstream against the same chain snapshot the tool reads. The premium paid bounds the position's loss: `position_size.premium_at_risk` carries it, the sizing band applies to it (capital at risk, not notional), and the portfolio manager authors the broker-enforced capital-protection floor from it. The contract's expiration must sit beyond your entry window's deadline — a contract that expires before the entry resolves is structurally dead and is rejected. + Each proposal is self-contained. Do not compare proposals to each other in any narrative field, do not signal preference, do not describe one as stronger than another. Cross-proposal judgment belongs to the portfolio manager. @@ -99,6 +105,12 @@ Each proposal is self-contained. Do not compare proposals to each other in any n - Do not emit a recommendation whose final `guardrail_validation_result.overall` is FAIL or UNAVAILABLE. - Do not call this tool in watchlist mode. +`retrieve_options_chain(underlying)`: +- Call once per underlying you are considering for an option expression, after the IVr screen and before drafting the proposal. The tool returns the current chain slice: near-the-money contracts inside the configured strike band, expiration window, and open-interest floor, each with OCC symbol, expiration, strike, type, NBBO bid/ask, IV, delta, open interest, and snapshot age. The filters are config-owned; you supply only the ticker. +- Propose only contracts present in the tool's output, and anchor the entry limit premium to that contract's NBBO. The `omitted` note names what the filters dropped — if the strike or expiration you wanted was filtered out, that is the liquidity verdict, not an invitation to propose it anyway. +- A "no usable options chain data" response means the name has no tradeable surface this invocation — express the thesis in equity or drop it. +- Do not call for names you are only proposing as equity, and do not re-call for the same underlying within an invocation — the snapshot does not change mid-reasoning. + `retrieve_brief(ref_id)`: - The inline `=== SYNTHESIZER BRIEF PREVIEW (full brief via retrieve_brief) ===` section in your user turn is the synthesizer's framing preface, not the canonical brief. The full brief lives in tool-retrievable artifacts; read a finding's full content by calling `retrieve_brief(ref_id)` with the typed reference IDs (`[SA-TECH-n]`, `[QR-n]`, `[AR-n]`, `[CR-n]`, etc.) the preview cites. - Required before emitting an empty `recommendations` array: if the preview cites any reference at ≥4σ (four sigma) or otherwise framed as load-bearing (a structural divergence, a primary catalyst, a thesis-pivoting signal), call `retrieve_brief` for at least the highest-σ / most load-bearing reference before concluding no thesis is warranted. "No thesis" is a valid output only once you have read the full content of those references — not before. The preview's brevity is not evidence of a quiet day; it is the format. @@ -216,6 +228,99 @@ Presentation order within `recommendations`: conviction descending; then entry w } + + Normal-regime invocation on a book where NVDA's reference line reads `NVDA: 842.50 | IVr 38 | exp: 05-08, 05-29` — a hard catalyst (NVDA reports 2026-05-27 after close) sits inside the 05-29 expiration, and moderate IV rank means the surface is not already charging for the move. retrieve_options_chain("NVDA") returned, among others: `NVDA260529C00880000 2026-05-29 880C bid/ask 9.60/10.00 IV 0.46 delta +0.35 OI 2210 age 14m`. Findings [SA-TECH-2] and [QR-3] converge on the pre-earnings setup; total portfolio value is $1.0M. + +{ + "invocation_id": "inv-2026-04-23T14-30Z", + "timestamp": "2026-04-23T14:31:22Z", + "mode": "normal", + "recommendations": [ + { + "recommendation_id": "REC-1", + "instrument": { + "asset_type": "option", + "underlying": "NVDA", + "strike": 880.00, + "expiration": "2026-05-29", + "contract_type": "call", + "direction": "long" + }, + "underlying": "NVDA", + "sector": "semis", + "conviction_level": 2, + "entry_order": { + "type": "limit", + "limit_price": 9.80 + }, + "position_size": { + "quantity": 6, + "dollar_value": 5880.00, + "pct_of_portfolio": 0.59, + "premium_at_risk": 5880.00, + "delta_adjusted_exposure": 176925.00 + }, + "target": { + "target_type": "pl_percentage", + "price": 15.68, + "dollar_pl_target": 3528.00, + "pl_percentage": 60.0 + }, + "invalidation_legs": [ + { + "leg_id": "INV-1", + "type": "price", + "is_hard": true, + "condition": { + "underlying_trigger": "NVDA", + "comparator": "<=", + "trigger_price": 815.00 + }, + "order_parameters": { + "order_type": "market" + } + }, + { + "leg_id": "INV-2", + "type": "event", + "is_hard": false, + "condition": { + "event_description": "A major hyperscaler cuts FY capex guidance before NVDA reports on 2026-05-27" + } + } + ], + "entry_window": { + "deadline": "2026-04-24T19:55:00Z", + "decay_type": "gradual", + "rationale": "The resting limit at the NBBO midpoint should fill within two sessions of normal two-way flow; beyond that the surface will have repriced and the premium anchor is stale." + }, + "time_expectation_hours": 60, + "guardrail_validation_result": { + "overall": "PASS", + "per_rule": [ + {"rule": "position_max_loss_options_pct", "status": "PASS", "current": 0.0, "limit": 2.0, "projected_after": 0.59, "headroom_remaining": 1.41, "unit": "% of portfolio (premium at risk)"}, + {"rule": "options_delta_pct", "status": "PASS", "current": 0.0, "limit": 20.0, "projected_after": 17.69, "headroom_remaining": 2.31, "unit": "% of portfolio (delta-adjusted)"}, + {"rule": "sector_concentration", "status": "PASS", "current": 4.2, "limit": 25.0, "projected_after": 21.89, "headroom_remaining": 3.11, "unit": "% of portfolio (delta-adjusted)"}, + {"rule": "net_long_exposure", "status": "PASS", "current": 24.6, "limit": 60.0, "projected_after": 42.29, "headroom_remaining": 17.71, "unit": "% of portfolio (delta-adjusted)"} + ], + "delta_adjusted_exposure": 176925.00, + "cumulative_impact_note": "Proposal #1 of 1 in this invocation.", + "checked_at": "2026-04-23T14:31:10Z" + }, + "thesis_narrative": "Hyperscaler capex signals in [SA-TECH-2] support upstream demand into NVDA's 2026-05-27 print, and [QR-3] shows prediction-market repricing toward a beat while the IVr column reads 38 — the option surface is not yet charging for the catalyst. The 880C expiring 2026-05-29 holds the print inside the contract's life with two days of post-print reaction window. Defined risk fits the conviction: one unresolved contradiction caps this at conviction 2, and the premium bounds the downside while the equity expression would carry open-ended tail through the print.", + "target_rationale": "A 60% premium gain corresponds to the underlying reaching the prior reaction shelf near $890 shortly after the print, per the chain's pricing at entry. Premium-denominated because an option's P/L resolves in premium, not underlying points.", + "invalidation_rationale": [ + {"leg_id": "INV-1", "rationale": "A break of $815 before the print means flow has already contradicted the demand thesis; holding a long call through a broken setup just donates the remaining premium to theta."}, + {"leg_id": "INV-2", "rationale": "The thesis rests on hyperscaler capex holding through the print. A guidance cut by a major buyer invalidates the causal chain regardless of NVDA's price action."} + ], + "position_size_rationale": "Defined-risk instrument, so the conviction-2 band applies to premium at risk: $5,880 is 0.59% of portfolio, inside the 0.5-1.5% band. Notional and delta-adjusted exposure are larger by construction; the guardrail result above carries the delta-adjusted figure.", + "entry_window_rationale": "Gradual decay: the premium anchor ages as the surface reprices toward the print; a fill materially later than two sessions would be at a different premium than validated.", + "counterarguments_acknowledged": "The implied move in the chain's pricing may already be fair for a consensus beat — [QR-3] flags the repricing as partially complete. The trade proceeds because IVr 38 says this name's own surface is mid-range, so the asymmetry is paying for the right tail rather than chasing it; if the premium gaps above the validated anchor before fill, the resting limit simply expires unfilled." + } + ] +} + + diff --git a/prompts/decision/strategist.md b/prompts/decision/strategist.md index 50dc20ebd..2877787a3 100644 --- a/prompts/decision/strategist.md +++ b/prompts/decision/strategist.md @@ -24,7 +24,7 @@ You are the strategist in a systematic trading pipeline. You read a synthesized - Your output is consumed by a deterministic proposal pre-processor (reads structured fields) and a portfolio manager LLM (reads narrative fields). It is not read by humans. - You run in parallel with the analyst in a separate context window. The analyst produces new-entry proposals; you produce position-management assessments. Neither sees the other's output; the pre-processor merges and annotates before the PM. - Source references in the synthesizer brief use typed prefixes with a numeric index: `[SA-TECH-N]` (tech/semis researcher), `[SA-FIN-N]` (financials researcher), `[SA-ENERGY-N]` (energy researcher), `[QR-N]` (baseline qualitative research), `[AR-N]` (adaptive research threads), `[CR-N]` (correlation/regime brief). No other prefixes exist. Every citation you emit must carry an `-N` index. Bare prefixes (`[CR]`, `[SA-TECH]`, etc.) are structurally invalid because the retrieval store is keyed `-` and a bare prefix cannot resolve. When you would otherwise reach for a general region of the brief — "the correlation backdrop", "the financials read as a whole" — cite the specific section that grounds your point (e.g., `[CR-12]` for a regime-level observation in `portfolio_level_observations.thesis_dependency_warnings`, `[CR-7]` for a per-position correlation warning in `status_rationale`), not the unindexed prefix. -- Three tools are callable: `validate_guardrail`, `validate_guardrail_batch`, and `retrieve_brief`. No other tools. +- Four tools are callable: `validate_guardrail`, `validate_guardrail_batch`, `retrieve_brief`, and `retrieve_options_chain`. No other tools. - Your assessments may be rejected, modified, approved with parameter adjustments, or overridden with PM-authored corrective commands by the portfolio manager. Your job is not to advocate; it is to classify honestly and recommend actions whose rationales stand up to pressure-testing. - The analyst owns new entries. You own hold / reduce / close / adjust-bracket / add for existing positions and maintain / modify / cancel for pending orders. Do not propose new entries; do not reassess new-entry proposals. - Thesis-status classification is your sole responsibility. The PM reviews your calls, but does not reclassify. @@ -116,6 +116,10 @@ For every open position, execute this workflow. Missing any step is grounds for - Do not emit a coordinated remediation package whose final `BatchValidationResult.overall` is FAIL or UNAVAILABLE. Either revise the package until it PASSes or document the uncured breach and drop the remedy. - Do not call `validate_guardrail_batch` with a single proposal as a workaround for `validate_guardrail`; reach for the batch tool only when the proposals are part of one coordinated package. +`retrieve_options_chain(underlying)`: +- Call when managing an open single-leg option position and the assessment needs the contract's current mark: the tool returns the underlying's chain slice with each contract's NBBO bid/ask, IV, delta, open interest, and snapshot age. The held contract's NBBO midpoint is the current mark for premium-denominated reasoning (distance to a premium target, the cost of closing, whether a reduce quantity makes sense at the current quote). +- Do not call for equity-only books or for positions whose assessment does not turn on the option's current pricing. + `retrieve_brief(ref_id)`: - Call when a cited entry-thesis signal is flagged as contradicted or uncertain by the synthesizer and the contradiction's impact on the thesis is not clear from the synthesizer's surface language. - Call when an engine-originated closure bears on a currently-held position and the activity log's summary is insufficient to assess thesis overlap. diff --git a/src/alphamind/analysis/_sdk_subprocess.py b/src/alphamind/analysis/_sdk_subprocess.py index b456b9732..bc30dea24 100644 --- a/src/alphamind/analysis/_sdk_subprocess.py +++ b/src/alphamind/analysis/_sdk_subprocess.py @@ -68,6 +68,11 @@ RealizedVolEntry, SqlOptionsIvProvider, ) +from alphamind.state.repository.options_chain_read import ( + ChainFilterParams, + OptionsChainReader, + SqlOptionsChainReader, +) # Decision/analysis-layer harness HarnessSuccess types and the heavy state # Pydantic models are referenced only as type annotations in this module. @@ -154,6 +159,41 @@ def _prepare_market_inputs_for_pickle(market_inputs: MarketInputs) -> MarketInpu return market_inputs +@dataclass(frozen=True, slots=True) +class _OptionsChainReaderShim: + """Pickle-friendly stand-in for :class:`SqlOptionsChainReader` (ALP-948). + + Carries only the config-owned :class:`ChainFilterParams`; the worker + reconstructs a real reader against its own fresh ``DATABASE_PATH`` + sessionmaker. The instance never reaches a read method — it is swapped + out by ``_rehydrate_options_chain_reader`` before the harness call. + """ + + params: ChainFilterParams + + def chain_slice(self, *_: Any) -> Any: # pragma: no cover - never invoked + raise RuntimeError("_OptionsChainReaderShim must be rehydrated before use") + + def latest_quote(self, *_: Any) -> Any: # pragma: no cover - never invoked + raise RuntimeError("_OptionsChainReaderShim must be rehydrated before use") + + def options_context(self, *_: Any) -> Any: # pragma: no cover - never invoked + raise RuntimeError("_OptionsChainReaderShim must be rehydrated before use") + + +def _prepare_options_chain_reader_for_pickle( + reader: OptionsChainReader | None, +) -> OptionsChainReader | None: + """Swap a session-bound ``SqlOptionsChainReader`` for its shim before pickling. + + Other implementations (data-only fakes in tests) pickle as-is; ``None`` + (chain tool unmounted) passes through. + """ + if isinstance(reader, SqlOptionsChainReader): + return _OptionsChainReaderShim(params=reader.params) + return reader + + def _prepare_validation_state_for_pickle(state: ValidationToolState) -> ValidationToolState: """Swap out non-picklable inner fields before pickling ValidationToolState.""" return state.model_copy( @@ -593,6 +633,8 @@ async def invoke_analyst_in_subprocess( # noqa: PLR0913 — signature parity wi archive_root: Path | None = None, provenance_root: Path | None = None, sdk_query_fn: Any = None, + options_chain_reader: OptionsChainReader | None = None, + premium_staleness_tolerance_pct: float | None = None, progress: ProgressEmitter = NOOP_PROGRESS_EMITTER, phase: str = "analyst", ) -> AnalystHarnessSuccess: @@ -622,6 +664,8 @@ async def invoke_analyst_in_subprocess( # noqa: PLR0913 — signature parity wi as_of=as_of, archive_root=archive_root, sdk_query_fn=sdk_query_fn, + options_chain_reader=options_chain_reader, + premium_staleness_tolerance_pct=premium_staleness_tolerance_pct, progress=progress, phase=phase, ) @@ -641,6 +685,12 @@ async def invoke_analyst_in_subprocess( # noqa: PLR0913 — signature parity wi "provenance_root": str(provenance_root) if provenance_root is not None else None, "progress_jsonl_path": _extract_progress_jsonl_path(progress), "phase": phase, + "options_chain_reader_pickle": ( + _encode_pickle(_prepare_options_chain_reader_for_pickle(options_chain_reader)) + if options_chain_reader is not None + else None + ), + "premium_staleness_tolerance_pct": premium_staleness_tolerance_pct, } result = await _run_worker(payload) if result["kind"] == "failure": @@ -667,6 +717,7 @@ async def invoke_strategist_in_subprocess( # noqa: PLR0913 — signature parity archive_root: Path | None = None, provenance_root: Path | None = None, sdk_query_fn: Any = None, + options_chain_reader: OptionsChainReader | None = None, progress: ProgressEmitter = NOOP_PROGRESS_EMITTER, phase: str = "strategist", ) -> StratHarnessSuccess: @@ -691,6 +742,7 @@ async def invoke_strategist_in_subprocess( # noqa: PLR0913 — signature parity as_of=as_of, archive_root=archive_root, sdk_query_fn=sdk_query_fn, + options_chain_reader=options_chain_reader, progress=progress, phase=phase, ) @@ -711,6 +763,11 @@ async def invoke_strategist_in_subprocess( # noqa: PLR0913 — signature parity "provenance_root": str(provenance_root) if provenance_root is not None else None, "progress_jsonl_path": _extract_progress_jsonl_path(progress), "phase": phase, + "options_chain_reader_pickle": ( + _encode_pickle(_prepare_options_chain_reader_for_pickle(options_chain_reader)) + if options_chain_reader is not None + else None + ), } result = await _run_worker(payload) if result["kind"] == "failure": diff --git a/src/alphamind/analysis/_sdk_subprocess_worker.py b/src/alphamind/analysis/_sdk_subprocess_worker.py index 3180ef5b5..3907488cd 100644 --- a/src/alphamind/analysis/_sdk_subprocess_worker.py +++ b/src/alphamind/analysis/_sdk_subprocess_worker.py @@ -39,6 +39,7 @@ from alphamind.analysis._sdk_subprocess import ( _decode_pickle, _encode_pickle, + _OptionsChainReaderShim, _SqlIvProviderShim, ) from alphamind.analysis._shared import Sector @@ -64,6 +65,7 @@ from alphamind.risk_guardrails.state_delivery.validation_tool import ValidationToolState from alphamind.state.config import StatePersistenceConfig from alphamind.state.invocation_context.context import InvocationHandle +from alphamind.state.repository.options_chain_read import SqlOptionsChainReader class _JsonlAppender: @@ -378,6 +380,26 @@ def _rehydrate_market_inputs( return market_inputs +def _rehydrate_options_chain_reader(payload: dict[str, Any], *, sync_session_factory: Any) -> Any: + """Reconstruct the options chain reader from the worker payload (ALP-948). + + ``None`` (chain tool unmounted) passes through; the parent's + ``_OptionsChainReaderShim`` becomes a real :class:`SqlOptionsChainReader` + against this worker's own ``DATABASE_PATH`` sessionmaker; any other + pickled reader (a data-only test fake) is used as-is. + """ + encoded = payload.get("options_chain_reader_pickle") + if encoded is None: + return None + reader = _decode_pickle(encoded) + if isinstance(reader, _OptionsChainReaderShim): + return SqlOptionsChainReader( + sync_session_factory=sync_session_factory, + params=reader.params, + ) + return reader + + def _rehydrate_validation_state( state: ValidationToolState, *, sync_session_factory: Any ) -> ValidationToolState: @@ -490,6 +512,9 @@ async def _run_analyst(payload: dict[str, Any]) -> dict[str, Any]: _decode_pickle(payload["initial_validation_state_pickle"]), sync_session_factory=sync_session_factory, ) + options_chain_reader = _rehydrate_options_chain_reader( + payload, sync_session_factory=sync_session_factory + ) try: async with _telemetry_session(provenance_root) as telemetry_session: result = await invoke_analyst( @@ -503,6 +528,8 @@ async def _run_analyst(payload: dict[str, Any]) -> dict[str, Any]: archive_root=archive_root, telemetry_session=telemetry_session, provenance_root=provenance_root, + options_chain_reader=options_chain_reader, + premium_staleness_tolerance_pct=payload.get("premium_staleness_tolerance_pct"), progress=progress, phase=payload.get("phase", "analyst"), ) @@ -530,6 +557,9 @@ async def _run_strategist(payload: dict[str, Any]) -> dict[str, Any]: _decode_pickle(payload["validation_state_pickle"]), sync_session_factory=sync_session_factory, ) + options_chain_reader = _rehydrate_options_chain_reader( + payload, sync_session_factory=sync_session_factory + ) try: async with _telemetry_session(provenance_root) as telemetry_session: result = await run_strategist_harness( @@ -544,6 +574,7 @@ async def _run_strategist(payload: dict[str, Any]) -> dict[str, Any]: archive_root=archive_root, telemetry_session=telemetry_session, provenance_root=provenance_root, + options_chain_reader=options_chain_reader, progress=progress, phase=payload.get("phase", "strategist"), ) diff --git a/src/alphamind/config/load.py b/src/alphamind/config/load.py index bb06b817a..fa8b8ac56 100644 --- a/src/alphamind/config/load.py +++ b/src/alphamind/config/load.py @@ -45,6 +45,7 @@ from alphamind.config.models.guardrails import GuardrailsConfig from alphamind.config.models.llm_failure import LLMFailureConfig from alphamind.config.models.main import MainConfig +from alphamind.config.models.options_chain import OptionsChainConfig from alphamind.config.models.scheduler import SchedulerConfig from alphamind.config.models.venue import VenueConfig from alphamind.config.resolver import ( @@ -108,6 +109,9 @@ def parse_loaded_config(config_dir: Path) -> LoadedConfig: continuous_monitor=ContinuousMonitorConfig.model_validate( read_yaml_file(config_dir / "continuous_monitor.yaml") ), + options_chain=OptionsChainConfig.model_validate( + read_yaml_file(config_dir / "options_chain.yaml") + ), profiles=load_profiles(config_dir), regimes=load_regimes(config_dir), modes=load_modes(config_dir), diff --git a/src/alphamind/config/models/__init__.py b/src/alphamind/config/models/__init__.py index b0041d875..bebc9352c 100644 --- a/src/alphamind/config/models/__init__.py +++ b/src/alphamind/config/models/__init__.py @@ -84,6 +84,7 @@ StrategistOutputMode, ) from alphamind.config.models.news_outlets import CredibilityTier, NewsOutletsConfig, OutletEntry +from alphamind.config.models.options_chain import OptionsChainConfig from alphamind.config.models.overlays import ( EventType, FinalInvocationBeforeEvent, @@ -219,6 +220,7 @@ def _load_env_example_keys() -> frozenset[str]: "NarrativeLag", "NewsDigestConfig", "NewsOutletsConfig", + "OptionsChainConfig", "OrderType", "OutletEntry", "Overlay", diff --git a/src/alphamind/config/models/options_chain.py b/src/alphamind/config/models/options_chain.py new file mode 100644 index 000000000..651caa22b --- /dev/null +++ b/src/alphamind/config/models/options_chain.py @@ -0,0 +1,34 @@ +"""Pydantic model for options_chain.yaml — chain-slice selection policy (ALP-948). + +The operator-tunable knobs behind the decision layer's contract-level options +read: the strike band, expiration window, open-interest floor, and contract +cap the ``retrieve_options_chain`` tool's chain slice applies, plus the +premium drift tolerance the analyst validator's ``option_premium_staleness`` +check enforces against the chain-snapshot NBBO midpoint. Starting values are +operator-tunable configuration, not contract. +""" + +from typing import Self + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class OptionsChainConfig(BaseModel): + """Operator-tunable chain-slice filters + premium validation tolerance.""" + + model_config = ConfigDict(frozen=True, strict=True) + + strike_band_pct: float = Field(gt=0) + min_days_to_expiration: int = Field(ge=0) + max_days_to_expiration: int = Field(ge=1) + min_open_interest: int = Field(ge=0) + max_contracts_rendered: int = Field(ge=1) + premium_staleness_tolerance_pct: float = Field(gt=0) + + @model_validator(mode="after") + def _expiration_window_ordered(self) -> Self: + # A max below the min selects an empty expiration window — every chain + # slice would render no contracts while looking configured. + if self.max_days_to_expiration < self.min_days_to_expiration: + raise ValueError("max_days_to_expiration must be >= min_days_to_expiration") + return self diff --git a/src/alphamind/config/resolver.py b/src/alphamind/config/resolver.py index ff6130b69..d87a00624 100644 --- a/src/alphamind/config/resolver.py +++ b/src/alphamind/config/resolver.py @@ -47,6 +47,7 @@ StrategistAction, StrategistOutputMode, ) +from alphamind.config.models.options_chain import OptionsChainConfig from alphamind.config.models.overlays import ( Overlay, PreEventOverlay, @@ -129,6 +130,7 @@ class ResolvedConfig: assets: AssetsConfig agents: AgentsConfig continuous_monitor: ContinuousMonitorConfig + options_chain: OptionsChainConfig def __hash__(self) -> int: # Pydantic frozen models with list/dict fields are not hashable, and @@ -175,6 +177,7 @@ def __hash__(self) -> int: self.assets.model_dump_json(), self.agents.model_dump_json(), self.continuous_monitor.model_dump_json(), + self.options_chain.model_dump_json(), ) ) @@ -293,6 +296,7 @@ class LoadedConfig: assets: AssetsConfig agents: AgentsConfig continuous_monitor: ContinuousMonitorConfig + options_chain: OptionsChainConfig profiles: Mapping[Profile, ProfileConfig] regimes: Mapping[Regime, RegimeConfig] modes: Mapping[Mode, ModeConfig] @@ -374,4 +378,5 @@ def compose_config(inputs: LoadedConfig, runtime: RuntimeDimensions) -> Resolved assets=inputs.assets, agents=inputs.agents, continuous_monitor=inputs.continuous_monitor, + options_chain=inputs.options_chain, ) diff --git a/src/alphamind/decision/analyst/harness.py b/src/alphamind/decision/analyst/harness.py index 55bfff7ac..b0789bb12 100644 --- a/src/alphamind/decision/analyst/harness.py +++ b/src/alphamind/decision/analyst/harness.py @@ -58,6 +58,10 @@ from alphamind.risk_guardrails.state_delivery.validation_tool_mcp import ( build_validate_guardrail_mcp_server, ) +from alphamind.state.repository.options_chain_read import OptionsChainReader +from alphamind.state.repository.options_chain_tool_mcp import ( + build_retrieve_options_chain_mcp_server, +) __all__ = [ "ContextOverflowFailure", @@ -80,9 +84,9 @@ # loops. Mirrors the qualitative-researcher cap. _MAX_TURNS = 25 -# Real tool calls go through the two in-process MCP servers registered as -# ``alphamind_decision_validation`` (story 04) and -# ``alphamind_synthesizer_retrieval`` (synthesizer's factory); the bundled +# Real tool calls go through the in-process MCP servers registered as +# ``alphamind_decision_validation`` (story 04), ``alphamind_synthesizer_retrieval`` +# (synthesizer's factory), and ``alphamind_options_chain`` (ALP-948); the bundled # CLI rewrites those names to ``mcp____`` on the wire. Any # other ``ToolUseBlock.name`` (``ToolSearch``, ``StructuredOutput``, …) is an # SDK-internal pseudo-event injected by the JSON-Schema output mode and must @@ -90,6 +94,7 @@ _TOOL_NAME_PREFIXES: tuple[str, ...] = ( "mcp__alphamind_decision_validation__", "mcp__alphamind_synthesizer_retrieval__", + "mcp__alphamind_options_chain__", ) @@ -173,11 +178,17 @@ class _ValidatorContext: together through the harness's parse/validate path; bundling them keeps the helper signatures narrow. ``underlying_prices`` is the guardrail library's latest ``ohlcv_bars`` close per ticker (ALP-742 bracket-coherence checks). + ``options_chain_reader`` / ``premium_staleness_tolerance_pct`` feed the + ALP-948 InstrumentOption checks — the same reader the + ``retrieve_options_chain`` MCP tool is mounted with, so the premium the + agent saw is the premium it is judged against. """ retrieval_store: RetrievalStore active_sectors: frozenset[str] underlying_prices: Mapping[str, float] + options_chain_reader: OptionsChainReader | None = None + premium_staleness_tolerance_pct: float | None = None def _parse_and_validate( @@ -221,6 +232,8 @@ def _parse_and_validate( retrieval_store=validator.retrieval_store, active_sectors=validator.active_sectors, underlying_prices=validator.underlying_prices, + options_chain_reader=validator.options_chain_reader, + premium_staleness_tolerance_pct=validator.premium_staleness_tolerance_pct, ) if not validation.is_valid: for ve in validation.errors: @@ -254,12 +267,15 @@ def _build_mcp_wiring( *, initial_validation_state: ValidationToolState, retrieval_store: RetrievalStore, + options_chain_reader: OptionsChainReader | None = None, ) -> tuple[dict[str, Any], list[str]]: - """Compose the two MCP servers and merge their allowed-tool lists. + """Compose the MCP servers and merge their allowed-tool lists. Returns ``(merged_servers, merged_allowed_tools)`` ready for direct assignment to ``ClaudeAgentOptions.mcp_servers`` and - ``ClaudeAgentOptions.allowed_tools``. + ``ClaudeAgentOptions.allowed_tools``. The ``retrieve_options_chain`` + server (ALP-948) mounts only when a reader is supplied — composition + without one (tests, degraded paths) keeps the legacy two-server surface. """ # The analyst's prompt does not document the batch tool — and the # agents.yaml `tools` allowlist for `analyst` does not include @@ -270,10 +286,13 @@ def _build_mcp_wiring( initial_validation_state, include_batch_tool=False ) retrieval_servers, retrieval_tools = build_retrieve_brief_mcp_server(retrieval_store) - return ( - {**validation_servers, **retrieval_servers}, - [*validation_tools, *retrieval_tools], - ) + servers = {**validation_servers, **retrieval_servers} + tools = [*validation_tools, *retrieval_tools] + if options_chain_reader is not None: + chain_servers, chain_tools = build_retrieve_options_chain_mcp_server(options_chain_reader) + servers |= chain_servers + tools += chain_tools + return servers, tools _INCOMPAT_KEYWORDS: frozenset[str] = frozenset({"format", "discriminator"}) @@ -471,6 +490,8 @@ async def invoke_analyst( # noqa: PLR0913 — public signature is fixed by ALP- phase: str = "analyst", telemetry_session: AsyncSession | None = None, provenance_root: Path | None = None, + options_chain_reader: OptionsChainReader | None = None, + premium_staleness_tolerance_pct: float | None = None, ) -> HarnessSuccess: """Invoke the analyst agent and return :class:`HarnessSuccess`. @@ -500,6 +521,15 @@ async def invoke_analyst( # noqa: PLR0913 — public signature is fixed by ALP- sdk_query_fn: Callable matching ``claude_agent_sdk.query``. Defaults to the real SDK function. Inject a stub in tests. + options_chain_reader: + Per-invocation :class:`OptionsChainReader` (ALP-948). When supplied, + the ``retrieve_options_chain`` MCP server mounts and the validator's + InstrumentOption premium checks run against the same reader. ``None`` + keeps the legacy two-tool surface. + premium_staleness_tolerance_pct: + The ``options_chain`` config section's premium drift tolerance, + threaded into the validator. ``None`` falls back to the validator's + module default. Raises ------ @@ -526,10 +556,13 @@ async def invoke_analyst( # noqa: PLR0913 — public signature is fixed by ALP- retrieval_store=retrieval_store, active_sectors=active_sectors, underlying_prices=initial_validation_state.library_market.underlying_prices, + options_chain_reader=options_chain_reader, + premium_staleness_tolerance_pct=premium_staleness_tolerance_pct, ) mcp_servers, allowed_tools = _build_mcp_wiring( initial_validation_state=initial_validation_state, retrieval_store=retrieval_store, + options_chain_reader=options_chain_reader, ) prompt_text = await _load_prompt(agent_config.prompt) diff --git a/src/alphamind/decision/analyst/input_bundle.py b/src/alphamind/decision/analyst/input_bundle.py index 76236ecf4..d7c4f8b0a 100644 --- a/src/alphamind/decision/analyst/input_bundle.py +++ b/src/alphamind/decision/analyst/input_bundle.py @@ -27,6 +27,7 @@ render_analyst_header_halt_mode, ) from alphamind.risk_guardrails.state_delivery.config import StateDeliveryConfig +from alphamind.state.repository.options_chain_read import TickerOptionsContext __all__ = ["assemble_input_bundle_halt", "assemble_input_bundle_normal"] @@ -62,6 +63,7 @@ def assemble_input_bundle_normal( # noqa: PLR0913 — mirrors render_analyst_he tool_names: tuple[str, ...], underlying_prices: Mapping[str, float], sector_label_display: dict[str, str] | None = None, + options_context: Mapping[str, TickerOptionsContext] | None = None, ) -> str: """Compose the analyst's user-message text for a normal-mode invocation. @@ -70,6 +72,10 @@ def assemble_input_bundle_normal( # noqa: PLR0913 — mirrors render_analyst_he the ALP-742 validator enforces, so the analyst anchors brackets to the same number it is judged against — ALP-758), a brief tool-reminder section, then the synthesizer brief verbatim. + + ``options_context`` (ALP-948) decorates a ticker's reference-price line + with its IV rank and liquid expirations when the name has a usable options + surface; tickers absent from the map keep the bare price line. """ header = render_analyst_header( analyst_view=analyst_view, @@ -83,7 +89,7 @@ def assemble_input_bundle_normal( # noqa: PLR0913 — mirrors render_analyst_he config=state_delivery_config, sector_label_display=sector_label_display, ) - reference_prices = _render_reference_prices(underlying_prices) + reference_prices = _render_reference_prices(underlying_prices, options_context or {}) tool_reminder = _render_tool_reminder(tool_names, halt_mode=False) return ( f"{header}\n\n{reference_prices}\n\n{tool_reminder}\n\n" @@ -136,7 +142,10 @@ def assemble_input_bundle_halt( # noqa: PLR0913 — mirrors render_analyst_head # --------------------------------------------------------------------------- -def _render_reference_prices(underlying_prices: Mapping[str, float]) -> str: +def _render_reference_prices( + underlying_prices: Mapping[str, float], + options_context: Mapping[str, TickerOptionsContext], +) -> str: """Render the ``=== REFERENCE PRICES ===`` block. One `` TICKER: price`` line per entry in ``underlying_prices`` — the exact @@ -147,17 +156,36 @@ def _render_reference_prices(underlying_prices: Mapping[str, float]) -> str: a reference it was never shown. Sorted by ticker for determinism; prices are rendered to two decimals (a percentage staleness tolerance dwarfs the rounding). + + A ticker present in ``options_context`` (ALP-948) extends its line to + `` TICKER: price | IVr NN | exp: MM-DD, MM-DD`` — the ATM-IV percentile + rank (``n/a`` while the baseline is uncalibrated) and the expirations that + survive the chain-slice liquidity filters. Contract detail stays behind + the ``retrieve_options_chain`` tool. """ lines: list[str] = [_REFERENCE_PRICES_HEADER, _REFERENCE_PRICES_GUIDANCE] if not underlying_prices: lines.append(_REFERENCE_PRICES_NONE) return "\n".join(lines) lines.extend( - f" {ticker}: {underlying_prices[ticker]:.2f}" for ticker in sorted(underlying_prices) + _render_reference_price_line(ticker, underlying_prices[ticker], options_context.get(ticker)) + for ticker in sorted(underlying_prices) ) return "\n".join(lines) +def _render_reference_price_line( + ticker: str, price: float, context: TickerOptionsContext | None +) -> str: + """One reference-price line, options-decorated when context exists.""" + base = f" {ticker}: {price:.2f}" + if context is None: + return base + iv_rank = f"{context.iv_rank:.0f}" if context.iv_rank is not None else "n/a" + expirations = ", ".join(d.strftime("%m-%d") for d in context.liquid_expirations) + return f"{base} | IVr {iv_rank} | exp: {expirations}" + + # --------------------------------------------------------------------------- # Tool-reminder section # --------------------------------------------------------------------------- diff --git a/src/alphamind/decision/analyst/runner.py b/src/alphamind/decision/analyst/runner.py index 354ef81c0..4abe81818 100644 --- a/src/alphamind/decision/analyst/runner.py +++ b/src/alphamind/decision/analyst/runner.py @@ -53,6 +53,13 @@ from alphamind.risk_guardrails.state_delivery.validation_tool_mcp import ( build_initial_validation_state, ) +from alphamind.state.repository.options_chain_read import ( + OptionsChainReader, + TickerOptionsContext, +) +from alphamind.state.repository.options_chain_tool_mcp import ( + RETRIEVE_OPTIONS_CHAIN_TOOL_NAME, +) __all__ = ["ANALYST_TOOL_NAMES", "AnalystResult", "load_analyst_agent_config", "run_analyst"] @@ -72,15 +79,27 @@ # --------------------------------------------------------------------------- # Wire-form names rendered into the analyst's input bundle's ``=== AVAILABLE -# TOOLS ===`` section. The harness's two MCP factories -# (``build_validate_guardrail_mcp_server`` and ``build_retrieve_brief_mcp_server``) -# emit these same names as their allowed-tools lists. +# TOOLS ===`` section. The harness's MCP factories +# (``build_validate_guardrail_mcp_server``, ``build_retrieve_brief_mcp_server``, +# and ``build_retrieve_options_chain_mcp_server``) emit these same names as +# their allowed-tools lists. The chain tool (ALP-948) mounts only when the +# composition supplies an ``OptionsChainReader``; ``_tool_names`` drops it +# from the rendered list otherwise so the prompt never advertises an +# uncallable tool. ANALYST_TOOL_NAMES: tuple[str, ...] = ( "mcp__alphamind_decision_validation__validate_guardrail", "mcp__alphamind_synthesizer_retrieval__retrieve_brief", + RETRIEVE_OPTIONS_CHAIN_TOOL_NAME, ) +def _tool_names(options_chain_reader: OptionsChainReader | None) -> tuple[str, ...]: + """The AVAILABLE TOOLS render for this composition's mounted surface.""" + if options_chain_reader is not None: + return ANALYST_TOOL_NAMES + return tuple(n for n in ANALYST_TOOL_NAMES if n != RETRIEVE_OPTIONS_CHAIN_TOOL_NAME) + + # --------------------------------------------------------------------------- # Result type # --------------------------------------------------------------------------- @@ -154,6 +173,9 @@ async def run_analyst( # noqa: PLR0913 — signature dictated by ALP-299 spec p sdk_query_fn: Callable[..., AsyncIterator[Any]] | None = None, agent_config: BaseAgentConfig | None = None, borrow_cost_resolver: Callable[[str], float | None] | None = None, + options_chain_reader: OptionsChainReader | None = None, + options_context: Mapping[str, TickerOptionsContext] | None = None, + premium_staleness_tolerance_pct: float | None = None, progress: ProgressEmitter = NOOP_PROGRESS_EMITTER, phase: str = "analyst", ) -> AnalystResult: @@ -209,8 +231,15 @@ async def run_analyst( # noqa: PLR0913 — signature dictated by ALP-299 spec p state_delivery_config=state_delivery_config, synthesizer_text=synthesizer_text, underlying_prices=library_market.underlying_prices, + options_chain_reader=options_chain_reader, + options_context=options_context, + ) + logger.info( + "analyst input bundle assembled (mode=%s, chars=%d, options_context_tickers=%d)", + mode, + len(user_message), + len(options_context or {}), ) - logger.info("analyst input bundle assembled (mode=%s, chars=%d)", mode, len(user_message)) # HarnessFailure propagates up unchanged — the runner does NOT catch and # degrade. The pipeline-level orchestrator handles fail-closed semantics. @@ -227,6 +256,8 @@ async def run_analyst( # noqa: PLR0913 — signature dictated by ALP-299 spec p archive_root=archive_root, provenance_root=provenance_root, sdk_query_fn=sdk_query_fn, + options_chain_reader=options_chain_reader, + premium_staleness_tolerance_pct=premium_staleness_tolerance_pct, progress=progress, phase=phase, ) @@ -263,13 +294,17 @@ def _assemble_user_message( # noqa: PLR0913 — fan-in of input-bundle assemble state_delivery_config: StateDeliveryConfig, synthesizer_text: str, underlying_prices: Mapping[str, float], + options_chain_reader: OptionsChainReader | None, + options_context: Mapping[str, TickerOptionsContext] | None, ) -> str: """Dispatch to the mode-specific input-bundle assembler. Both assemblers share the same non-halt arguments; pulling the dispatch out of :func:`run_analyst` keeps that function under the linter's - PLR0915 threshold. ``underlying_prices`` (the ALP-742 reference map) is only - surfaced in normal mode — watchlist mode emits no brackets to anchor. + PLR0915 threshold. ``underlying_prices`` (the ALP-742 reference map) and + ``options_context`` (the ALP-948 per-ticker IVr/expirations decoration) + are only surfaced in normal mode — watchlist mode emits no brackets to + anchor and no sized options proposals. """ if mode == "normal": return assemble_input_bundle_normal( @@ -283,8 +318,9 @@ def _assemble_user_message( # noqa: PLR0913 — fan-in of input-bundle assemble active_sectors=active_sectors_tuple, state_delivery_config=state_delivery_config, synthesizer_brief_text=synthesizer_text, - tool_names=ANALYST_TOOL_NAMES, + tool_names=_tool_names(options_chain_reader), underlying_prices=underlying_prices, + options_context=options_context, ) # mode == "watchlist" — the runner-side guard above guarantees halt_state @@ -302,5 +338,5 @@ def _assemble_user_message( # noqa: PLR0913 — fan-in of input-bundle assemble active_sectors=active_sectors_tuple, state_delivery_config=state_delivery_config, synthesizer_brief_text=synthesizer_text, - tool_names=ANALYST_TOOL_NAMES, + tool_names=_tool_names(options_chain_reader), ) diff --git a/src/alphamind/decision/analyst/validation.py b/src/alphamind/decision/analyst/validation.py index 0c3402ed5..3a9fdcbb5 100644 --- a/src/alphamind/decision/analyst/validation.py +++ b/src/alphamind/decision/analyst/validation.py @@ -22,7 +22,7 @@ from __future__ import annotations from collections.abc import Iterable, Mapping -from datetime import datetime +from datetime import UTC, datetime from alphamind.analysis.synthesizer.models import ( REF_ID_RE, @@ -38,13 +38,19 @@ from alphamind.decision.analyst.models import ( AnalystOutput, InstrumentEquity, + InstrumentOption, PriceCondition, Recommendation, WatchlistEntry, ) +from alphamind.state.repository.options_chain_read import ( + OptionsChainReader, + occ_symbol_for_contract, +) __all__ = [ "DEFAULT_CONVICTION_BANDS", + "DEFAULT_PREMIUM_STALENESS_TOLERANCE_PCT", "DEFAULT_PRICE_STALENESS_TOLERANCE_PCT", "ValidationError", "ValidationResult", @@ -82,6 +88,13 @@ # may override; it is not (yet) YAML-wired. DEFAULT_PRICE_STALENESS_TOLERANCE_PCT: float = 5.0 +# Options counterpart of the equity staleness tolerance (ALP-948): maximum +# tolerated drift between an InstrumentOption recommendation's entry limit +# premium and the latest chain-snapshot NBBO midpoint, as a percentage of the +# midpoint. Production overrides this with the ``options_chain`` config +# section's ``premium_staleness_tolerance_pct``. +DEFAULT_PREMIUM_STALENESS_TOLERANCE_PCT: float = 10.0 + # --------------------------------------------------------------------------- # Layer-2 per-recommendation checks (a)-(i) @@ -515,6 +528,102 @@ def _check_reference_price_staleness( ) +# --------------------------------------------------------------------------- +# Layer-2 (l)-(m) — option premium coherence vs the chain-snapshot NBBO (ALP-948) +# --------------------------------------------------------------------------- + + +def _check_option_quote_anchoring( + rec: Recommendation, + *, + field_prefix: str, + options_chain_reader: OptionsChainReader, + tolerance_pct: float, +) -> Iterable[ValidationError]: + """(l) An InstrumentOption proposal must anchor to a resolvable chain quote. + + Mirrors the ALP-742 equity staleness-check shape for the options leg of + the book: the proposed contract must resolve to a latest snapshot in + ``options_contract_snapshots`` (rule ``option_contract_unknown`` — a + fabricated or stale-surface contract has no premium anchor at all), and + the entry limit premium must sit within *tolerance_pct* of that + snapshot's NBBO midpoint (rule ``option_premium_staleness`` — a premium + drawn from anywhere but the chain tool mis-states capital at risk and + rests at a level the market will not fill). + """ + if not isinstance(rec.instrument, InstrumentOption): + return + instrument = rec.instrument + try: + occ = occ_symbol_for_contract( + underlying=instrument.underlying, + expiration=instrument.expiration, + strike=float(instrument.strike), + contract_type=instrument.contract_type, + ) + except ValueError: + occ = None + quote = options_chain_reader.latest_quote(occ) if occ is not None else None + if quote is None: + yield ValidationError( + field_path=f"{field_prefix}.instrument", + rule="option_contract_unknown", + message=( + f"{rec.underlying} {instrument.expiration.isoformat()} " + f"{float(instrument.strike):g}{instrument.contract_type[0].upper()} " + f"({rec.recommendation_id}): no latest chain snapshot resolves for this " + "contract. Propose a contract returned by retrieve_options_chain — " + "its NBBO is the only valid premium anchor." + ), + ) + return + limit = rec.entry_order.limit_price + mid = quote.nbbo_mid + if limit is None or mid <= 0: + return + drift_pct = abs(float(limit) - mid) / mid * 100.0 + if drift_pct > tolerance_pct: + yield ValidationError( + field_path=f"{field_prefix}.entry_order.limit_price", + rule="option_premium_staleness", + message=( + f"{quote.occ_symbol} ({rec.recommendation_id}): entry limit premium " + f"{float(limit):.2f} is {drift_pct:.1f}% off the latest NBBO midpoint " + f"{mid:.2f} (bid/ask {quote.bid:.2f}/{quote.ask:.2f}; tolerance " + f"{tolerance_pct:g}%). Re-anchor the premium to the chain tool's NBBO." + ), + ) + + +def _check_option_expiration_covers_deadline( + rec: Recommendation, + *, + field_prefix: str, + reference_clock: datetime, +) -> Iterable[ValidationError]: + """(m) An InstrumentOption's expiration must not precede the entry deadline. + + The deadline is ``entry_window.deadline`` when present (a resting options + entry may legitimately wait until then), else ``output.timestamp`` — a + contract that expires before the entry can even fill is structurally + dead. Rule ``option_expiration_before_deadline``. + """ + if not isinstance(rec.instrument, InstrumentOption): + return + deadline = rec.entry_window.deadline if rec.entry_window is not None else reference_clock + if rec.instrument.expiration < deadline.astimezone(UTC).date(): + yield ValidationError( + field_path=f"{field_prefix}.instrument.expiration", + rule="option_expiration_before_deadline", + message=( + f"{rec.underlying} ({rec.recommendation_id}): expiration " + f"{rec.instrument.expiration.isoformat()} falls before the entry-window " + f"deadline {deadline.isoformat()}; the contract would expire before the " + "entry resolves" + ), + ) + + # --------------------------------------------------------------------------- # Layer-3 referential integrity # --------------------------------------------------------------------------- @@ -657,6 +766,8 @@ def validate_analyst_output( conviction_bands: dict[int, tuple[float, float]] | None = None, underlying_prices: Mapping[str, float] | None = None, price_staleness_tolerance_pct: float | None = None, + options_chain_reader: OptionsChainReader | None = None, + premium_staleness_tolerance_pct: float | None = None, ) -> ValidationResult: """Run Layer-2 + Layer-3 checks on *output*. @@ -687,6 +798,16 @@ def validate_analyst_output( Maximum tolerated drift between the analyst's implied entry anchor and the live close before the recommendation is flagged for redraft. Defaults to :data:`DEFAULT_PRICE_STALENESS_TOLERANCE_PCT`. + options_chain_reader: + Contract-level chain read the ALP-948 option checks resolve quotes + against (``option_contract_unknown`` / ``option_premium_staleness``). + ``None`` skips those two checks — production threads the same reader + the ``retrieve_options_chain`` tool is mounted with. + premium_staleness_tolerance_pct: + Maximum tolerated drift between an option entry's limit premium and + the latest NBBO midpoint. Defaults to + :data:`DEFAULT_PREMIUM_STALENESS_TOLERANCE_PCT`; production threads + the ``options_chain`` config section's value. Returns ------- @@ -699,6 +820,11 @@ def validate_analyst_output( if price_staleness_tolerance_pct is not None else DEFAULT_PRICE_STALENESS_TOLERANCE_PCT ) + premium_tolerance = ( + premium_staleness_tolerance_pct + if premium_staleness_tolerance_pct is not None + else DEFAULT_PREMIUM_STALENESS_TOLERANCE_PCT + ) errors: list[ValidationError] = [] warnings: list[ValidationWarning] = [] if output.mode == "normal" and output.recommendations is not None: @@ -744,6 +870,20 @@ def validate_analyst_output( tolerance_pct=tolerance, ) ) + if options_chain_reader is not None: + errors.extend( + _check_option_quote_anchoring( + rec, + field_prefix=field_prefix, + options_chain_reader=options_chain_reader, + tolerance_pct=premium_tolerance, + ) + ) + errors.extend( + _check_option_expiration_covers_deadline( + rec, field_prefix=field_prefix, reference_clock=output.timestamp + ) + ) elif output.mode == "watchlist" and output.watchlist is not None: for k, entry in enumerate(output.watchlist): errors.extend( diff --git a/src/alphamind/decision/strategist/harness.py b/src/alphamind/decision/strategist/harness.py index c8fcb9532..8047dded0 100644 --- a/src/alphamind/decision/strategist/harness.py +++ b/src/alphamind/decision/strategist/harness.py @@ -61,6 +61,10 @@ from alphamind.risk_guardrails.state_delivery.validation_tool_mcp import ( build_validate_guardrail_mcp_server, ) +from alphamind.state.repository.options_chain_read import OptionsChainReader +from alphamind.state.repository.options_chain_tool_mcp import ( + build_retrieve_options_chain_mcp_server, +) __all__ = [ "ContextOverflowFailure", @@ -92,6 +96,7 @@ _TOOL_NAME_PREFIXES: tuple[str, ...] = ( "mcp__alphamind_decision_validation__", "mcp__alphamind_synthesizer_retrieval__", + "mcp__alphamind_options_chain__", ) @@ -352,14 +357,23 @@ def _build_mcp_wiring( *, validation_state: ValidationToolState, retrieval_store: RetrievalStore, + options_chain_reader: OptionsChainReader | None = None, ) -> tuple[dict[str, Any], list[str]]: - """Compose the two MCP servers and merge their allowed-tool lists.""" + """Compose the MCP servers and merge their allowed-tool lists. + + The ``retrieve_options_chain`` server (ALP-948) mounts only when a reader + is supplied — composition without one (tests, degraded paths) keeps the + legacy two-server surface. + """ validation_servers, validation_tools = build_validate_guardrail_mcp_server(validation_state) retrieval_servers, retrieval_tools = build_retrieve_brief_mcp_server(retrieval_store) - return ( - {**validation_servers, **retrieval_servers}, - [*validation_tools, *retrieval_tools], - ) + servers = {**validation_servers, **retrieval_servers} + tools = [*validation_tools, *retrieval_tools] + if options_chain_reader is not None: + chain_servers, chain_tools = build_retrieve_options_chain_mcp_server(options_chain_reader) + servers |= chain_servers + tools += chain_tools + return servers, tools _INCOMPAT_KEYWORDS: frozenset[str] = frozenset({"format", "discriminator"}) @@ -578,6 +592,7 @@ async def run_strategist_harness( # noqa: PLR0913 — public signature is fixed phase: str = "strategist", telemetry_session: AsyncSession | None = None, provenance_root: Path | None = None, + options_chain_reader: OptionsChainReader | None = None, ) -> HarnessSuccess: """Invoke the strategist agent and return :class:`HarnessSuccess`. @@ -632,6 +647,7 @@ async def run_strategist_harness( # noqa: PLR0913 — public signature is fixed mcp_servers, allowed_tools = _build_mcp_wiring( validation_state=validation_state, retrieval_store=retrieval_store, + options_chain_reader=options_chain_reader, ) with system_prompt_as_file(system_prompt) as prompt_path: diff --git a/src/alphamind/decision/strategist/runner.py b/src/alphamind/decision/strategist/runner.py index 3aa0414c2..775e493e5 100644 --- a/src/alphamind/decision/strategist/runner.py +++ b/src/alphamind/decision/strategist/runner.py @@ -64,6 +64,10 @@ from alphamind.risk_guardrails.regime_adaptation import RegimeTransitionBreach from alphamind.risk_guardrails.state_delivery import build_initial_validation_state from alphamind.risk_guardrails.state_delivery.config import StateDeliveryConfig +from alphamind.state.repository.options_chain_read import OptionsChainReader +from alphamind.state.repository.options_chain_tool_mcp import ( + RETRIEVE_OPTIONS_CHAIN_TOOL_NAME, +) __all__ = [ "STRATEGIST_TOOL_NAMES", @@ -99,9 +103,22 @@ "mcp__alphamind_decision_validation__validate_guardrail", "mcp__alphamind_decision_validation__validate_guardrail_batch", "mcp__alphamind_synthesizer_retrieval__retrieve_brief", + RETRIEVE_OPTIONS_CHAIN_TOOL_NAME, ) +def _tool_names(options_chain_reader: OptionsChainReader | None) -> tuple[str, ...]: + """The AVAILABLE TOOLS render for this composition's mounted surface. + + The chain tool (ALP-948) mounts only when the composition supplies an + ``OptionsChainReader``; drop it from the rendered list otherwise so the + prompt never advertises an uncallable tool. + """ + if options_chain_reader is not None: + return STRATEGIST_TOOL_NAMES + return tuple(n for n in STRATEGIST_TOOL_NAMES if n != RETRIEVE_OPTIONS_CHAIN_TOOL_NAME) + + # --------------------------------------------------------------------------- # Result type # --------------------------------------------------------------------------- @@ -209,6 +226,7 @@ async def run_strategist( # noqa: PLR0913 — signature dictated by ALP-308 spe sdk_query_fn: Callable[..., AsyncIterator[Any]] | None = None, borrow_cost_resolver: Callable[[str], float | None] | None = None, prior_health_snapshots: tuple[ThesisHealthSnapshot, ...] = (), + options_chain_reader: OptionsChainReader | None = None, progress: ProgressEmitter = NOOP_PROGRESS_EMITTER, phase: str = "strategist", ) -> StrategistResult: @@ -254,6 +272,7 @@ async def run_strategist( # noqa: PLR0913 — signature dictated by ALP-308 spe user_message = _assemble_user_message( mode=mode, halt_state=halt_state, + options_chain_reader=options_chain_reader, strategist_view=strategist_view, invocation_id=invocation_id, timestamp=timestamp, @@ -289,6 +308,7 @@ async def run_strategist( # noqa: PLR0913 — signature dictated by ALP-308 spe archive_root=archive_root, provenance_root=provenance_root, sdk_query_fn=sdk_query_fn, + options_chain_reader=options_chain_reader, progress=progress, phase=phase, ) @@ -319,6 +339,7 @@ def _assemble_user_message( # noqa: PLR0913 — fan-in of input-bundle assemble *, mode: Literal["normal", "defensive_posture"], halt_state: HaltState | None, + options_chain_reader: OptionsChainReader | None, strategist_view: StrategistView, invocation_id: str, timestamp: datetime, @@ -357,7 +378,7 @@ def _assemble_user_message( # noqa: PLR0913 — fan-in of input-bundle assemble available_for_new_positions_usd=available_for_new_positions_usd, current_price_lookup=current_price_lookup, synthesizer_brief_text=synthesizer_brief_text, - tool_names=STRATEGIST_TOOL_NAMES, + tool_names=_tool_names(options_chain_reader), position_zones=position_zones, sector_label_display=sector_label_display, regime_transition_breaches=regime_transition_breaches, @@ -381,7 +402,7 @@ def _assemble_user_message( # noqa: PLR0913 — fan-in of input-bundle assemble available_for_new_positions_usd=available_for_new_positions_usd, current_price_lookup=current_price_lookup, synthesizer_brief_text=synthesizer_brief_text, - tool_names=STRATEGIST_TOOL_NAMES, + tool_names=_tool_names(options_chain_reader), position_zones=position_zones, sector_label_display=sector_label_display, regime_transition_breaches=regime_transition_breaches, diff --git a/src/alphamind/execution/continuous_monitor/breach_loop/production_substrate.py b/src/alphamind/execution/continuous_monitor/breach_loop/production_substrate.py index 948beff31..a9980862a 100644 --- a/src/alphamind/execution/continuous_monitor/breach_loop/production_substrate.py +++ b/src/alphamind/execution/continuous_monitor/breach_loop/production_substrate.py @@ -169,6 +169,7 @@ def load_breach_loop_loaded_config(config_dir: Path) -> LoadedConfig: from alphamind.config.models.feedback import FeedbackLoopConfig from alphamind.config.models.guardrails import GuardrailsConfig from alphamind.config.models.llm_failure import LLMFailureConfig + from alphamind.config.models.options_chain import OptionsChainConfig from alphamind.config.models.scheduler import SchedulerConfig from alphamind.config.models.venue import VenueConfig @@ -189,6 +190,9 @@ def load_breach_loop_loaded_config(config_dir: Path) -> LoadedConfig: continuous_monitor=ContinuousMonitorConfig.model_validate( read_yaml_file(config_dir / "continuous_monitor.yaml") ), + options_chain=OptionsChainConfig.model_validate( + read_yaml_file(config_dir / "options_chain.yaml") + ), profiles=load_profiles(config_dir), regimes=load_regimes(config_dir), modes=load_modes(config_dir), diff --git a/src/alphamind/pipeline/decision.py b/src/alphamind/pipeline/decision.py index 96280376c..2e8fdd3b5 100644 --- a/src/alphamind/pipeline/decision.py +++ b/src/alphamind/pipeline/decision.py @@ -103,6 +103,10 @@ RegimeOverride, ) from alphamind.state.config import StatePersistenceConfig +from alphamind.state.repository.options_chain_read import ( + OptionsChainReader, + TickerOptionsContext, +) # Synthesizer's ``RetrievalStore`` is the harness-side type the analyst, # strategist, and PM consume. @@ -381,6 +385,9 @@ async def run_decision_pipeline( # noqa: PLR0913, PLR0915 — composition surfa venue_config: VenueConfig | None = None, execution_mode: ExecutionMode | None = None, execution_config: ExecutionConfig | None = None, + options_chain_reader: OptionsChainReader | None = None, + options_context: Mapping[str, TickerOptionsContext] | None = None, + premium_staleness_tolerance_pct: float | None = None, ) -> DecisionPipelineResult: """Run the decision-layer composition end-to-end. @@ -601,6 +608,9 @@ def _strategist_price_lookup(ticker: str) -> float: provenance_root=provenance_root, agent_config=resolved_agents.get(AgentName.analyst.value), borrow_cost_resolver=borrow_cost_resolver, + options_chain_reader=options_chain_reader, + options_context=options_context, + premium_staleness_tolerance_pct=premium_staleness_tolerance_pct, progress=progress, phase="analyst", ) @@ -633,6 +643,7 @@ def _strategist_price_lookup(ticker: str) -> float: regime_transition_breaches=regime_transition_breaches, borrow_cost_resolver=borrow_cost_resolver, prior_health_snapshots=prior_health_snapshots, + options_chain_reader=options_chain_reader, progress=progress, phase="strategist", ) diff --git a/src/alphamind/scheduler/orchestrator.py b/src/alphamind/scheduler/orchestrator.py index 9d98c772c..9ddb051e6 100644 --- a/src/alphamind/scheduler/orchestrator.py +++ b/src/alphamind/scheduler/orchestrator.py @@ -50,6 +50,7 @@ from __future__ import annotations +import asyncio import json import logging import os @@ -185,6 +186,11 @@ SqlOptionPriceProvider, build_sql_portfolio_state_repository, ) +from alphamind.state.repository.options_chain_read import ( + ChainFilterParams, + SqlOptionsChainReader, + build_options_context, +) from alphamind.state.tables.invocations import InvocationRow __all__ = ["InvocationSummary", "run_invocation"] @@ -404,6 +410,8 @@ def _build_decision_kwargs( # noqa: PLR0913 — composition surface threads eac venue_config: VenueConfig | None = None, execution_mode: ExecutionMode | None = None, execution_config: ExecutionConfig | None = None, + options_chain_reader: Any = None, + options_context: Any = None, ) -> dict[str, Any]: """Assemble the kwargs ``run_decision_pipeline`` requires.""" resolved = pipeline_config.resolved @@ -445,6 +453,13 @@ def _build_decision_kwargs( # noqa: PLR0913 — composition surface threads eac "venue_config": venue_config, "execution_mode": execution_mode, "execution_config": execution_config, + # ALP-948 — the contract-level options surface for the analyst / + # strategist: the chain reader backs the retrieve_options_chain tool + # and the analyst validator's premium checks; options_context is the + # per-ticker IVr/expirations decoration on REFERENCE PRICES. + "options_chain_reader": options_chain_reader, + "options_context": options_context, + "premium_staleness_tolerance_pct": (resolved.options_chain.premium_staleness_tolerance_pct), } @@ -960,6 +975,28 @@ async def _run_fill_collection_write_unit() -> FillCollectionSummary: # rather than re-parsing ``execution.yaml`` — ``parse_loaded_config`` # already did the work inside ``insert_invocation_record``. broker_routing_active = context.debug_e2e is None + # ALP-948 — per-invocation options chain reader + per-ticker options + # context. The reader applies the resolved options_chain filter policy; + # the context map decorates the analyst's REFERENCE PRICES lines for the + # same tickers the reference map carries. Built off the sync session + # factory in a worker thread so the per-ticker chain reads do not block + # the event loop. + options_chain_cfg = pipeline_config.resolved.options_chain + options_chain_reader = SqlOptionsChainReader( + sync_session_factory=context.sync_session_factory, + params=ChainFilterParams( + strike_band_pct=options_chain_cfg.strike_band_pct, + min_days_to_expiration=options_chain_cfg.min_days_to_expiration, + max_days_to_expiration=options_chain_cfg.max_days_to_expiration, + min_open_interest=options_chain_cfg.min_open_interest, + max_contracts_rendered=options_chain_cfg.max_contracts_rendered, + ), + ) + options_context = await asyncio.to_thread( + build_options_context, + options_chain_reader, + tickers=sorted(fill_collection_inputs.market_inputs.underlying_prices), + ) decision_kwargs = _build_decision_kwargs( invocation_id=invocation_id, pipeline_config=pipeline_config, @@ -985,6 +1022,8 @@ async def _run_fill_collection_write_unit() -> FillCollectionSummary: venue_config=venue_config if broker_routing_active else None, execution_mode=execution_mode if broker_routing_active else None, execution_config=pipeline_config.loaded.execution if broker_routing_active else None, + options_chain_reader=options_chain_reader, + options_context=options_context, ) _emit_phase_transition(sse_emitter, invocation_id=invocation_id, phase="decide") decision_result = await run_decision_pipeline(**decision_kwargs, progress=progress) diff --git a/src/alphamind/state/repository/options_chain_read.py b/src/alphamind/state/repository/options_chain_read.py new file mode 100644 index 000000000..3129de48b --- /dev/null +++ b/src/alphamind/state/repository/options_chain_read.py @@ -0,0 +1,505 @@ +"""Options chain-slice read + selection core — ALP-948. + +The decision layer's contract-level read over ``options_contract_snapshots``: +a filtered per-underlying chain slice (the ``retrieve_options_chain`` MCP +tool's data source), a per-contract latest-quote lookup (the analyst +validator's premium anchor), and a per-ticker options context (IV rank + +liquid expirations) rendered into the REFERENCE PRICES block. + +Selection (:func:`select_chain_slice`) is a pure function over plain +:class:`ContractQuote` tuples so the strike-band / expiration-window / +open-interest policy is unit-testable with no DB session. The SQL shell +(:class:`SqlOptionsChainReader`) follows the session conventions of +:mod:`alphamind.state.repository.sql_option_price_provider` — one fresh sync +``Session`` per read against the WAL-mode SQLite database. + +IV rank reuses the q3 pure compute read-only: +:func:`~alphamind.distillation.q3.atm_iv_baseline_loaders.load_atm_iv_history_by_ticker` +plus :func:`~alphamind.distillation.q3.atm_iv_baseline_compute.compute_atm_iv_baseline` +— explicitly NOT ``load_and_refresh_atm_iv_baselines``, whose upsert must not +run a second time per invocation. +""" + +from __future__ import annotations + +import dataclasses +import logging +from collections.abc import Callable, Iterable, Mapping, Sequence +from dataclasses import dataclass +from datetime import UTC, date, datetime +from typing import Any, Literal, Protocol + +from sqlalchemy import func, select +from sqlalchemy.orm import Session, sessionmaker + +from alphamind._kernel.ids import OccSymbol, make_occ_symbol +from alphamind.distillation._calibration_core import CalibrationState +from alphamind.distillation.q3.atm_iv_baseline_compute import compute_atm_iv_baseline +from alphamind.distillation.q3.atm_iv_baseline_loaders import load_atm_iv_history_by_ticker +from alphamind.persistence.models import OptionsContracts, OptionsContractSnapshots + +__all__ = [ + "ChainFilterParams", + "ChainSlice", + "ContractQuote", + "OptionsChainReader", + "SqlOptionsChainReader", + "TickerOptionsContext", + "build_options_context", + "occ_symbol_for_contract", +] + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# IV-rank window policy +# --------------------------------------------------------------------------- + +# Mirror q3's ATM-IV baseline window (``q3/_loaders._ATM_IV_HISTORY_DAYS`` / +# ``_ATM_IV_MIN_OBSERVATIONS``). Restated here rather than imported because +# those names are private to the q3 loader composition; the IVr column is +# advisory context, so a future drift between the two consumers degrades +# gracefully rather than breaking a contract. +_IV_RANK_WINDOW_DAYS: int = 252 +_IV_RANK_MIN_OBSERVATIONS: int = 60 + + +# --------------------------------------------------------------------------- +# Typed records +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class ChainFilterParams: + """Config-owned chain-slice selection policy (``options_chain`` section). + + Filter values are operator-tunable configuration, never LLM-supplied — + the ``retrieve_options_chain`` tool's input schema carries only the + underlying ticker. + """ + + strike_band_pct: float + min_days_to_expiration: int + max_days_to_expiration: int + min_open_interest: int + max_contracts_rendered: int + + +@dataclass(frozen=True, slots=True) +class ContractQuote: + """Latest usable snapshot for one option contract.""" + + 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 + + @property + def nbbo_mid(self) -> float: + """Bid/ask midpoint — the premium anchor the validator checks against.""" + return (self.bid + self.ask) / 2.0 + + +@dataclass(frozen=True, slots=True) +class ChainSlice: + """Filtered per-underlying chain view the chain tool renders. + + ``omitted`` is a human-readable note naming what the filters dropped — + the slice never silently truncates. ``None`` when nothing was dropped. + """ + + underlying: str + spot: float + as_of: datetime + contracts: tuple[ContractQuote, ...] + omitted: str | None + + +@dataclass(frozen=True, slots=True) +class TickerOptionsContext: + """Per-ticker options context rendered into the REFERENCE PRICES block. + + ``iv_rank`` is the ATM-IV percentile rank (0-100) from the q3 pure + compute, ``None`` while the baseline is not yet calibrated or the rank is + undefined. ``liquid_expirations`` are the expirations surviving the + chain-slice filters. + """ + + iv_rank: float | None + liquid_expirations: tuple[date, ...] + + +# --------------------------------------------------------------------------- +# OCC symbol construction +# --------------------------------------------------------------------------- + + +def occ_symbol_for_contract( + *, + underlying: str, + expiration: date, + strike: float, + contract_type: Literal["call", "put"], +) -> OccSymbol: + """Build the compressed OCC symbol for a single-leg contract. + + Format ``{ROOT}{YYMMDD}{C|P}{strike_milli:08d}`` — the collector's + Polygon ``contract_ticker`` minus its ``O:`` prefix (see + ``risk_guardrails.guardrail_evaluation.iv_sourcing._polygon_options_contract_ticker``). + Share-class dots drop per OCC convention (``BRK.B`` → ``BRKB``); the + strike is rounded to thousandths before formatting to avoid binary-float + drift (``12.50`` → ``00012500``). + """ + root = underlying.upper().replace(".", "") + expiry = expiration.strftime("%y%m%d") + cp = "C" if contract_type == "call" else "P" + strike_milli = round(strike * 1000) + return make_occ_symbol(f"{root}{expiry}{cp}{strike_milli:08d}") + + +def _polygon_contract_ticker(occ: OccSymbol) -> str: + """The collector-written ``contract_ticker`` key for a compressed OCC symbol.""" + return f"O:{occ}" + + +# --------------------------------------------------------------------------- +# Pure selection core +# --------------------------------------------------------------------------- + + +def select_chain_slice( + rows: tuple[ContractQuote, ...], + *, + underlying: str, + spot: float, + as_of: datetime, + params: ChainFilterParams, +) -> ChainSlice: + """Apply the configured strike band, expiration window, open-interest + floor, and contract cap to *rows*. + + Pure function — *as_of* anchors the days-to-expiration window (the + caller's clock; the SQL shell passes its injected ``now``). When the + survivors exceed ``max_contracts_rendered``, the cap keeps the contracts + closest to the money; every drop is named in ``ChainSlice.omitted``. + """ + as_of_date = as_of.astimezone(UTC).date() + band = spot * params.strike_band_pct / 100.0 + + outside_band = 0 + outside_window = 0 + below_oi_floor = 0 + survivors: list[ContractQuote] = [] + for row in rows: + if abs(row.strike - spot) > band: + outside_band += 1 + continue + dte = (row.expiration - as_of_date).days + if dte < params.min_days_to_expiration or dte > params.max_days_to_expiration: + outside_window += 1 + continue + if row.open_interest < params.min_open_interest: + below_oi_floor += 1 + continue + survivors.append(row) + + beyond_cap = max(0, len(survivors) - params.max_contracts_rendered) + if beyond_cap: + survivors.sort(key=lambda r: (abs(r.strike - spot), r.expiration, r.contract_type)) + survivors = survivors[: params.max_contracts_rendered] + survivors.sort(key=lambda r: (r.expiration, r.strike, r.contract_type)) + + notes: list[str] = [] + if outside_band: + notes.append(f"{outside_band} outside the ±{params.strike_band_pct:g}% strike band") + if outside_window: + notes.append( + f"{outside_window} outside the " + f"{params.min_days_to_expiration}-{params.max_days_to_expiration} DTE window" + ) + if below_oi_floor: + notes.append(f"{below_oi_floor} below the open-interest floor {params.min_open_interest}") + if beyond_cap: + notes.append( + f"{beyond_cap} furthest-from-money beyond the " + f"{params.max_contracts_rendered}-contract cap" + ) + omitted = f"omitted: {', '.join(notes)}" if notes else None + + return ChainSlice( + underlying=underlying, + spot=spot, + as_of=as_of, + contracts=tuple(survivors), + omitted=omitted, + ) + + +# --------------------------------------------------------------------------- +# Reader Protocol +# --------------------------------------------------------------------------- + + +class OptionsChainReader(Protocol): + """Contract-level options read surface for the decision layer.""" + + def chain_slice(self, underlying: str) -> ChainSlice | None: + """The filtered chain slice for *underlying*, or ``None`` without usable data.""" + ... + + def latest_quote(self, occ: OccSymbol) -> ContractQuote | None: + """The latest usable snapshot for one contract, or ``None``.""" + ... + + def options_context(self, underlying: str) -> TickerOptionsContext | None: + """IV rank + liquid expirations for *underlying*, or ``None`` without a chain.""" + ... + + +# --------------------------------------------------------------------------- +# SQL shell +# --------------------------------------------------------------------------- + + +def _parse_snapshot_ts(raw: str) -> datetime: + """Parse the collector's snapshot timestamp into a tz-aware UTC datetime.""" + parsed = datetime.fromisoformat(raw) + if parsed.tzinfo is None or parsed.utcoffset() is None: + parsed = parsed.replace(tzinfo=UTC) + return parsed + + +_SNAPSHOT_COLUMNS = ( + OptionsContractSnapshots.contract_ticker, + OptionsContractSnapshots.snapshot_ts, + OptionsContractSnapshots.bid, + OptionsContractSnapshots.ask, + OptionsContractSnapshots.implied_volatility, + OptionsContractSnapshots.delta, + OptionsContractSnapshots.open_interest, + OptionsContracts.underlying_ticker, + OptionsContracts.expiration_date, + OptionsContracts.strike_price, + OptionsContracts.contract_type, +) + + +class SqlOptionsChainReader: + """:class:`OptionsChainReader` over ``options_contract_snapshots``. + + Reads the latest snapshot per contract (correlated ``MAX(snapshot_ts)`` + subquery, mirroring :class:`SqlOptionPriceProvider`) joined to + ``options_contracts`` for the strike / expiration / type reference + fields. One fresh sync ``Session`` per read; WAL-mode SQLite permits the + concurrent reader. + + ``params`` is exposed read-only so the subprocess transport's pickle shim + can reconstruct an equivalent reader against the worker's own session + factory (see ``analysis._sdk_subprocess``). + """ + + def __init__( + self, + *, + sync_session_factory: sessionmaker[Session], + params: ChainFilterParams, + now: Callable[[], datetime] = lambda: datetime.now(UTC), + ) -> None: + self._sync_session_factory = sync_session_factory + self._params = params + self._now = now + + @property + def params(self) -> ChainFilterParams: + """The chain-slice selection policy this reader applies.""" + return self._params + + # -- chain_slice -------------------------------------------------------- + + def chain_slice(self, underlying: str) -> ChainSlice | None: + latest_subq = ( + select( + OptionsContractSnapshots.contract_ticker.label("ct"), + func.max(OptionsContractSnapshots.snapshot_ts).label("max_ts"), + ) + .where(OptionsContractSnapshots.underlying_ticker == underlying) + .group_by(OptionsContractSnapshots.contract_ticker) + .subquery() + ) + stmt = ( + select(*_SNAPSHOT_COLUMNS, OptionsContractSnapshots.underlying_price) + .join( + latest_subq, + (OptionsContractSnapshots.contract_ticker == latest_subq.c.ct) + & (OptionsContractSnapshots.snapshot_ts == latest_subq.c.max_ts), + ) + .join( + OptionsContracts, + OptionsContracts.contract_ticker == OptionsContractSnapshots.contract_ticker, + ) + ) + + quotes: list[ContractQuote] = [] + unusable = 0 + spot: float | None = None + spot_ts: datetime | None = None + with self._sync_session_factory() as session: + for row in session.execute(stmt).all(): + (*snapshot_fields, underlying_price) = row + quote = _quote_from_row(tuple(snapshot_fields)) + if quote is None: + unusable += 1 + else: + quotes.append(quote) + # Spot rides on the freshest snapshot row carrying an + # underlying_price — usable-quote status is irrelevant to it. + row_ts = _parse_snapshot_ts(row[1]) + if underlying_price is not None and (spot_ts is None or row_ts > spot_ts): + spot = float(underlying_price) + spot_ts = row_ts + + if spot is None or spot <= 0: + return None + + chain = select_chain_slice( + tuple(quotes), + underlying=underlying, + spot=spot, + as_of=self._now(), + params=self._params, + ) + if unusable: + note = f"{unusable} contract(s) without a usable two-sided quote" + omitted = f"{chain.omitted}, {note}" if chain.omitted else f"omitted: {note}" + chain = dataclasses.replace(chain, omitted=omitted) + return chain + + # -- latest_quote ------------------------------------------------------- + + def latest_quote(self, occ: OccSymbol) -> ContractQuote | None: + contract_ticker = _polygon_contract_ticker(occ) + stmt = ( + select(*_SNAPSHOT_COLUMNS) + .join( + OptionsContracts, + OptionsContracts.contract_ticker == OptionsContractSnapshots.contract_ticker, + ) + .where(OptionsContractSnapshots.contract_ticker == contract_ticker) + .order_by(OptionsContractSnapshots.snapshot_ts.desc()) + .limit(1) + ) + with self._sync_session_factory() as session: + row = session.execute(stmt).first() + if row is None: + return None + return _quote_from_row(tuple(row)) + + # -- options_context ---------------------------------------------------- + + def options_context(self, underlying: str) -> TickerOptionsContext | None: + chain = self.chain_slice(underlying) + if chain is None or not chain.contracts: + return None + expirations = tuple(sorted({quote.expiration for quote in chain.contracts})) + return TickerOptionsContext( + iv_rank=self._iv_rank(underlying), + liquid_expirations=expirations, + ) + + def _iv_rank(self, underlying: str) -> float | None: + """Read-only IV-rank via the q3 pure compute — no baseline upsert.""" + as_of_iso = self._now().astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + with self._sync_session_factory() as session: + history = load_atm_iv_history_by_ticker( + session, + ticker_scope=(underlying,), + as_of=as_of_iso, + window_days=_IV_RANK_WINDOW_DAYS, + )[underlying] + result = compute_atm_iv_baseline( + history, + window_days=_IV_RANK_WINDOW_DAYS, + min_observations=_IV_RANK_MIN_OBSERVATIONS, + ) + if result.rank.state is not CalibrationState.CALIBRATED or result.rank.value is None: + return None + percentile = result.rank.value.get("iv_rank_percentile") + return float(percentile) if percentile is not None else None + + +def _quote_from_row(row: Sequence[Any]) -> ContractQuote | None: + """Build a :class:`ContractQuote` from a ``_SNAPSHOT_COLUMNS`` row. + + Returns ``None`` for an unusable row: a missing or crossed two-sided + quote, a missing IV or delta, or a contract ticker outside the OCC + pattern. A zero bid alone is usable — deep-OTM contracts legitimately + quote ``bid=0``. + """ + ( + contract_ticker, + snapshot_ts, + bid, + ask, + implied_volatility, + delta, + open_interest, + underlying_ticker, + expiration_date, + strike_price, + contract_type, + ) = row + if bid is None or ask is None or float(ask) <= 0 or float(bid) < 0 or float(bid) > float(ask): + return None + if implied_volatility is None or delta is None: + return None + if contract_type not in ("call", "put"): + return None + try: + occ = make_occ_symbol(str(contract_ticker).removeprefix("O:")) + except ValueError: + logger.warning("skipping non-OCC contract_ticker %r", contract_ticker) + return None + return ContractQuote( + occ_symbol=occ, + underlying=str(underlying_ticker), + expiration=date.fromisoformat(str(expiration_date)), + strike=float(strike_price), + contract_type=contract_type, + bid=float(bid), + ask=float(ask), + implied_volatility=float(implied_volatility), + delta=float(delta), + open_interest=int(open_interest) if open_interest is not None else 0, + snapshot_ts=_parse_snapshot_ts(str(snapshot_ts)), + ) + + +# --------------------------------------------------------------------------- +# Per-invocation context assembly +# --------------------------------------------------------------------------- + + +def build_options_context( + reader: OptionsChainReader, + *, + tickers: Iterable[str], +) -> Mapping[str, TickerOptionsContext]: + """Assemble the per-ticker options-context map for the REFERENCE PRICES render. + + Tickers without a usable chain are simply absent — their reference-price + line stays the bare price line. + """ + context: dict[str, TickerOptionsContext] = {} + for ticker in tickers: + ticker_context = reader.options_context(ticker) + if ticker_context is not None: + context[ticker] = ticker_context + return context diff --git a/src/alphamind/state/repository/options_chain_tool_mcp.py b/src/alphamind/state/repository/options_chain_tool_mcp.py new file mode 100644 index 000000000..4acd68cac --- /dev/null +++ b/src/alphamind/state/repository/options_chain_tool_mcp.py @@ -0,0 +1,122 @@ +"""Per-invocation MCP wrapper around the options chain reader — ALP-948. + +Wraps :meth:`OptionsChainReader.chain_slice` as a Claude Agent SDK MCP tool +the analyst and strategist call during reasoning to pull contract-level +options data for one underlying. Mirrors the per-invocation factory shape of +:func:`alphamind.risk_guardrails.state_delivery.validation_tool_mcp.build_validate_guardrail_mcp_server`: +each call returns a fresh SDK MCP server whose handler closes over the +supplied reader. + +The tool's input schema carries exactly one required string property — +``underlying``. Filter values (strike band, expiration window, open-interest +floor, contract cap) are config-owned via the reader's +:class:`~alphamind.state.repository.options_chain_read.ChainFilterParams`, +never LLM-supplied. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from claude_agent_sdk import McpSdkServerConfig, create_sdk_mcp_server, tool + +from alphamind.state.repository.options_chain_read import ( + ChainSlice, + ContractQuote, + OptionsChainReader, +) + +__all__ = [ + "OPTIONS_CHAIN_SERVER_NAME", + "RETRIEVE_OPTIONS_CHAIN_TOOL_NAME", + "build_retrieve_options_chain_mcp_server", +] + + +OPTIONS_CHAIN_SERVER_NAME = "alphamind_options_chain" +"""Canonical SDK MCP server name; the wire-form tool name derives from it.""" + +RETRIEVE_OPTIONS_CHAIN_TOOL_NAME = f"mcp__{OPTIONS_CHAIN_SERVER_NAME}__retrieve_options_chain" +"""Wire-form tool name rendered into the agents' AVAILABLE TOOLS section.""" + + +_RETRIEVE_OPTIONS_CHAIN_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "required": ["underlying"], + "properties": { + "underlying": { + "type": "string", + "description": "Underlying equity ticker (e.g., NVDA).", + }, + }, +} + + +def build_retrieve_options_chain_mcp_server( + reader: OptionsChainReader, + *, + server_name: str = OPTIONS_CHAIN_SERVER_NAME, +) -> tuple[dict[str, McpSdkServerConfig], list[str]]: + """Build a per-invocation SDK MCP server bound to *reader*. + + Returns ``(mcp_servers_dict, allowed_tool_names)`` ready for direct + assignment to ``ClaudeAgentOptions.mcp_servers`` and + ``ClaudeAgentOptions.allowed_tools``. The allowed-tools list contains the + single name ``mcp____retrieve_options_chain``. + """ + + @tool( + "retrieve_options_chain", + ( + "Retrieve the current options chain slice for an underlying: " + "near-the-money contracts inside the configured strike band, " + "expiration window, and open-interest floor, each with OCC symbol, " + "expiration, strike, type, NBBO bid/ask, IV, delta, open interest, " + "and snapshot age. Premiums for a long single-leg proposal anchor " + "to this tool's NBBO." + ), + _RETRIEVE_OPTIONS_CHAIN_INPUT_SCHEMA, + ) + async def _retrieve_options_chain(args: dict[str, Any]) -> dict[str, Any]: + underlying = str(args["underlying"]).strip().upper() + chain = reader.chain_slice(underlying) + if chain is None or not chain.contracts: + text = ( + f"No usable options chain data for {underlying} this invocation — " + "no quoted contracts survive the configured filters. Prefer the " + "equity expression for this name." + ) + else: + text = _render_chain_slice(chain) + return {"content": [{"type": "text", "text": text}]} + + server = create_sdk_mcp_server(name=server_name, tools=[_retrieve_options_chain]) + allowed = [f"mcp__{server_name}__retrieve_options_chain"] + return {server_name: server}, allowed + + +# --------------------------------------------------------------------------- +# Rendering +# --------------------------------------------------------------------------- + + +def _render_chain_slice(chain: ChainSlice) -> str: + """One header line, one line per contract, plus the omitted note.""" + lines = [ + f"{chain.underlying} options chain — spot {chain.spot:.2f}, as of {chain.as_of.isoformat()}" + ] + lines.extend(_render_contract(quote, as_of=chain.as_of) for quote in chain.contracts) + if chain.omitted is not None: + lines.append(chain.omitted) + return "\n".join(lines) + + +def _render_contract(quote: ContractQuote, *, as_of: datetime) -> str: + age_minutes = max(0, round((as_of - quote.snapshot_ts).total_seconds() / 60.0)) + cp = "C" if quote.contract_type == "call" else "P" + return ( + f" {quote.occ_symbol} {quote.expiration.isoformat()} {quote.strike:g}{cp} " + f"bid/ask {quote.bid:.2f}/{quote.ask:.2f} IV {quote.implied_volatility:.2f} " + f"delta {quote.delta:+.2f} OI {quote.open_interest} age {age_minutes}m" + ) diff --git a/tests/analysis/test_sdk_subprocess.py b/tests/analysis/test_sdk_subprocess.py index 57cd4ca8a..280c87080 100644 --- a/tests/analysis/test_sdk_subprocess.py +++ b/tests/analysis/test_sdk_subprocess.py @@ -237,6 +237,7 @@ def test_prepare_validation_state_round_trips_with_real_library_config() -> None LoadedConfig, MainConfig, Mode, + OptionsChainConfig, Regime, RuntimeDimensions, RunType, @@ -268,6 +269,7 @@ def _read(name: str) -> dict[str, Any]: assets=AssetsConfig.model_validate(_read("assets.yaml")), agents=AgentsConfig.model_validate(_read("agents.yaml")), continuous_monitor=ContinuousMonitorConfig.model_validate(_read("continuous_monitor.yaml")), + options_chain=OptionsChainConfig.model_validate(_read("options_chain.yaml")), profiles=dict(load_profiles(config_dir)), regimes=dict(load_regimes(config_dir)), modes=dict(load_modes(config_dir)), diff --git a/tests/config/test_cross_reference.py b/tests/config/test_cross_reference.py index 7224f11ff..7d6ad3655 100644 --- a/tests/config/test_cross_reference.py +++ b/tests/config/test_cross_reference.py @@ -31,6 +31,7 @@ LLMFailureConfig, LoadedConfig, MainConfig, + OptionsChainConfig, Overlay, Profile, ProfileConfig, @@ -64,6 +65,7 @@ def _read(name: str) -> dict[str, Any]: _ASSETS = AssetsConfig.model_validate(_read("assets.yaml")) _AGENTS = AgentsConfig.model_validate(_read("agents.yaml")) _CONTINUOUS_MONITOR = ContinuousMonitorConfig.model_validate(_read("continuous_monitor.yaml")) +_OPTIONS_CHAIN = OptionsChainConfig.model_validate(_read("options_chain.yaml")) _PROFILES = load_profiles(CONFIG_DIR) _REGIMES = load_regimes(CONFIG_DIR) _MODES = load_modes(CONFIG_DIR) @@ -95,6 +97,7 @@ def _make_inputs( assets=assets if assets is not None else _ASSETS, agents=agents if agents is not None else _AGENTS, continuous_monitor=_CONTINUOUS_MONITOR, + options_chain=_OPTIONS_CHAIN, profiles=dict(profiles) if profiles is not None else dict(_PROFILES), regimes=dict(regimes) if regimes is not None else dict(_REGIMES), modes=dict(_MODES), diff --git a/tests/config/test_models_package.py b/tests/config/test_models_package.py index 7f3082229..a252c7677 100644 --- a/tests/config/test_models_package.py +++ b/tests/config/test_models_package.py @@ -56,6 +56,7 @@ "NarrativeLag", "NewsDigestConfig", "NewsOutletsConfig", + "OptionsChainConfig", "OrderType", "OutletEntry", "Overlay", diff --git a/tests/config/test_resolver.py b/tests/config/test_resolver.py index 3d54ac88e..88c3393b8 100644 --- a/tests/config/test_resolver.py +++ b/tests/config/test_resolver.py @@ -34,6 +34,7 @@ LoadedConfig, MainConfig, Mode, + OptionsChainConfig, Overlay, PendingOrdersDefault, Profile, @@ -68,6 +69,7 @@ def _read(name: str) -> dict[str, Any]: _ASSETS = AssetsConfig.model_validate(_read("assets.yaml")) _AGENTS = AgentsConfig.model_validate(_read("agents.yaml")) _CONTINUOUS_MONITOR = ContinuousMonitorConfig.model_validate(_read("continuous_monitor.yaml")) +_OPTIONS_CHAIN = OptionsChainConfig.model_validate(_read("options_chain.yaml")) _PROFILES = load_profiles(CONFIG_DIR) _REGIMES = load_regimes(CONFIG_DIR) _MODES = load_modes(CONFIG_DIR) @@ -103,6 +105,7 @@ def _compose( assets=_ASSETS, agents=_AGENTS, continuous_monitor=_CONTINUOUS_MONITOR, + options_chain=_OPTIONS_CHAIN, profiles=dict(profiles) if profiles is not None else dict(_PROFILES), regimes=dict(regimes) if regimes is not None else dict(_REGIMES), modes=dict(_MODES), diff --git a/tests/config/test_semantic_validation.py b/tests/config/test_semantic_validation.py index 0f7486fc3..ee4cfebca 100644 --- a/tests/config/test_semantic_validation.py +++ b/tests/config/test_semantic_validation.py @@ -37,6 +37,7 @@ LLMFailureConfig, LoadedConfig, MainConfig, + OptionsChainConfig, Profile, Regime, ResolvedConfig, @@ -71,6 +72,7 @@ def _read(name: str) -> dict[str, Any]: _ASSETS = AssetsConfig.model_validate(_read("assets.yaml")) _AGENTS = AgentsConfig.model_validate(_read("agents.yaml")) _CONTINUOUS_MONITOR = ContinuousMonitorConfig.model_validate(_read("continuous_monitor.yaml")) +_OPTIONS_CHAIN = OptionsChainConfig.model_validate(_read("options_chain.yaml")) _PROFILES = load_profiles(CONFIG_DIR) _REGIMES = load_regimes(CONFIG_DIR) _MODES = load_modes(CONFIG_DIR) @@ -91,6 +93,7 @@ def _shipped_loaded() -> LoadedConfig: assets=_ASSETS, agents=_AGENTS, continuous_monitor=_CONTINUOUS_MONITOR, + options_chain=_OPTIONS_CHAIN, profiles=dict(_PROFILES), regimes=dict(_REGIMES), modes=dict(_MODES), diff --git a/tests/config/test_snapshot.py b/tests/config/test_snapshot.py index 8d9e20ada..43870d078 100644 --- a/tests/config/test_snapshot.py +++ b/tests/config/test_snapshot.py @@ -33,6 +33,7 @@ LoadedConfig, MainConfig, Mode, + OptionsChainConfig, Profile, Regime, RegimeConfig, @@ -74,6 +75,7 @@ def _read(name: str) -> dict[str, Any]: _ASSETS = AssetsConfig.model_validate(_read("assets.yaml")) _AGENTS = AgentsConfig.model_validate(_read("agents.yaml")) _CONTINUOUS_MONITOR = ContinuousMonitorConfig.model_validate(_read("continuous_monitor.yaml")) +_OPTIONS_CHAIN = OptionsChainConfig.model_validate(_read("options_chain.yaml")) _PROFILES = load_profiles(CONFIG_DIR) _RAW_REGIMES = load_regimes(CONFIG_DIR) _MODES = load_modes(CONFIG_DIR) @@ -115,6 +117,7 @@ def _fixture_resolved() -> ResolvedConfig: assets=_ASSETS, agents=_AGENTS, continuous_monitor=_CONTINUOUS_MONITOR, + options_chain=_OPTIONS_CHAIN, profiles=dict(_PROFILES), regimes=dict(_REGIMES), modes=dict(_MODES), @@ -274,7 +277,11 @@ def test_pinned_fixture_hash_matches_known_value() -> None: # ContinuousMonitorConfig (the dedicated monitor watchdog's probe cadence; stall # bound = tick x watchdog_cadence_multiplier), shifting the resolved-config # canonical bytes. - expected = "5c2aab97a711674c0e4ddc302910df06ddb2508c1353586710e4fd198bcb5999" + # Pin updated 2026-06-10 (options_chain section, ALP-948): ALP-948 added + # config/options_chain.yaml and OptionsChainConfig (chain-slice filters + + # premium staleness tolerance for the analyst options enablement) to + # LoadedConfig/ResolvedConfig, adding a new canonical-bytes block. + expected = "1021bfccf6da71ac7b5a2353cbff703433f3d2df99437d9f58e7e27eee0abaf2" assert digest == expected, ( f"Snapshot hash drift detected. Got {digest}; expected {expected}. " f"If the inputs intentionally changed, update the pinned value." diff --git a/tests/decision/analyst/test_harness.py b/tests/decision/analyst/test_harness.py index 31982c836..f02898343 100644 --- a/tests/decision/analyst/test_harness.py +++ b/tests/decision/analyst/test_harness.py @@ -1373,3 +1373,57 @@ async def test_stale_anchor_bracket_redrafted_to_coherent_succeeds( assert result.retry_count == 1 assert result.output.recommendations is not None assert float(result.output.recommendations[0].target.price) == 90.0 + + +# --------------------------------------------------------------------------- +# ALP-948 — retrieve_options_chain mounts when a chain reader is supplied +# --------------------------------------------------------------------------- + + +class _NoChainReader: + """Minimal ``OptionsChainReader`` stand-in for wiring tests.""" + + def chain_slice(self, underlying: str) -> None: + return None + + def latest_quote(self, occ: object) -> None: + return None + + def options_context(self, underlying: str) -> None: + return None + + +@pytest.mark.asyncio +async def test_options_chain_reader_mounts_third_mcp_server( + agent_config: BaseAgentConfig, + archive_root: Path, + initial_validation_state: ValidationToolState, + retrieval_store: RetrievalStore, + active_sectors: frozenset[str], +) -> None: + """Supplying an OptionsChainReader mounts ``alphamind_options_chain`` and + adds ``retrieve_options_chain`` to allowed_tools (ALP-948).""" + captured_options: list[Any] = [] + + async def _capturing_stub(**kwargs: Any) -> AsyncIterator[Any]: + captured_options.append(kwargs.get("options")) + async for msg in _async_iter(_make_sdk_response(_MINIMAL_PAYLOAD)): + yield msg + + await invoke_analyst( + agent_config=agent_config, + user_message="Produce analyst output.", + invocation_id="inv-chain-001", + initial_validation_state=initial_validation_state, + retrieval_store=retrieval_store, + active_sectors=active_sectors, + archive_root=archive_root, + as_of=_AS_OF, + sdk_query_fn=_capturing_stub, + options_chain_reader=_NoChainReader(), + ) + + options = captured_options[0] + assert "alphamind_options_chain" in options.mcp_servers + assert options.mcp_servers["alphamind_options_chain"]["type"] == "sdk" + assert "mcp__alphamind_options_chain__retrieve_options_chain" in options.allowed_tools diff --git a/tests/decision/analyst/test_input_bundle.py b/tests/decision/analyst/test_input_bundle.py index d6013a5cf..2c0025b09 100644 --- a/tests/decision/analyst/test_input_bundle.py +++ b/tests/decision/analyst/test_input_bundle.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, date, datetime from alphamind._kernel.ids import PositionId, Symbol from alphamind._kernel.money import money @@ -32,6 +32,7 @@ from alphamind.portfolio_state.records.positions import Direction, InstrumentType from alphamind.risk_guardrails.breach_behavior import HaltState from alphamind.risk_guardrails.state_delivery.config import StateDeliveryConfig +from alphamind.state.repository.options_chain_read import TickerOptionsContext # --------------------------------------------------------------------------- # Constants — canonical analyst tool names (story 04 + synthesizer story 06a) @@ -551,3 +552,46 @@ def test_halt_mode_deterministic() -> None: sector_label_display=_MICRO_SECTOR_LABELS, ) assert out_a == out_b + + +# --------------------------------------------------------------------------- +# Per-ticker options context (ALP-948) — IVr + liquid expirations decoration +# --------------------------------------------------------------------------- + + +def test_normal_mode_options_context_decorates_reference_price_line() -> None: + kwargs = _normal_kwargs() + kwargs["options_context"] = { + "NVDA": TickerOptionsContext( + iv_rank=62.4, + liquid_expirations=(date(2026, 7, 2), date(2026, 7, 17)), + ), + } + out = assemble_input_bundle_normal( + **kwargs, # type: ignore[arg-type] + sector_label_display=_MICRO_SECTOR_LABELS, + ) + assert " NVDA: 905.12 | IVr 62 | exp: 07-02, 07-17" in out + # Tickers without context keep the bare price line. + assert " AVGO: 446.80\n" in out + + +def test_normal_mode_options_context_uncalibrated_iv_rank_renders_na() -> None: + kwargs = _normal_kwargs() + kwargs["options_context"] = { + "GS": TickerOptionsContext(iv_rank=None, liquid_expirations=(date(2026, 6, 19),)), + } + out = assemble_input_bundle_normal( + **kwargs, # type: ignore[arg-type] + sector_label_display=_MICRO_SECTOR_LABELS, + ) + assert " GS: 1024.00 | IVr n/a | exp: 06-19" in out + + +def test_normal_mode_without_options_context_keeps_bare_price_lines() -> None: + out = assemble_input_bundle_normal( + **_normal_kwargs(), # type: ignore[arg-type] + sector_label_display=_MICRO_SECTOR_LABELS, + ) + assert " NVDA: 905.12\n" in out + assert "IVr" not in out diff --git a/tests/decision/analyst/test_validation.py b/tests/decision/analyst/test_validation.py index 3dd2af778..a213b1469 100644 --- a/tests/decision/analyst/test_validation.py +++ b/tests/decision/analyst/test_validation.py @@ -50,6 +50,10 @@ validate_analyst_output, ) from alphamind.risk_guardrails.guardrail_evaluation import RuleProjection, Status +from alphamind.state.repository.options_chain_read import ( + ContractQuote, + occ_symbol_for_contract, +) # --------------------------------------------------------------------------- # Fixture builders @@ -1584,3 +1588,201 @@ def test_validation_result_is_frozen_dataclass(self) -> None: assert result.is_valid is True with pytest.raises(dataclasses.FrozenInstanceError): result.errors = (ValidationError(field_path="x", rule="r", message="m"),) # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# Option premium / contract / expiration closure (ALP-948) +# --------------------------------------------------------------------------- + + +class _FakeChainReader: + """In-memory ``OptionsChainReader`` keyed by compressed OCC symbol.""" + + def __init__(self, quotes: dict[str, ContractQuote] | None = None) -> None: + self._quotes = quotes or {} + + def chain_slice(self, underlying: str) -> None: + return None + + def latest_quote(self, occ: str) -> ContractQuote | None: + return self._quotes.get(str(occ)) + + def options_context(self, underlying: str) -> None: + return None + + +def _option_quote(*, bid: float = 24.10, ask: float = 24.90) -> ContractQuote: + return ContractQuote( + occ_symbol=occ_symbol_for_contract( + underlying="NVDA", + expiration=date(2026, 7, 17), + strike=850.0, + contract_type="call", + ), + underlying="NVDA", + expiration=date(2026, 7, 17), + strike=850.0, + contract_type="call", + bid=bid, + ask=ask, + implied_volatility=0.41, + delta=0.52, + open_interest=1834, + snapshot_ts=_ts("2026-04-23T14:20:00Z"), + ) + + +def _option_recommendation(**overrides: Any) -> Recommendation: + defaults: dict[str, Any] = { + "instrument": InstrumentOption( + asset_type="option", + underlying=Symbol("NVDA"), + strike=price(850.0), + expiration=date(2026, 7, 17), + contract_type="call", + direction="long", + ), + "entry_order": EntryOrder(type="limit", limit_price=price(24.50)), + "position_size": PositionSize( + quantity=2, + dollar_value=money(4900.0), + pct_of_portfolio=4.9, + premium_at_risk=money(4900.0), + ), + "target": Target( + target_type="pl_percentage", + price=price(39.20), + dollar_pl_target=money(2940.0), + pl_percentage=60.0, + ), + } + return _make_recommendation(**(defaults | overrides)) + + +def _reader_with_quote(**quote_overrides: Any) -> _FakeChainReader: + quote = _option_quote(**quote_overrides) + return _FakeChainReader({str(quote.occ_symbol): quote}) + + +class TestOptionChecks: + def test_anchored_option_recommendation_passes(self) -> None: + output = _make_output(recommendations=(_option_recommendation(),)) + result = validate_analyst_output( + output, + retrieval_store=_store_with_baseline_refs(), + active_sectors=_DEFAULT_ACTIVE_SECTORS, + options_chain_reader=_reader_with_quote(), + ) + assert result.is_valid, result.errors + + def test_unknown_contract_rejected(self) -> None: + output = _make_output(recommendations=(_option_recommendation(),)) + result = validate_analyst_output( + output, + retrieval_store=_store_with_baseline_refs(), + active_sectors=_DEFAULT_ACTIVE_SECTORS, + options_chain_reader=_FakeChainReader(), # no quotes resolve + ) + assert not result.is_valid + assert [e.rule for e in result.errors] == ["option_contract_unknown"] + assert result.errors[0].field_path == "recommendations[0].instrument" + + def test_out_of_tolerance_premium_rejected(self) -> None: + # NBBO mid is 24.50; a 30.00 limit is ~22.4% off — beyond the 10% default. + rec = _option_recommendation(entry_order=EntryOrder(type="limit", limit_price=price(30.0))) + output = _make_output(recommendations=(rec,)) + result = validate_analyst_output( + output, + retrieval_store=_store_with_baseline_refs(), + active_sectors=_DEFAULT_ACTIVE_SECTORS, + options_chain_reader=_reader_with_quote(), + ) + assert not result.is_valid + assert [e.rule for e in result.errors] == ["option_premium_staleness"] + assert result.errors[0].field_path == "recommendations[0].entry_order.limit_price" + + def test_premium_tolerance_is_caller_configurable(self) -> None: + # 24.50 mid vs 25.50 limit is ~4.1% drift: inside the 10% default, + # outside a 2% configured tolerance. + rec = _option_recommendation(entry_order=EntryOrder(type="limit", limit_price=price(25.50))) + output = _make_output(recommendations=(rec,)) + lenient = validate_analyst_output( + output, + retrieval_store=_store_with_baseline_refs(), + active_sectors=_DEFAULT_ACTIVE_SECTORS, + options_chain_reader=_reader_with_quote(), + ) + strict = validate_analyst_output( + output, + retrieval_store=_store_with_baseline_refs(), + active_sectors=_DEFAULT_ACTIVE_SECTORS, + options_chain_reader=_reader_with_quote(), + premium_staleness_tolerance_pct=2.0, + ) + assert lenient.is_valid + assert [e.rule for e in strict.errors] == ["option_premium_staleness"] + + def test_expiration_before_entry_window_deadline_rejected(self) -> None: + rec = _option_recommendation( + instrument=InstrumentOption( + asset_type="option", + underlying=Symbol("NVDA"), + strike=price(850.0), + expiration=date(2026, 5, 1), + contract_type="call", + direction="long", + ), + entry_window=EntryWindow( + deadline=_ts("2026-05-05T20:00:00Z"), + decay_type="gradual", + rationale="Edge erodes into the catalyst.", + ), + entry_window_rationale="Gradual decay into the catalyst window.", + ) + output = _make_output(recommendations=(rec,)) + result = validate_analyst_output( + output, + retrieval_store=_store_with_baseline_refs(), + active_sectors=_DEFAULT_ACTIVE_SECTORS, + ) + assert not result.is_valid + assert [e.rule for e in result.errors] == ["option_expiration_before_deadline"] + assert result.errors[0].field_path == "recommendations[0].instrument.expiration" + + def test_expiration_before_output_timestamp_rejected_without_entry_window(self) -> None: + rec = _option_recommendation( + instrument=InstrumentOption( + asset_type="option", + underlying=Symbol("NVDA"), + strike=price(850.0), + expiration=date(2026, 4, 17), # before output.timestamp 2026-04-23 + contract_type="call", + direction="long", + ), + ) + output = _make_output(recommendations=(rec,)) + result = validate_analyst_output( + output, + retrieval_store=_store_with_baseline_refs(), + active_sectors=_DEFAULT_ACTIVE_SECTORS, + ) + assert [e.rule for e in result.errors] == ["option_expiration_before_deadline"] + + def test_quote_checks_skipped_without_a_reader(self) -> None: + output = _make_output(recommendations=(_option_recommendation(),)) + result = validate_analyst_output( + output, + retrieval_store=_store_with_baseline_refs(), + active_sectors=_DEFAULT_ACTIVE_SECTORS, + ) + assert result.is_valid, result.errors + + def test_equity_recommendation_never_trips_option_rules(self) -> None: + output = _make_output() # baseline equity recommendation + result = validate_analyst_output( + output, + retrieval_store=_store_with_baseline_refs(), + active_sectors=_DEFAULT_ACTIVE_SECTORS, + options_chain_reader=_FakeChainReader(), + ) + assert result.is_valid, result.errors diff --git a/tests/decision/strategist/test_harness.py b/tests/decision/strategist/test_harness.py index ad8a7281c..eb99e64b3 100644 --- a/tests/decision/strategist/test_harness.py +++ b/tests/decision/strategist/test_harness.py @@ -1099,3 +1099,57 @@ async def test_tokens_used_accumulates_across_retry( assert result.metadata["attempts"] == 2 assert result.tokens_used.input_tokens == 180 assert result.tokens_used.output_tokens == 170 + + +# --------------------------------------------------------------------------- +# ALP-948 — retrieve_options_chain mounts when a chain reader is supplied +# --------------------------------------------------------------------------- + + +class _NoChainReader: + """Minimal ``OptionsChainReader`` stand-in for wiring tests.""" + + def chain_slice(self, underlying: str) -> None: + return None + + def latest_quote(self, occ: object) -> None: + return None + + def options_context(self, underlying: str) -> None: + return None + + +@pytest.mark.asyncio +async def test_options_chain_reader_mounts_third_mcp_server( + agent_config: BaseAgentConfig, + archive_root: Path, + validation_state: ValidationToolState, + retrieval_store: RetrievalStore, + active_sectors: frozenset[str], +) -> None: + """Supplying an OptionsChainReader mounts ``alphamind_options_chain`` and + adds ``retrieve_options_chain`` to allowed_tools (ALP-948).""" + captured_options: list[Any] = [] + + async def _capturing_stub(**kwargs: Any) -> AsyncIterator[Any]: + captured_options.append(kwargs.get("options")) + async for msg in _async_iter(_make_sdk_response(_MINIMAL_PAYLOAD)): + yield msg + + await run_strategist_harness( + user_message="Produce strategist output.", + system_prompt="You are the strategist.", + invocation_id="inv-chain-001", + agent_config=agent_config, + validation_state=validation_state, + retrieval_store=retrieval_store, + active_sectors=active_sectors, + archive_root=archive_root, + as_of=_AS_OF, + sdk_query_fn=_capturing_stub, + options_chain_reader=_NoChainReader(), + ) + + options = captured_options[0] + assert "alphamind_options_chain" in options.mcp_servers + assert "mcp__alphamind_options_chain__retrieve_options_chain" in options.allowed_tools diff --git a/tests/risk_guardrails/guardrail_evaluation/test_e2e_golden.py b/tests/risk_guardrails/guardrail_evaluation/test_e2e_golden.py index a90af2fee..c07295531 100644 --- a/tests/risk_guardrails/guardrail_evaluation/test_e2e_golden.py +++ b/tests/risk_guardrails/guardrail_evaluation/test_e2e_golden.py @@ -43,6 +43,7 @@ LoadedConfig, MainConfig, Mode, + OptionsChainConfig, Overlay, Profile, Regime, @@ -101,6 +102,7 @@ def _read(name: str) -> dict[str, Any]: _ASSETS = AssetsConfig.model_validate(_read("assets.yaml")) _AGENTS = AgentsConfig.model_validate(_read("agents.yaml")) _CONTINUOUS_MONITOR = ContinuousMonitorConfig.model_validate(_read("continuous_monitor.yaml")) +_OPTIONS_CHAIN = OptionsChainConfig.model_validate(_read("options_chain.yaml")) _PROFILES = load_profiles(_CONFIG_DIR) _REGIMES = load_regimes(_CONFIG_DIR) _MODES = load_modes(_CONFIG_DIR) @@ -143,6 +145,7 @@ def _resolve_config( assets=_ASSETS, agents=_AGENTS, continuous_monitor=_CONTINUOUS_MONITOR, + options_chain=_OPTIONS_CHAIN, profiles=dict(_PROFILES), regimes=dict(_REGIMES), modes=dict(_MODES), diff --git a/tests/risk_guardrails/guardrail_evaluation/test_effective_limits_and_feature_gate.py b/tests/risk_guardrails/guardrail_evaluation/test_effective_limits_and_feature_gate.py index 797e553fd..eb3887ed3 100644 --- a/tests/risk_guardrails/guardrail_evaluation/test_effective_limits_and_feature_gate.py +++ b/tests/risk_guardrails/guardrail_evaluation/test_effective_limits_and_feature_gate.py @@ -45,6 +45,7 @@ LoadedConfig, MainConfig, Mode, + OptionsChainConfig, Overlay, Profile, Regime, @@ -90,6 +91,7 @@ def _read(name: str) -> dict[str, Any]: _ASSETS = AssetsConfig.model_validate(_read("assets.yaml")) _AGENTS = AgentsConfig.model_validate(_read("agents.yaml")) _CONTINUOUS_MONITOR = ContinuousMonitorConfig.model_validate(_read("continuous_monitor.yaml")) +_OPTIONS_CHAIN = OptionsChainConfig.model_validate(_read("options_chain.yaml")) _PROFILES = load_profiles(CONFIG_DIR) _REGIMES = load_regimes(CONFIG_DIR) _MODES = load_modes(CONFIG_DIR) @@ -124,6 +126,7 @@ def _compose( assets=_ASSETS, agents=_AGENTS, continuous_monitor=_CONTINUOUS_MONITOR, + options_chain=_OPTIONS_CHAIN, profiles=dict(_PROFILES), regimes=dict(_REGIMES), modes=dict(_MODES), diff --git a/tests/risk_guardrails/regime_adaptation/conftest.py b/tests/risk_guardrails/regime_adaptation/conftest.py index 58667bef0..3868939ae 100644 --- a/tests/risk_guardrails/regime_adaptation/conftest.py +++ b/tests/risk_guardrails/regime_adaptation/conftest.py @@ -66,6 +66,7 @@ from alphamind.config.models.guardrails import GuardrailsConfig from alphamind.config.models.llm_failure import LLMFailureConfig from alphamind.config.models.main import MainConfig +from alphamind.config.models.options_chain import OptionsChainConfig from alphamind.config.models.scheduler import SchedulerConfig from alphamind.config.models.venue import VenueConfig from alphamind.config.resolver import LoadedConfig @@ -127,6 +128,7 @@ def loaded_config_micro_normal() -> LoadedConfig: continuous_monitor=ContinuousMonitorConfig.model_validate( _read_yaml("continuous_monitor.yaml") ), + options_chain=OptionsChainConfig.model_validate(_read_yaml("options_chain.yaml")), profiles=load_profiles(CONFIG_DIR), regimes=load_regimes(CONFIG_DIR), modes=load_modes(CONFIG_DIR), diff --git a/tests/risk_guardrails/regime_adaptation/test_orchestrator.py b/tests/risk_guardrails/regime_adaptation/test_orchestrator.py index 07519e1b8..e1c311734 100644 --- a/tests/risk_guardrails/regime_adaptation/test_orchestrator.py +++ b/tests/risk_guardrails/regime_adaptation/test_orchestrator.py @@ -51,6 +51,7 @@ from alphamind.config.models.guardrails import GuardrailsConfig from alphamind.config.models.llm_failure import LLMFailureConfig from alphamind.config.models.main import MainConfig +from alphamind.config.models.options_chain import OptionsChainConfig from alphamind.config.models.overlays import ( EventType, FinalInvocationBeforeEvent, @@ -169,6 +170,7 @@ def loaded_config_session_scoped() -> LoadedConfig: continuous_monitor=ContinuousMonitorConfig.model_validate( _read_yaml("continuous_monitor.yaml") ), + options_chain=OptionsChainConfig.model_validate(_read_yaml("options_chain.yaml")), profiles=load_profiles(CONFIG_DIR), regimes=load_regimes(CONFIG_DIR), modes=load_modes(CONFIG_DIR), diff --git a/tests/risk_guardrails/rules_and_limits/test_registry.py b/tests/risk_guardrails/rules_and_limits/test_registry.py index b3ca67eb0..2ff4e5cf8 100644 --- a/tests/risk_guardrails/rules_and_limits/test_registry.py +++ b/tests/risk_guardrails/rules_and_limits/test_registry.py @@ -35,6 +35,7 @@ LoadedConfig, MainConfig, Mode, + OptionsChainConfig, Profile, Regime, ResolvedConfig, @@ -88,6 +89,7 @@ def _resolved_for_profile(profile: Profile) -> ResolvedConfig: continuous_monitor=ContinuousMonitorConfig.model_validate( _read_yaml("continuous_monitor.yaml") ), + options_chain=OptionsChainConfig.model_validate(_read_yaml("options_chain.yaml")), profiles=dict(load_profiles(CONFIG_DIR)), regimes=dict(load_regimes(CONFIG_DIR)), modes=dict(load_modes(CONFIG_DIR)), diff --git a/tests/state/test_options_chain_read.py b/tests/state/test_options_chain_read.py new file mode 100644 index 000000000..466dcd7a2 --- /dev/null +++ b/tests/state/test_options_chain_read.py @@ -0,0 +1,442 @@ +"""Tests for the options chain-slice read + selection core (ALP-948). + +``select_chain_slice`` is exercised as a pure function on plain +:class:`ContractQuote` tuples — no DB session. :class:`SqlOptionsChainReader` +is exercised against a seeded on-disk SQLite database, mirroring the +``test_sql_option_price_provider`` conventions (on-disk because the reader's +sync sessionmaker is a distinct ``Engine`` from the seeding engine). +""" + +from __future__ import annotations + +from datetime import UTC, date, datetime, timedelta +from pathlib import Path + +import pytest +from sqlalchemy import select +from sqlalchemy.orm import Session, sessionmaker + +# Prime ``state.invocation_context`` before ``state.repository.__init__`` +# (see test_sql_option_price_provider.py for the partial-import rationale). +import alphamind.state.invocation_context # noqa: F401 +from alphamind._kernel.ids import OccSymbol +from alphamind.persistence.models import ( + AssetUniverse, + Base, + DistillationTickerBaseline, + OptionsContracts, + OptionsContractSnapshots, +) +from alphamind.persistence.session import make_engine, make_session_factory +from alphamind.state.repository.options_chain_read import ( + ChainFilterParams, + ContractQuote, + SqlOptionsChainReader, + build_options_context, + occ_symbol_for_contract, + select_chain_slice, +) + +_NOW = datetime(2026, 6, 10, 14, 30, 0, tzinfo=UTC) + +_PARAMS = ChainFilterParams( + strike_band_pct=15.0, + min_days_to_expiration=5, + max_days_to_expiration=45, + min_open_interest=100, + max_contracts_rendered=24, +) + + +def _quote( + *, + strike: float = 100.0, + expiration: date = date(2026, 7, 2), # 22 DTE from _NOW + contract_type: str = "call", + open_interest: int = 500, + bid: float = 4.90, + ask: float = 5.10, + underlying: str = "AAPL", +) -> ContractQuote: + return ContractQuote( + occ_symbol=occ_symbol_for_contract( + underlying=underlying, + expiration=expiration, + strike=strike, + contract_type=contract_type, # type: ignore[arg-type] + ), + underlying=underlying, + expiration=expiration, + strike=strike, + contract_type=contract_type, # type: ignore[arg-type] + bid=bid, + ask=ask, + implied_volatility=0.32, + delta=0.51, + open_interest=open_interest, + snapshot_ts=_NOW - timedelta(minutes=10), + ) + + +# --------------------------------------------------------------------------- +# select_chain_slice — pure selection policy +# --------------------------------------------------------------------------- + + +def test_select_chain_slice_drops_strikes_outside_band_and_names_them() -> None: + inside = _quote(strike=110.0) + outside = _quote(strike=120.0) # ±15% of spot 100 → band is 85..115 + + chain = select_chain_slice( + (inside, outside), underlying="AAPL", spot=100.0, as_of=_NOW, params=_PARAMS + ) + + assert chain.contracts == (inside,) + assert chain.omitted is not None + assert "1 outside the ±15% strike band" in chain.omitted + + +def test_select_chain_slice_drops_expirations_outside_dte_window() -> None: + inside = _quote(expiration=date(2026, 7, 2)) + too_near = _quote(expiration=date(2026, 6, 12), strike=101.0) # 2 DTE < 5 + too_far = _quote(expiration=date(2026, 9, 18), strike=102.0) # 100 DTE > 45 + + chain = select_chain_slice( + (inside, too_near, too_far), underlying="AAPL", spot=100.0, as_of=_NOW, params=_PARAMS + ) + + assert chain.contracts == (inside,) + assert chain.omitted is not None + assert "2 outside the 5-45 DTE window" in chain.omitted + + +def test_select_chain_slice_drops_contracts_below_open_interest_floor() -> None: + liquid = _quote(open_interest=100) + illiquid = _quote(open_interest=99, strike=101.0) + + chain = select_chain_slice( + (liquid, illiquid), underlying="AAPL", spot=100.0, as_of=_NOW, params=_PARAMS + ) + + assert chain.contracts == (liquid,) + assert chain.omitted is not None + assert "1 below the open-interest floor 100" in chain.omitted + + +def test_select_chain_slice_caps_to_nearest_the_money_and_names_the_drop() -> None: + params = ChainFilterParams( + strike_band_pct=15.0, + min_days_to_expiration=5, + max_days_to_expiration=45, + min_open_interest=100, + max_contracts_rendered=2, + ) + near = _quote(strike=100.0) + nearer = _quote(strike=101.0) + far = _quote(strike=110.0) + + chain = select_chain_slice( + (far, near, nearer), underlying="AAPL", spot=100.0, as_of=_NOW, params=params + ) + + assert {quote.strike for quote in chain.contracts} == {100.0, 101.0} + assert chain.omitted is not None + assert "1 furthest-from-money beyond the 2-contract cap" in chain.omitted + + +def test_select_chain_slice_omitted_is_none_when_nothing_dropped() -> None: + rows = (_quote(strike=100.0), _quote(strike=105.0)) + + chain = select_chain_slice(rows, underlying="AAPL", spot=100.0, as_of=_NOW, params=_PARAMS) + + assert chain.omitted is None + assert len(chain.contracts) == 2 + + +def test_select_chain_slice_orders_by_expiration_then_strike() -> None: + later = _quote(expiration=date(2026, 7, 10), strike=95.0) + earlier_high = _quote(expiration=date(2026, 7, 2), strike=105.0) + earlier_low = _quote(expiration=date(2026, 7, 2), strike=95.0) + + chain = select_chain_slice( + (later, earlier_high, earlier_low), + underlying="AAPL", + spot=100.0, + as_of=_NOW, + params=_PARAMS, + ) + + assert [(q.expiration, q.strike) for q in chain.contracts] == [ + (date(2026, 7, 2), 95.0), + (date(2026, 7, 2), 105.0), + (date(2026, 7, 10), 95.0), + ] + + +# --------------------------------------------------------------------------- +# occ_symbol_for_contract +# --------------------------------------------------------------------------- + + +def test_occ_symbol_for_contract_matches_collector_key_format() -> None: + occ = occ_symbol_for_contract( + underlying="NVDA", + expiration=date(2026, 6, 19), + strike=800.0, + contract_type="call", + ) + assert occ == "NVDA260619C00800000" + + +def test_occ_symbol_for_contract_drops_share_class_dot_and_rounds_strike() -> None: + occ = occ_symbol_for_contract( + underlying="BRK.B", + expiration=date(2026, 6, 19), + strike=12.50, + contract_type="put", + ) + assert occ == "BRKB260619P00012500" + + +# --------------------------------------------------------------------------- +# SqlOptionsChainReader — seeded SQLite +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def sync_factory(tmp_path: Path) -> sessionmaker[Session]: + db_path = tmp_path / "alphamind.db" + engine = make_engine(str(db_path)) + Base.metadata.create_all(engine) + return make_session_factory(engine) + + +def _seed_underlying(session: Session, underlying: str) -> None: + session.add( + AssetUniverse( + asset_id=f"asset-{underlying.lower()}", + ticker=underlying, + full_name=f"{underlying} Holdings", + asset_class="equity", + asset_role="universe", + exchange="NASDAQ", + is_active=1, + added_date="2020-01-01", + last_updated="2026-04-01T00:00:00Z", + ) + ) + session.flush() + + +def _iso(ts: datetime) -> str: + return ts.isoformat().replace("+00:00", "Z") + + +def _seed_contract( + session: Session, + *, + underlying: str, + strike: float, + expiration: str, + contract_type: str = "call", +) -> str: + contract_ticker = "O:" + occ_symbol_for_contract( + underlying=underlying, + expiration=date.fromisoformat(expiration), + strike=strike, + contract_type=contract_type, # type: ignore[arg-type] + ) + session.add( + OptionsContracts( + contract_ticker=contract_ticker, + underlying_ticker=underlying, + expiration_date=expiration, + strike_price=strike, + contract_type=contract_type, + first_seen_at="2026-04-01T00:00:00Z", + last_seen_at=_iso(_NOW), + source="test", + ) + ) + session.flush() + return contract_ticker + + +def _seed_snapshot( + session: Session, + *, + contract_ticker: str, + underlying: str, + snapshot_ts: datetime, + bid: float | None = 4.90, + ask: float | None = 5.10, + implied_volatility: float | None = 0.32, + delta: float | None = 0.51, + open_interest: int | None = 500, + underlying_price: float | None = 100.0, +) -> None: + session.add( + OptionsContractSnapshots( + snapshot_ts=_iso(snapshot_ts), + contract_ticker=contract_ticker, + underlying_ticker=underlying, + open_interest=open_interest, + volume_today=50, + last_price=5.00, + bid=bid, + ask=ask, + implied_volatility=implied_volatility, + delta=delta, + gamma=0.05, + theta=-0.03, + vega=0.10, + rho=0.02, + underlying_price=underlying_price, + source="test", + ingested_at=_iso(snapshot_ts), + ) + ) + + +def _reader(sync_factory: sessionmaker[Session]) -> SqlOptionsChainReader: + return SqlOptionsChainReader( + sync_session_factory=sync_factory, + params=_PARAMS, + now=lambda: _NOW, + ) + + +def test_chain_slice_returns_latest_snapshot_per_contract( + sync_factory: sessionmaker[Session], +) -> None: + with sync_factory() as session, session.begin(): + _seed_underlying(session, "AAPL") + ct = _seed_contract(session, underlying="AAPL", strike=100.0, expiration="2026-07-02") + _seed_snapshot( + session, + contract_ticker=ct, + underlying="AAPL", + snapshot_ts=_NOW - timedelta(hours=2), + bid=3.00, + ask=3.20, + underlying_price=98.0, + ) + _seed_snapshot( + session, + contract_ticker=ct, + underlying="AAPL", + snapshot_ts=_NOW - timedelta(minutes=10), + bid=4.90, + ask=5.10, + underlying_price=100.0, + ) + + chain = _reader(sync_factory).chain_slice("AAPL") + + assert chain is not None + assert len(chain.contracts) == 1 + quote = chain.contracts[0] + assert quote.bid == pytest.approx(4.90) + assert quote.ask == pytest.approx(5.10) + assert quote.snapshot_ts == _NOW - timedelta(minutes=10) + # Spot rides the freshest snapshot's underlying_price. + assert chain.spot == pytest.approx(100.0) + + +def test_chain_slice_returns_none_for_underlying_without_data( + sync_factory: sessionmaker[Session], +) -> None: + assert _reader(sync_factory).chain_slice("TSLA") is None + + +def test_chain_slice_notes_contracts_without_usable_quotes( + sync_factory: sessionmaker[Session], +) -> None: + with sync_factory() as session, session.begin(): + _seed_underlying(session, "AAPL") + quoted = _seed_contract(session, underlying="AAPL", strike=100.0, expiration="2026-07-02") + unquoted = _seed_contract(session, underlying="AAPL", strike=105.0, expiration="2026-07-02") + _seed_snapshot(session, contract_ticker=quoted, underlying="AAPL", snapshot_ts=_NOW) + _seed_snapshot( + session, + contract_ticker=unquoted, + underlying="AAPL", + snapshot_ts=_NOW, + bid=None, + ask=None, + ) + + chain = _reader(sync_factory).chain_slice("AAPL") + + assert chain is not None + assert len(chain.contracts) == 1 + assert chain.omitted is not None + assert "1 contract(s) without a usable two-sided quote" in chain.omitted + + +def test_latest_quote_resolves_compressed_occ_to_polygon_key( + sync_factory: sessionmaker[Session], +) -> None: + with sync_factory() as session, session.begin(): + _seed_underlying(session, "AAPL") + ct = _seed_contract(session, underlying="AAPL", strike=100.0, expiration="2026-07-02") + _seed_snapshot(session, contract_ticker=ct, underlying="AAPL", snapshot_ts=_NOW) + + occ = occ_symbol_for_contract( + underlying="AAPL", expiration=date(2026, 7, 2), strike=100.0, contract_type="call" + ) + quote = _reader(sync_factory).latest_quote(occ) + + assert quote is not None + assert quote.strike == pytest.approx(100.0) + assert quote.nbbo_mid == pytest.approx(5.00) + + +def test_latest_quote_returns_none_for_unknown_contract( + sync_factory: sessionmaker[Session], +) -> None: + occ = OccSymbol("AAPL260702C00100000") + assert _reader(sync_factory).latest_quote(occ) is None + + +def test_options_context_carries_surviving_expirations_and_writes_no_baseline( + sync_factory: sessionmaker[Session], +) -> None: + with sync_factory() as session, session.begin(): + _seed_underlying(session, "AAPL") + near = _seed_contract(session, underlying="AAPL", strike=100.0, expiration="2026-07-02") + far = _seed_contract( + session, underlying="AAPL", strike=100.0, expiration="2026-09-18" + ) # 100 DTE — dropped by the expiration window + _seed_snapshot(session, contract_ticker=near, underlying="AAPL", snapshot_ts=_NOW) + _seed_snapshot(session, contract_ticker=far, underlying="AAPL", snapshot_ts=_NOW) + + context = _reader(sync_factory).options_context("AAPL") + + assert context is not None + assert context.liquid_expirations == (date(2026, 7, 2),) + # A bootstrap-thin IV history yields no calibrated rank. + assert context.iv_rank is None + # Read-only reuse: assembling context must not write baseline rows. + with sync_factory() as session: + baseline_rows = session.execute(select(DistillationTickerBaseline)).all() + assert baseline_rows == [] + + +def test_options_context_returns_none_without_chain_data( + sync_factory: sessionmaker[Session], +) -> None: + assert _reader(sync_factory).options_context("TSLA") is None + + +def test_build_options_context_includes_only_tickers_with_chains( + sync_factory: sessionmaker[Session], +) -> None: + with sync_factory() as session, session.begin(): + _seed_underlying(session, "AAPL") + ct = _seed_contract(session, underlying="AAPL", strike=100.0, expiration="2026-07-02") + _seed_snapshot(session, contract_ticker=ct, underlying="AAPL", snapshot_ts=_NOW) + + context = build_options_context(_reader(sync_factory), tickers=("AAPL", "TSLA")) + + assert set(context) == {"AAPL"} diff --git a/tests/state/test_options_chain_tool_mcp.py b/tests/state/test_options_chain_tool_mcp.py new file mode 100644 index 000000000..f56a924ca --- /dev/null +++ b/tests/state/test_options_chain_tool_mcp.py @@ -0,0 +1,141 @@ +"""Tests for the ``retrieve_options_chain`` MCP tool (ALP-948). + +Exercises the per-invocation SDK MCP server against an in-memory fake +:class:`OptionsChainReader` — the SQL shell has its own seeded-SQLite tests +in ``test_options_chain_read.py``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, date, datetime, timedelta +from typing import Any + +import alphamind.state.invocation_context # noqa: F401 — import-order priming (see test_sql_option_price_provider) +from alphamind._kernel.ids import OccSymbol +from alphamind.state.repository.options_chain_read import ( + ChainSlice, + ContractQuote, + TickerOptionsContext, + occ_symbol_for_contract, +) +from alphamind.state.repository.options_chain_tool_mcp import ( + RETRIEVE_OPTIONS_CHAIN_TOOL_NAME, + build_retrieve_options_chain_mcp_server, +) + +_NOW = datetime(2026, 6, 10, 14, 30, 0, tzinfo=UTC) + + +@dataclass +class _FakeReader: + """In-memory ``OptionsChainReader`` substitute.""" + + slices: dict[str, ChainSlice] = field(default_factory=dict) + + def chain_slice(self, underlying: str) -> ChainSlice | None: + return self.slices.get(underlying) + + def latest_quote(self, occ: OccSymbol) -> ContractQuote | None: + for chain in self.slices.values(): + for quote in chain.contracts: + if quote.occ_symbol == occ: + return quote + return None + + def options_context(self, underlying: str) -> TickerOptionsContext | None: + return None + + +def _nvda_quote() -> ContractQuote: + return ContractQuote( + occ_symbol=occ_symbol_for_contract( + underlying="NVDA", + expiration=date(2026, 7, 17), + strike=850.0, + contract_type="call", + ), + underlying="NVDA", + expiration=date(2026, 7, 17), + strike=850.0, + contract_type="call", + bid=24.10, + ask=24.90, + implied_volatility=0.41, + delta=0.52, + open_interest=1834, + snapshot_ts=_NOW - timedelta(minutes=12), + ) + + +def _nvda_slice(*, omitted: str | None = None) -> ChainSlice: + return ChainSlice( + underlying="NVDA", + spot=842.50, + as_of=_NOW, + contracts=(_nvda_quote(),), + omitted=omitted, + ) + + +async def _call_tool(reader: _FakeReader, underlying: str) -> str: + mcp_servers, allowed = build_retrieve_options_chain_mcp_server(reader) + assert allowed == [RETRIEVE_OPTIONS_CHAIN_TOOL_NAME] + server: Any = mcp_servers["alphamind_options_chain"]["instance"] + + from mcp.types import CallToolRequest, CallToolRequestParams + + request = CallToolRequest( + method="tools/call", + params=CallToolRequestParams( + name="retrieve_options_chain", arguments={"underlying": underlying} + ), + ) + handler = server.request_handlers[CallToolRequest] + result = await handler(request) + assert not result.root.isError + text = result.root.content[0].text + assert isinstance(text, str) + return text + + +async def test_tool_renders_header_contract_lines_and_omitted_note() -> None: + reader = _FakeReader(slices={"NVDA": _nvda_slice(omitted="omitted: 3 below the floor")}) + + text = await _call_tool(reader, "NVDA") + + lines = text.splitlines() + assert lines[0] == f"NVDA options chain — spot 842.50, as of {_NOW.isoformat()}" + contract_line = lines[1] + assert "NVDA260717C00850000" in contract_line + assert "2026-07-17 850C" in contract_line + assert "bid/ask 24.10/24.90" in contract_line + assert "IV 0.41" in contract_line + assert "delta +0.52" in contract_line + assert "OI 1834" in contract_line + assert "age 12m" in contract_line + assert lines[-1] == "omitted: 3 below the floor" + + +async def test_tool_uppercases_the_requested_underlying() -> None: + reader = _FakeReader(slices={"NVDA": _nvda_slice()}) + + text = await _call_tool(reader, " nvda ") + + assert text.startswith("NVDA options chain") + + +async def test_tool_returns_explicit_no_chain_data_message_not_an_error() -> None: + text = await _call_tool(_FakeReader(), "TSLA") + + assert "No usable options chain data for TSLA" in text + + +async def test_input_schema_has_exactly_one_required_string_property() -> None: + from alphamind.state.repository.options_chain_tool_mcp import ( + _RETRIEVE_OPTIONS_CHAIN_INPUT_SCHEMA, + ) + + assert _RETRIEVE_OPTIONS_CHAIN_INPUT_SCHEMA["required"] == ["underlying"] + assert set(_RETRIEVE_OPTIONS_CHAIN_INPUT_SCHEMA["properties"]) == {"underlying"} + assert _RETRIEVE_OPTIONS_CHAIN_INPUT_SCHEMA["properties"]["underlying"]["type"] == "string" From 6c2dea8c7d5ab56fcbed5be14148a4e584cc8501 Mon Sep 17 00:00:00 2001 From: Jackson Atassi Date: Wed, 10 Jun 2026 21:34:44 -0600 Subject: [PATCH 2/4] fix: address code-review findings on ALP-948 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 --- src/alphamind/decision/_shared/__init__.py | 3 +- .../decision/_shared/tool_surface.py | 26 +++ .../decision/analyst/input_bundle.py | 4 +- src/alphamind/decision/analyst/runner.py | 18 +- src/alphamind/decision/strategist/runner.py | 17 +- src/alphamind/scheduler/orchestrator.py | 47 ++--- .../state/repository/options_chain_read.py | 162 ++++++++++-------- tests/decision/analyst/test_input_bundle.py | 13 ++ tests/state/test_options_chain_read.py | 51 ++++++ 9 files changed, 219 insertions(+), 122 deletions(-) create mode 100644 src/alphamind/decision/_shared/tool_surface.py diff --git a/src/alphamind/decision/_shared/__init__.py b/src/alphamind/decision/_shared/__init__.py index 73ea6d901..c73caf473 100644 --- a/src/alphamind/decision/_shared/__init__.py +++ b/src/alphamind/decision/_shared/__init__.py @@ -10,5 +10,6 @@ from alphamind.decision._shared.direction_display import direction_display from alphamind.decision._shared.prompt_file import system_prompt_as_file +from alphamind.decision._shared.tool_surface import surface_tool_names -__all__ = ["direction_display", "system_prompt_as_file"] +__all__ = ["direction_display", "surface_tool_names", "system_prompt_as_file"] diff --git a/src/alphamind/decision/_shared/tool_surface.py b/src/alphamind/decision/_shared/tool_surface.py new file mode 100644 index 000000000..15b0284b8 --- /dev/null +++ b/src/alphamind/decision/_shared/tool_surface.py @@ -0,0 +1,26 @@ +"""Rendered tool-name selection shared by the analyst and strategist runners. + +The ``retrieve_options_chain`` tool (ALP-948) mounts only when the composition +supplies an :class:`~alphamind.state.repository.options_chain_read.OptionsChainReader`; +the AVAILABLE TOOLS render must match the mounted surface so the prompt never +advertises an uncallable tool. +""" + +from __future__ import annotations + +from alphamind.state.repository.options_chain_read import OptionsChainReader +from alphamind.state.repository.options_chain_tool_mcp import ( + RETRIEVE_OPTIONS_CHAIN_TOOL_NAME, +) + +__all__ = ["surface_tool_names"] + + +def surface_tool_names( + tool_names: tuple[str, ...], + options_chain_reader: OptionsChainReader | None, +) -> tuple[str, ...]: + """*tool_names* narrowed to this composition's mounted surface.""" + if options_chain_reader is not None: + return tool_names + return tuple(name for name in tool_names if name != RETRIEVE_OPTIONS_CHAIN_TOOL_NAME) diff --git a/src/alphamind/decision/analyst/input_bundle.py b/src/alphamind/decision/analyst/input_bundle.py index d7c4f8b0a..46371c775 100644 --- a/src/alphamind/decision/analyst/input_bundle.py +++ b/src/alphamind/decision/analyst/input_bundle.py @@ -179,7 +179,9 @@ def _render_reference_price_line( ) -> str: """One reference-price line, options-decorated when context exists.""" base = f" {ticker}: {price:.2f}" - if context is None: + if context is None or not context.liquid_expirations: + # A context without surviving expirations carries no actionable options + # surface — keep the bare price line rather than a dangling "exp:". return base iv_rank = f"{context.iv_rank:.0f}" if context.iv_rank is not None else "n/a" expirations = ", ".join(d.strftime("%m-%d") for d in context.liquid_expirations) diff --git a/src/alphamind/decision/analyst/runner.py b/src/alphamind/decision/analyst/runner.py index 4abe81818..461cd636c 100644 --- a/src/alphamind/decision/analyst/runner.py +++ b/src/alphamind/decision/analyst/runner.py @@ -30,6 +30,7 @@ AgentsConfig, BaseAgentConfig, ) +from alphamind.decision._shared import surface_tool_names from alphamind.decision.analyst.harness import ( # noqa: F401 — kept for tests that inject the in-process harness HarnessSuccess, invoke_analyst, @@ -83,9 +84,9 @@ # (``build_validate_guardrail_mcp_server``, ``build_retrieve_brief_mcp_server``, # and ``build_retrieve_options_chain_mcp_server``) emit these same names as # their allowed-tools lists. The chain tool (ALP-948) mounts only when the -# composition supplies an ``OptionsChainReader``; ``_tool_names`` drops it -# from the rendered list otherwise so the prompt never advertises an -# uncallable tool. +# composition supplies an ``OptionsChainReader``; ``surface_tool_names`` +# drops it from the rendered list otherwise so the prompt never advertises +# an uncallable tool. ANALYST_TOOL_NAMES: tuple[str, ...] = ( "mcp__alphamind_decision_validation__validate_guardrail", "mcp__alphamind_synthesizer_retrieval__retrieve_brief", @@ -93,13 +94,6 @@ ) -def _tool_names(options_chain_reader: OptionsChainReader | None) -> tuple[str, ...]: - """The AVAILABLE TOOLS render for this composition's mounted surface.""" - if options_chain_reader is not None: - return ANALYST_TOOL_NAMES - return tuple(n for n in ANALYST_TOOL_NAMES if n != RETRIEVE_OPTIONS_CHAIN_TOOL_NAME) - - # --------------------------------------------------------------------------- # Result type # --------------------------------------------------------------------------- @@ -318,7 +312,7 @@ def _assemble_user_message( # noqa: PLR0913 — fan-in of input-bundle assemble active_sectors=active_sectors_tuple, state_delivery_config=state_delivery_config, synthesizer_brief_text=synthesizer_text, - tool_names=_tool_names(options_chain_reader), + tool_names=surface_tool_names(ANALYST_TOOL_NAMES, options_chain_reader), underlying_prices=underlying_prices, options_context=options_context, ) @@ -338,5 +332,5 @@ def _assemble_user_message( # noqa: PLR0913 — fan-in of input-bundle assemble active_sectors=active_sectors_tuple, state_delivery_config=state_delivery_config, synthesizer_brief_text=synthesizer_text, - tool_names=_tool_names(options_chain_reader), + tool_names=surface_tool_names(ANALYST_TOOL_NAMES, options_chain_reader), ) diff --git a/src/alphamind/decision/strategist/runner.py b/src/alphamind/decision/strategist/runner.py index 775e493e5..fb9de330f 100644 --- a/src/alphamind/decision/strategist/runner.py +++ b/src/alphamind/decision/strategist/runner.py @@ -37,6 +37,7 @@ AgentsConfig, BaseAgentConfig, ) +from alphamind.decision._shared import surface_tool_names from alphamind.decision.strategist.harness import ( # noqa: F401 — kept for tests that inject the in-process harness HarnessSuccess, run_strategist_harness, @@ -107,18 +108,6 @@ ) -def _tool_names(options_chain_reader: OptionsChainReader | None) -> tuple[str, ...]: - """The AVAILABLE TOOLS render for this composition's mounted surface. - - The chain tool (ALP-948) mounts only when the composition supplies an - ``OptionsChainReader``; drop it from the rendered list otherwise so the - prompt never advertises an uncallable tool. - """ - if options_chain_reader is not None: - return STRATEGIST_TOOL_NAMES - return tuple(n for n in STRATEGIST_TOOL_NAMES if n != RETRIEVE_OPTIONS_CHAIN_TOOL_NAME) - - # --------------------------------------------------------------------------- # Result type # --------------------------------------------------------------------------- @@ -378,7 +367,7 @@ def _assemble_user_message( # noqa: PLR0913 — fan-in of input-bundle assemble available_for_new_positions_usd=available_for_new_positions_usd, current_price_lookup=current_price_lookup, synthesizer_brief_text=synthesizer_brief_text, - tool_names=_tool_names(options_chain_reader), + tool_names=surface_tool_names(STRATEGIST_TOOL_NAMES, options_chain_reader), position_zones=position_zones, sector_label_display=sector_label_display, regime_transition_breaches=regime_transition_breaches, @@ -402,7 +391,7 @@ def _assemble_user_message( # noqa: PLR0913 — fan-in of input-bundle assemble available_for_new_positions_usd=available_for_new_positions_usd, current_price_lookup=current_price_lookup, synthesizer_brief_text=synthesizer_brief_text, - tool_names=_tool_names(options_chain_reader), + tool_names=surface_tool_names(STRATEGIST_TOOL_NAMES, options_chain_reader), position_zones=position_zones, sector_label_display=sector_label_display, regime_transition_breaches=regime_transition_breaches, diff --git a/src/alphamind/scheduler/orchestrator.py b/src/alphamind/scheduler/orchestrator.py index 9ddb051e6..7c632a61b 100644 --- a/src/alphamind/scheduler/orchestrator.py +++ b/src/alphamind/scheduler/orchestrator.py @@ -188,7 +188,9 @@ ) from alphamind.state.repository.options_chain_read import ( ChainFilterParams, + OptionsChainReader, SqlOptionsChainReader, + TickerOptionsContext, build_options_context, ) from alphamind.state.tables.invocations import InvocationRow @@ -410,8 +412,8 @@ def _build_decision_kwargs( # noqa: PLR0913 — composition surface threads eac venue_config: VenueConfig | None = None, execution_mode: ExecutionMode | None = None, execution_config: ExecutionConfig | None = None, - options_chain_reader: Any = None, - options_context: Any = None, + options_chain_reader: OptionsChainReader | None = None, + options_context: Mapping[str, TickerOptionsContext] | None = None, ) -> dict[str, Any]: """Assemble the kwargs ``run_decision_pipeline`` requires.""" resolved = pipeline_config.resolved @@ -976,27 +978,28 @@ async def _run_fill_collection_write_unit() -> FillCollectionSummary: # already did the work inside ``insert_invocation_record``. broker_routing_active = context.debug_e2e is None # ALP-948 — per-invocation options chain reader + per-ticker options - # context. The reader applies the resolved options_chain filter policy; + # context, gated on the resolved profile's options feature flag: with + # options disabled the chain tool stays unmounted and the validator's + # option quote checks stay inert, matching the prompt's Hard blocks + # signal. The reader applies the resolved options_chain filter policy; # the context map decorates the analyst's REFERENCE PRICES lines for the - # same tickers the reference map carries. Built off the sync session - # factory in a worker thread so the per-ticker chain reads do not block - # the event loop. - options_chain_cfg = pipeline_config.resolved.options_chain - options_chain_reader = SqlOptionsChainReader( - sync_session_factory=context.sync_session_factory, - params=ChainFilterParams( - strike_band_pct=options_chain_cfg.strike_band_pct, - min_days_to_expiration=options_chain_cfg.min_days_to_expiration, - max_days_to_expiration=options_chain_cfg.max_days_to_expiration, - min_open_interest=options_chain_cfg.min_open_interest, - max_contracts_rendered=options_chain_cfg.max_contracts_rendered, - ), - ) - options_context = await asyncio.to_thread( - build_options_context, - options_chain_reader, - tickers=sorted(fill_collection_inputs.market_inputs.underlying_prices), - ) + # same tickers the reference map carries. Context is only built for a + # normal-mode invocation — the halt-mode watchlist bundle never renders + # it — and runs in a worker thread so the per-ticker chain reads do not + # block the event loop. + options_chain_reader: OptionsChainReader | None = None + options_context: Mapping[str, TickerOptionsContext] | None = None + if pipeline_config.resolved.feature_flags.options_enabled: + options_chain_reader = SqlOptionsChainReader( + sync_session_factory=context.sync_session_factory, + params=ChainFilterParams.from_config(pipeline_config.resolved.options_chain), + ) + if pipeline_mode is PipelineMode.NORMAL: + options_context = await asyncio.to_thread( + build_options_context, + options_chain_reader, + tickers=sorted(fill_collection_inputs.market_inputs.underlying_prices), + ) decision_kwargs = _build_decision_kwargs( invocation_id=invocation_id, pipeline_config=pipeline_config, diff --git a/src/alphamind/state/repository/options_chain_read.py b/src/alphamind/state/repository/options_chain_read.py index 3129de48b..51b25e259 100644 --- a/src/alphamind/state/repository/options_chain_read.py +++ b/src/alphamind/state/repository/options_chain_read.py @@ -24,10 +24,10 @@ import dataclasses import logging -from collections.abc import Callable, Iterable, Mapping, Sequence +from collections.abc import Callable, Iterable, Mapping from dataclasses import dataclass from datetime import UTC, date, datetime -from typing import Any, Literal, Protocol +from typing import TYPE_CHECKING, Any, Literal, Protocol from sqlalchemy import func, select from sqlalchemy.orm import Session, sessionmaker @@ -37,6 +37,10 @@ from alphamind.distillation.q3.atm_iv_baseline_compute import compute_atm_iv_baseline from alphamind.distillation.q3.atm_iv_baseline_loaders import load_atm_iv_history_by_ticker from alphamind.persistence.models import OptionsContracts, OptionsContractSnapshots +from alphamind.state.repository.sql_option_price_provider import _parse_snapshot_ts + +if TYPE_CHECKING: + from alphamind.config.models.options_chain import OptionsChainConfig __all__ = [ "ChainFilterParams", @@ -85,6 +89,22 @@ class ChainFilterParams: min_open_interest: int max_contracts_rendered: int + @classmethod + def from_config(cls, config: OptionsChainConfig) -> ChainFilterParams: + """Project the ``options_chain`` config section onto the filter policy. + + ``premium_staleness_tolerance_pct`` is deliberately excluded — it is + validator policy, not chain-selection policy, and travels to + ``validate_analyst_output`` separately. + """ + return cls( + strike_band_pct=config.strike_band_pct, + min_days_to_expiration=config.min_days_to_expiration, + max_days_to_expiration=config.max_days_to_expiration, + min_open_interest=config.min_open_interest, + max_contracts_rendered=config.max_contracts_rendered, + ) + @dataclass(frozen=True, slots=True) class ContractQuote: @@ -269,14 +289,6 @@ def options_context(self, underlying: str) -> TickerOptionsContext | None: # --------------------------------------------------------------------------- -def _parse_snapshot_ts(raw: str) -> datetime: - """Parse the collector's snapshot timestamp into a tz-aware UTC datetime.""" - parsed = datetime.fromisoformat(raw) - if parsed.tzinfo is None or parsed.utcoffset() is None: - parsed = parsed.replace(tzinfo=UTC) - return parsed - - _SNAPSHOT_COLUMNS = ( OptionsContractSnapshots.contract_ticker, OptionsContractSnapshots.snapshot_ts, @@ -325,6 +337,10 @@ def params(self) -> ChainFilterParams: # -- chain_slice -------------------------------------------------------- def chain_slice(self, underlying: str) -> ChainSlice | None: + with self._sync_session_factory() as session: + return self._chain_slice(session, underlying) + + def _chain_slice(self, session: Session, underlying: str) -> ChainSlice | None: latest_subq = ( select( OptionsContractSnapshots.contract_ticker.label("ct"), @@ -351,20 +367,18 @@ def chain_slice(self, underlying: str) -> ChainSlice | None: unusable = 0 spot: float | None = None spot_ts: datetime | None = None - with self._sync_session_factory() as session: - for row in session.execute(stmt).all(): - (*snapshot_fields, underlying_price) = row - quote = _quote_from_row(tuple(snapshot_fields)) - if quote is None: - unusable += 1 - else: - quotes.append(quote) - # Spot rides on the freshest snapshot row carrying an - # underlying_price — usable-quote status is irrelevant to it. - row_ts = _parse_snapshot_ts(row[1]) - if underlying_price is not None and (spot_ts is None or row_ts > spot_ts): - spot = float(underlying_price) - spot_ts = row_ts + for row in session.execute(stmt).all(): + quote = _quote_from_row(row) + if quote is None: + unusable += 1 + else: + quotes.append(quote) + # Spot rides on the freshest snapshot row carrying an + # underlying_price — usable-quote status is irrelevant to it. + row_ts = _parse_snapshot_ts(row.snapshot_ts) + if row.underlying_price is not None and (spot_ts is None or row_ts > spot_ts): + spot = float(row.underlying_price) + spot_ts = row_ts if spot is None or spot <= 0: return None @@ -385,6 +399,11 @@ def chain_slice(self, underlying: str) -> ChainSlice | None: # -- latest_quote ------------------------------------------------------- def latest_quote(self, occ: OccSymbol) -> ContractQuote | None: + """The latest USABLE snapshot for *occ* — a newer garbage row (missing + or crossed quote, missing IV/delta) must not mask an older usable one, + or the validator would reject a premium the chain tool itself anchored. + The SQL filter mirrors :func:`_quote_from_row`'s usability predicate. + """ contract_ticker = _polygon_contract_ticker(occ) stmt = ( select(*_SNAPSHOT_COLUMNS) @@ -392,7 +411,16 @@ def latest_quote(self, occ: OccSymbol) -> ContractQuote | None: OptionsContracts, OptionsContracts.contract_ticker == OptionsContractSnapshots.contract_ticker, ) - .where(OptionsContractSnapshots.contract_ticker == contract_ticker) + .where( + OptionsContractSnapshots.contract_ticker == contract_ticker, + OptionsContractSnapshots.bid.is_not(None), + OptionsContractSnapshots.ask.is_not(None), + OptionsContractSnapshots.bid >= 0, + OptionsContractSnapshots.ask > 0, + OptionsContractSnapshots.bid <= OptionsContractSnapshots.ask, + OptionsContractSnapshots.implied_volatility.is_not(None), + OptionsContractSnapshots.delta.is_not(None), + ) .order_by(OptionsContractSnapshots.snapshot_ts.desc()) .limit(1) ) @@ -400,30 +428,31 @@ def latest_quote(self, occ: OccSymbol) -> ContractQuote | None: row = session.execute(stmt).first() if row is None: return None - return _quote_from_row(tuple(row)) + return _quote_from_row(row) # -- options_context ---------------------------------------------------- def options_context(self, underlying: str) -> TickerOptionsContext | None: - chain = self.chain_slice(underlying) - if chain is None or not chain.contracts: - return None + # One session serves both the chain read and the IV-history read — + # build_options_context loops this per active-universe ticker, so the + # per-call session count matters. + with self._sync_session_factory() as session: + chain = self._chain_slice(session, underlying) + if chain is None or not chain.contracts: + return None + iv_rank = self._iv_rank(session, underlying) expirations = tuple(sorted({quote.expiration for quote in chain.contracts})) - return TickerOptionsContext( - iv_rank=self._iv_rank(underlying), - liquid_expirations=expirations, - ) + return TickerOptionsContext(iv_rank=iv_rank, liquid_expirations=expirations) - def _iv_rank(self, underlying: str) -> float | None: + def _iv_rank(self, session: Session, underlying: str) -> float | None: """Read-only IV-rank via the q3 pure compute — no baseline upsert.""" as_of_iso = self._now().astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") - with self._sync_session_factory() as session: - history = load_atm_iv_history_by_ticker( - session, - ticker_scope=(underlying,), - as_of=as_of_iso, - window_days=_IV_RANK_WINDOW_DAYS, - )[underlying] + history = load_atm_iv_history_by_ticker( + session, + ticker_scope=(underlying,), + as_of=as_of_iso, + window_days=_IV_RANK_WINDOW_DAYS, + )[underlying] result = compute_atm_iv_baseline( history, window_days=_IV_RANK_WINDOW_DAYS, @@ -435,50 +464,39 @@ def _iv_rank(self, underlying: str) -> float | None: return float(percentile) if percentile is not None else None -def _quote_from_row(row: Sequence[Any]) -> ContractQuote | None: - """Build a :class:`ContractQuote` from a ``_SNAPSHOT_COLUMNS`` row. +def _quote_from_row(row: Any) -> ContractQuote | None: + """Build a :class:`ContractQuote` from a row selecting ``_SNAPSHOT_COLUMNS``. - Returns ``None`` for an unusable row: a missing or crossed two-sided - quote, a missing IV or delta, or a contract ticker outside the OCC - pattern. A zero bid alone is usable — deep-OTM contracts legitimately - quote ``bid=0``. + Fields are read by column label (order-independent — a future reorder of + ``_SNAPSHOT_COLUMNS`` cannot silently mis-assign them). Returns ``None`` + for an unusable row: a missing or crossed two-sided quote, a missing IV + or delta, or a contract ticker outside the OCC pattern. A zero bid alone + is usable — deep-OTM contracts legitimately quote ``bid=0``. """ - ( - contract_ticker, - snapshot_ts, - bid, - ask, - implied_volatility, - delta, - open_interest, - underlying_ticker, - expiration_date, - strike_price, - contract_type, - ) = row + bid, ask = row.bid, row.ask if bid is None or ask is None or float(ask) <= 0 or float(bid) < 0 or float(bid) > float(ask): return None - if implied_volatility is None or delta is None: + if row.implied_volatility is None or row.delta is None: return None - if contract_type not in ("call", "put"): + if row.contract_type not in ("call", "put"): return None try: - occ = make_occ_symbol(str(contract_ticker).removeprefix("O:")) + occ = make_occ_symbol(str(row.contract_ticker).removeprefix("O:")) except ValueError: - logger.warning("skipping non-OCC contract_ticker %r", contract_ticker) + logger.warning("skipping non-OCC contract_ticker %r", row.contract_ticker) return None return ContractQuote( occ_symbol=occ, - underlying=str(underlying_ticker), - expiration=date.fromisoformat(str(expiration_date)), - strike=float(strike_price), - contract_type=contract_type, + underlying=str(row.underlying_ticker), + expiration=date.fromisoformat(str(row.expiration_date)), + strike=float(row.strike_price), + contract_type=row.contract_type, bid=float(bid), ask=float(ask), - implied_volatility=float(implied_volatility), - delta=float(delta), - open_interest=int(open_interest) if open_interest is not None else 0, - snapshot_ts=_parse_snapshot_ts(str(snapshot_ts)), + implied_volatility=float(row.implied_volatility), + delta=float(row.delta), + open_interest=int(row.open_interest) if row.open_interest is not None else 0, + snapshot_ts=_parse_snapshot_ts(str(row.snapshot_ts)), ) diff --git a/tests/decision/analyst/test_input_bundle.py b/tests/decision/analyst/test_input_bundle.py index 2c0025b09..20281fc03 100644 --- a/tests/decision/analyst/test_input_bundle.py +++ b/tests/decision/analyst/test_input_bundle.py @@ -595,3 +595,16 @@ def test_normal_mode_without_options_context_keeps_bare_price_lines() -> None: ) assert " NVDA: 905.12\n" in out assert "IVr" not in out + + +def test_normal_mode_options_context_with_no_expirations_keeps_bare_line() -> None: + kwargs = _normal_kwargs() + kwargs["options_context"] = { + "NVDA": TickerOptionsContext(iv_rank=62.4, liquid_expirations=()), + } + out = assemble_input_bundle_normal( + **kwargs, # type: ignore[arg-type] + sector_label_display=_MICRO_SECTOR_LABELS, + ) + assert " NVDA: 905.12\n" in out + assert "exp:" not in out diff --git a/tests/state/test_options_chain_read.py b/tests/state/test_options_chain_read.py index 466dcd7a2..58d40c902 100644 --- a/tests/state/test_options_chain_read.py +++ b/tests/state/test_options_chain_read.py @@ -440,3 +440,54 @@ def test_build_options_context_includes_only_tickers_with_chains( context = build_options_context(_reader(sync_factory), tickers=("AAPL", "TSLA")) assert set(context) == {"AAPL"} + + +def test_latest_quote_skips_newer_unusable_snapshot( + sync_factory: sessionmaker[Session], +) -> None: + """A newer garbage row (crossed quote) must not mask the older usable one + the chain tool anchored against — latest_quote returns the latest USABLE + snapshot.""" + with sync_factory() as session, session.begin(): + _seed_underlying(session, "AAPL") + ct = _seed_contract(session, underlying="AAPL", strike=100.0, expiration="2026-07-02") + _seed_snapshot( + session, + contract_ticker=ct, + underlying="AAPL", + snapshot_ts=_NOW - timedelta(minutes=30), + bid=4.90, + ask=5.10, + ) + _seed_snapshot( + session, + contract_ticker=ct, + underlying="AAPL", + snapshot_ts=_NOW - timedelta(minutes=5), + bid=5.30, + ask=5.10, # crossed — unusable + ) + + occ = occ_symbol_for_contract( + underlying="AAPL", expiration=date(2026, 7, 2), strike=100.0, contract_type="call" + ) + quote = _reader(sync_factory).latest_quote(occ) + + assert quote is not None + assert quote.nbbo_mid == pytest.approx(5.00) + assert quote.snapshot_ts == _NOW - timedelta(minutes=30) + + +def test_chain_filter_params_from_config_projects_the_five_filter_fields() -> None: + from alphamind.config.models.options_chain import OptionsChainConfig + + config = OptionsChainConfig( + strike_band_pct=15.0, + min_days_to_expiration=5, + max_days_to_expiration=45, + min_open_interest=100, + max_contracts_rendered=24, + premium_staleness_tolerance_pct=10.0, + ) + + assert ChainFilterParams.from_config(config) == _PARAMS From 3a2460dc1eebf66e1e2b19171d3adbf3a79e93ed Mon Sep 17 00:00:00 2001 From: Jackson Atassi Date: Wed, 10 Jun 2026 21:41:33 -0600 Subject: [PATCH 3/4] fix: address /review findings on ALP-948 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [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 --- prompts/decision/analyst.md | 2 +- prompts/decision/strategist.md | 2 +- .../state/repository/options_chain_read.py | 17 ++++++++++++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/prompts/decision/analyst.md b/prompts/decision/analyst.md index 4d5460294..2fa6e94e2 100644 --- a/prompts/decision/analyst.md +++ b/prompts/decision/analyst.md @@ -18,7 +18,7 @@ You are the analyst in a systematic trading pipeline. You read a synthesized mar - Your user turn contains, in order: (1) a guardrail state header block, (2) a `=== REFERENCE PRICES (authoritative bracket anchors) ===` block listing the current per-ticker reference price, (3) an `=== AVAILABLE TOOLS ===` reminder, (4) a `=== SYNTHESIZER BRIEF PREVIEW (full brief via retrieve_brief) ===` section — the synthesizer's framing preface, with each cited finding's full content available via `retrieve_brief(ref_id)`. - Your output is consumed by a deterministic proposal pre-processor (reads structured fields) and a portfolio manager LLM (reads narrative fields). It is not read by humans. - Source references in the synthesizer brief use typed prefixes: `SA-TECH` (tech/semis researcher), `SA-FIN` (financials researcher), `SA-ENERGY` (energy researcher), `QR` (baseline qualitative research), `AR` (adaptive research threads), `CR` (correlation/regime brief). No other prefixes exist. -- Three tools are callable: `validate_guardrail`, `retrieve_brief`, and `retrieve_options_chain`. No other tools. +- Up to three tools are callable: `validate_guardrail`, `retrieve_brief`, and `retrieve_options_chain`. `retrieve_options_chain` is mounted only when the active profile enables options — the `=== AVAILABLE TOOLS ===` block in your user turn is the authoritative surface; if the chain tool is absent there, options are off for this invocation and every options-related instruction in this prompt is inert. No other tools. - Your proposals will be re-evaluated and may be rejected, resized, or modified by the portfolio manager. Your job is not to advocate; it is to present complete, honest, falsifiable theses and let the PM decide. - Portfolio state can drift between your guardrail check and execution; the execution layer performs a final authoritative guardrail check. You are not the last line of defense — but every proposal must be compliant at the time you validate it. - The analyst is responsible for new entries only. Existing position management — hold/reduce/close/adjust/add — is the strategist's domain. Do not emit position-management actions. diff --git a/prompts/decision/strategist.md b/prompts/decision/strategist.md index 2877787a3..2842ed706 100644 --- a/prompts/decision/strategist.md +++ b/prompts/decision/strategist.md @@ -24,7 +24,7 @@ You are the strategist in a systematic trading pipeline. You read a synthesized - Your output is consumed by a deterministic proposal pre-processor (reads structured fields) and a portfolio manager LLM (reads narrative fields). It is not read by humans. - You run in parallel with the analyst in a separate context window. The analyst produces new-entry proposals; you produce position-management assessments. Neither sees the other's output; the pre-processor merges and annotates before the PM. - Source references in the synthesizer brief use typed prefixes with a numeric index: `[SA-TECH-N]` (tech/semis researcher), `[SA-FIN-N]` (financials researcher), `[SA-ENERGY-N]` (energy researcher), `[QR-N]` (baseline qualitative research), `[AR-N]` (adaptive research threads), `[CR-N]` (correlation/regime brief). No other prefixes exist. Every citation you emit must carry an `-N` index. Bare prefixes (`[CR]`, `[SA-TECH]`, etc.) are structurally invalid because the retrieval store is keyed `-` and a bare prefix cannot resolve. When you would otherwise reach for a general region of the brief — "the correlation backdrop", "the financials read as a whole" — cite the specific section that grounds your point (e.g., `[CR-12]` for a regime-level observation in `portfolio_level_observations.thesis_dependency_warnings`, `[CR-7]` for a per-position correlation warning in `status_rationale`), not the unindexed prefix. -- Four tools are callable: `validate_guardrail`, `validate_guardrail_batch`, `retrieve_brief`, and `retrieve_options_chain`. No other tools. +- Up to four tools are callable: `validate_guardrail`, `validate_guardrail_batch`, `retrieve_brief`, and `retrieve_options_chain`. `retrieve_options_chain` is mounted only when the active profile enables options — the `=== AVAILABLE TOOLS ===` block in your user turn is the authoritative surface; if it is absent there, skip any option-marks lookup. No other tools. - Your assessments may be rejected, modified, approved with parameter adjustments, or overridden with PM-authored corrective commands by the portfolio manager. Your job is not to advocate; it is to classify honestly and recommend actions whose rationales stand up to pressure-testing. - The analyst owns new entries. You own hold / reduce / close / adjust-bracket / add for existing positions and maintain / modify / cancel for pending orders. Do not propose new entries; do not reassess new-entry proposals. - Thesis-status classification is your sole responsibility. The PM reviews your calls, but does not reclassify. diff --git a/src/alphamind/state/repository/options_chain_read.py b/src/alphamind/state/repository/options_chain_read.py index 51b25e259..f68449961 100644 --- a/src/alphamind/state/repository/options_chain_read.py +++ b/src/alphamind/state/repository/options_chain_read.py @@ -37,7 +37,6 @@ from alphamind.distillation.q3.atm_iv_baseline_compute import compute_atm_iv_baseline from alphamind.distillation.q3.atm_iv_baseline_loaders import load_atm_iv_history_by_ticker from alphamind.persistence.models import OptionsContracts, OptionsContractSnapshots -from alphamind.state.repository.sql_option_price_provider import _parse_snapshot_ts if TYPE_CHECKING: from alphamind.config.models.options_chain import OptionsChainConfig @@ -289,6 +288,22 @@ def options_context(self, underlying: str) -> TickerOptionsContext | None: # --------------------------------------------------------------------------- +def _parse_snapshot_ts(raw: str) -> datetime: + """Parse the collector's snapshot timestamp into a tz-aware UTC datetime. + + Kept local rather than imported from ``sql_option_price_provider`` — + importing that module transitively reaches ``sql_repository`` and its + execution-layer aggregates, which breaks the ``decision-not-execution`` + import contract for this module's decision-layer consumers. Mirrors the + same kept-local rationale in + ``execution.continuous_monitor.greeks_refresh.iv_provider._parse_snapshot_ts``. + """ + parsed = datetime.fromisoformat(raw) + if parsed.tzinfo is None or parsed.utcoffset() is None: + parsed = parsed.replace(tzinfo=UTC) + return parsed + + _SNAPSHOT_COLUMNS = ( OptionsContractSnapshots.contract_ticker, OptionsContractSnapshots.snapshot_ts, From c66bd935cfd4c800fb58a8efd8b028122c8f75e0 Mon Sep 17 00:00:00 2001 From: Jackson Atassi Date: Wed, 10 Jun 2026 21:48:13 -0600 Subject: [PATCH 4/4] fix: decouple no-supersession tests from the live repo's git history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../validation/test_supersession.py | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/feedback_loop/validation/test_supersession.py b/tests/feedback_loop/validation/test_supersession.py index 5b507a93e..aef0699cf 100644 --- a/tests/feedback_loop/validation/test_supersession.py +++ b/tests/feedback_loop/validation/test_supersession.py @@ -34,6 +34,10 @@ _WINDOW_DAYS = 21 _DUE_AT = _REGISTERED_AT + timedelta(days=_WINDOW_DAYS) _ARTIFACT = "prompts/decision/strategist.md" +# For no-supersession assertions run against the live repo (repo_root unpinned): +# a path git has never tracked, so the concurrent-edit trigger cannot fire no +# matter what real commits land during the test window. +_NEVER_TRACKED_ARTIFACT = "prompts/decision/never-tracked-test-artifact.md" _REG_MODEL = "claude-opus-4-8" _REG_REGIME = "normal" @@ -257,7 +261,14 @@ def test_post_window_regime_shift_supersedes(self, db_path: str) -> None: assert record.superseded_reason is SupersededReason.REGIME_TRANSITION # type: ignore[attr-defined] def test_same_regime_in_window_is_not_superseded(self, db_path: str) -> None: - _add_validation(db_path, _validation_row("val-stable")) + # Watch a never-tracked path: the unpinned repo_root walks the LIVE + # repo's git log, so watching a real prompt path makes this test fail + # whenever a genuine commit to that prompt lands inside the hard-coded + # June 2026 window (surfaced by ALP-948's prompts/decision edits). + _add_validation( + db_path, + _validation_row("val-stable", edited_artifact=_NEVER_TRACKED_ARTIFACT), + ) _add_invocation( db_path, "inv-post", @@ -457,7 +468,12 @@ def test_runs_detector_and_reports_count( def test_reports_zero_when_nothing_supersedes( self, db_path: str, capsys: pytest.CaptureFixture[str] ) -> None: - _add_validation(db_path, _validation_row("val-cli-clean")) + # Never-tracked artifact for the same live-repo reason as + # test_same_regime_in_window_is_not_superseded above. + _add_validation( + db_path, + _validation_row("val-cli-clean", edited_artifact=_NEVER_TRACKED_ARTIFACT), + ) rc = cli.main(["detect-supersessions", "--db-path", db_path]) assert rc == 0 assert json.loads(capsys.readouterr().out)["marked"] == 0