diff --git a/prod/clean_arch/violet/live_factor_source.py b/prod/clean_arch/violet/live_factor_source.py new file mode 100644 index 0000000..443bfae --- /dev/null +++ b/prod/clean_arch/violet/live_factor_source.py @@ -0,0 +1,123 @@ +"""VIOLET V3.4b: source live ``SizingFactors`` from BLUE's published HZ planes. + +The field-path validation (``prod/docs/VIOLET_V34B_LIVE_FACTOR_FIELD_VALIDATION.md``) +established that of the eight sizing inputs, only ``posture`` and ``esof_score`` are +present in maps live BLUE publishes to Hazelcast: + + - ``posture`` ← ``DOLPHIN_STATE_BLUE`` ``engine_snapshot['posture']`` + - ``esof_score`` ← ``DOLPHIN_FEATURES['esof_latest'|'esof_advisor_latest']``, + parsed by BLUE's OWN ``parse_esof_payload`` / + ``esof_score_from_payload`` (wrap, don't reimplement). + +The remaining five (``boost``, ``beta``, ``mc_scale``, ``ob_median_imbalance``, +``ob_agreement_pct``, ``dc_status``) are BLUE-organ outputs — the ACB over +``DOLPHIN_FEATURES['exf_latest']``, the MC flag→scale derivation, ``OBFeatureEngine``, +and the per-asset signal generator — and are NOT present as scalars in any HZ map. +Sourcing them live is the V3.4c organ-wiring sprint. + +Until then this adapter sources the two HZ-available factors faithfully and supplies +BLUE's OWN neutral sentinels for the organ-derived five (``boost=1.0``, ``beta=0.0``, +``mc_scale=1.0``, ``ob_*=None``, ``dc_status="NONE"``). The result flows through the +validated ``extract_live_sizing_factors`` normalizer, so the V3.4 shadow breakdown +records posture+esof LIVE and the rest NEUTRAL — explicit, never silently faked. + +Pure boundary: callers pass already-fetched HZ blobs (the DARK service reads the maps +and hands the dicts in). No Hazelcast client here, no I/O, no launcher coupling — +mirrors ``live_factors.py``'s philosophy and keeps the adapter fully unit-testable. +""" + +from __future__ import annotations + +import sys +from collections.abc import Mapping +from pathlib import Path +from typing import Any, Optional + +from .decision_engine import SizingFactors +from .domain import typed +from .live_factors import extract_live_sizing_factors + +_PROJECT_ROOT = Path(__file__).resolve().parents[3] + +# The organ-derived factors this adapter cannot source from HZ yet (V3.4c). Listed so +# the journal/diagnostics can mark them NEUTRAL rather than mistaking them for live. +ORGAN_DERIVED_FACTORS = ( + "boost", "beta", "mc_scale", + "ob_median_imbalance", "ob_agreement_pct", "dc_status", +) + + +def _import_esof_gate() -> Any: + """Import BLUE's ``esof_size_gate`` (same root-injection as ``sizing.py``).""" + try: + from nautilus_dolphin.nautilus import esof_size_gate # type: ignore + except ImportError: + for p in (str(_PROJECT_ROOT / "nautilus_dolphin"), str(_PROJECT_ROOT)): + if p not in sys.path: + sys.path.insert(0, p) + sys.modules.pop("nautilus_dolphin", None) + from nautilus_dolphin.nautilus import esof_size_gate # type: ignore + return esof_size_gate + + +def posture_from_engine_snapshot(snapshot: Optional[Mapping[str, Any]]) -> str: + """BLUE's ``engine_snapshot['posture']`` (DOLPHIN_STATE_BLUE), defaulting to APEX. + + Mirrors BLUE's own default (``getattr(self, '_day_posture', 'APEX')``, + esf_alpha_orchestrator.py:365/613). Upper-cased for the SizingFactors contract. + """ + if not isinstance(snapshot, Mapping): + return "APEX" + raw = snapshot.get("posture") + text = str(raw).strip() if raw is not None else "" + return text.upper() if text else "APEX" + + +def esof_score_from_features( + esof_raw: Any, + *, + max_age_s: Optional[float] = None, +) -> Optional[float]: + """Extract the EsoF advisory score from a raw HZ ``esof_latest`` value. + + Mirrors BLUE's ``_read_esof_payload`` two-step exactly: ``parse_esof_payload(raw)`` + (the HZ value is a raw JSON blob) then ``esof_score_from_payload`` — both BLUE's + OWN functions (nautilus_event_trader.py:716,729), no reimplementation. ``max_age_s`` + mirrors BLUE's freshness gate (``ESOF_FRESHNESS_S``); ``None`` skips the staleness + check. Returns ``None`` when the value is missing/unparseable/stale — SizingFactors + then leaves ``esof_score`` unset (BLUE's ``esof_size_mult_from_score(None)`` neutral + path). + """ + if esof_raw is None: + return None + gate = _import_esof_gate() + payload = gate.parse_esof_payload(esof_raw) + if not payload: + return None + score = gate.esof_score_from_payload(payload, max_age_s=max_age_s) + return None if score is None else float(score) + + +@typed +def source_live_sizing_factors( + *, + engine_snapshot: Optional[Mapping[str, Any]] = None, + esof_payload: Any = None, + esof_max_age_s: Optional[float] = None, +) -> SizingFactors: + """Build live ``SizingFactors`` from BLUE's published HZ blobs. + + ``posture`` and ``esof_score`` are sourced LIVE from ``engine_snapshot`` and the + ``esof_latest`` payload; the six organ-derived factors fall to BLUE's neutral + sentinels via the ``extract_live_sizing_factors`` defaults. The DARK service is + expected to fetch ``DOLPHIN_STATE_BLUE['engine_snapshot']`` and + ``DOLPHIN_FEATURES['esof_latest']`` and pass them here. + """ + posture = posture_from_engine_snapshot(engine_snapshot) + esof_score = esof_score_from_features(esof_payload, max_age_s=esof_max_age_s) + + hz_snapshot: dict[str, Any] = {"posture": posture} + if esof_score is not None: + hz_snapshot["esof_score"] = esof_score + + return extract_live_sizing_factors(hz_snapshot=hz_snapshot) diff --git a/prod/clean_arch/violet/test_violet_live_factor_source.py b/prod/clean_arch/violet/test_violet_live_factor_source.py new file mode 100644 index 0000000..1907022 --- /dev/null +++ b/prod/clean_arch/violet/test_violet_live_factor_source.py @@ -0,0 +1,85 @@ +"""V3.4b: live_factor_source — SizingFactors from BLUE's published HZ blobs. + +Validates the field-path findings in +prod/docs/VIOLET_V34B_LIVE_FACTOR_FIELD_VALIDATION.md: posture + esof_score are +sourced LIVE from engine_snapshot / the esof_latest payload, the six organ-derived +factors fall to BLUE's neutral sentinels. +""" + +from __future__ import annotations + +import sys + +import pytest + +sys.path.insert(0, "/mnt/dolphinng5_predict") + +from prod.clean_arch.violet.decision_engine import SizingFactors +from prod.clean_arch.violet.live_factor_source import ( + ORGAN_DERIVED_FACTORS, + esof_score_from_features, + posture_from_engine_snapshot, + source_live_sizing_factors, +) + + +def test_posture_sourced_and_upper_cased(): + assert posture_from_engine_snapshot({"posture": "STALKER"}) == "STALKER" + assert posture_from_engine_snapshot({"posture": "restored"}) == "RESTORED" + + +def test_posture_defaults_to_apex_like_blue(): + assert posture_from_engine_snapshot(None) == "APEX" + assert posture_from_engine_snapshot({}) == "APEX" + assert posture_from_engine_snapshot({"posture": ""}) == "APEX" + assert posture_from_engine_snapshot({"posture": None}) == "APEX" + + +def test_esof_score_parsed_from_dict_payload(): + # max_age_s=None skips staleness; advisory_score preferred over score. + assert esof_score_from_features({"advisory_score": 0.5}) == 0.5 + assert esof_score_from_features({"score": -0.1}) == pytest.approx(-0.1) + + +def test_esof_score_parsed_from_raw_json_string(): + # The HZ value is a raw JSON blob — BLUE's parse_esof_payload handles it. + assert esof_score_from_features('{"advisory_score": 0.25}') == 0.25 + + +def test_esof_score_none_when_missing_or_unparseable(): + assert esof_score_from_features(None) is None + assert esof_score_from_features("not json") is None + assert esof_score_from_features({}) is None # no advisory_score/score key + + +def test_esof_staleness_gate_honored_when_max_age_supplied(): + # A payload with an ancient timestamp is stale → None when max_age_s is set. + stale = {"advisory_score": 0.5, "unix": 0.0} # 1970 → very old + assert esof_score_from_features(stale, max_age_s=30.0) is None + # …but with no freshness gate (None) the score still comes through. + assert esof_score_from_features(stale, max_age_s=None) == 0.5 + + +def test_source_live_factors_posture_and_esof_live_rest_neutral(): + factors = source_live_sizing_factors( + engine_snapshot={"posture": "RESTORED", "capital": 71591.1}, + esof_payload={"advisory_score": 0.42}, + ) + assert isinstance(factors, SizingFactors) + assert factors.posture == "RESTORED" + assert factors.esof_score == 0.42 + # the six organ-derived factors at BLUE's own neutral sentinels (V3.4c will source) + assert factors.boost == 1.0 and factors.beta == 0.0 and factors.mc_scale == 1.0 + assert factors.ob_median_imbalance is None and factors.ob_agreement_pct is None + assert factors.dc_status == "NONE" + + +def test_source_live_factors_all_neutral_when_no_blobs(): + assert source_live_sizing_factors() == SizingFactors() + + +def test_organ_derived_factor_set_is_the_documented_six(): + assert set(ORGAN_DERIVED_FACTORS) == { + "boost", "beta", "mc_scale", + "ob_median_imbalance", "ob_agreement_pct", "dc_status", + } diff --git a/prod/docs/VIOLET_V34B_LIVE_FACTOR_FIELD_VALIDATION.md b/prod/docs/VIOLET_V34B_LIVE_FACTOR_FIELD_VALIDATION.md new file mode 100644 index 0000000..e900a4f --- /dev/null +++ b/prod/docs/VIOLET_V34B_LIVE_FACTOR_FIELD_VALIDATION.md @@ -0,0 +1,61 @@ +# VIOLET V3.4b — live-factor field-path validation + +**Date:** 2026-06-16 +**Task:** validate `prod/clean_arch/violet/live_factors.py`'s candidate field paths +against how live BLUE actually sources the five sizing multipliers, *before* wiring +the launcher-sourcing half of V3.4b. + +## TL;DR + +`live_factors.py` assumes the eight sizing inputs arrive as flat/nested keys in a +single `hz_snapshot` dict. **That premise holds for only one of them (`posture`).** +`esof_score` is present in HZ but as a *payload to parse*, not a flat key. The other +five (`boost`, `beta`, `mc_scale`, `ob_median_imbalance`, `ob_agreement_pct`, +`dc_status`) are **BLUE-organ outputs that are not published to any HZ map** — they +live in the live `NDAlphaEngine`'s process memory / are recomputed per scan. + +The speculative alternate paths in `live_factors.py` (`acb_boost`, `s_acb_boost`, +`("acb","boost")`, `day_mc_scale`, `("esof","advisory_score")`, `("ob","market",…)`, +`("signal","dc_status")`, `safety_posture`, …) **correspond to nothing in live BLUE.** +They are harmless (first-match-wins, flat canonical key is tried first) but dead. + +## Per-factor validated sourcing + +Source of truth: `esf_alpha_orchestrator.py` (the composition), `adaptive_circuit_breaker.py` +(ACB), `nautilus_event_trader.py` (the HZ reads/publishes). + +| Factor | How live BLUE gets it | In a VIOLET-readable HZ map? | +|---|---|---| +| `posture` | `_day_posture`, set in `begin_day(posture=…)`; published in `engine_snapshot['posture']` (`nautilus_event_trader.py:5097`, map `DOLPHIN_STATE_BLUE`) and `DOLPHIN_SAFETY.latest.posture` | ✅ **flat key `posture`** in engine_snapshot | +| `esof_score` | `_read_esof_payload()` → `DOLPHIN_FEATURES['esof_latest'\|'esof_advisor_latest']` → `parse_esof_payload` → `esof_score_from_payload(..., max_age_s=ESOF_FRESHNESS_S)` (`nautilus_event_trader.py:707-719`) | ✅ but a **payload parse**, not a flat `esof_score` | +| `boost` / `beta` | `acb.get_dynamic_boost_from_hz(date)` → `acb_info['boost'\|'beta']`; the ACB **computes** them from `DOLPHIN_FEATURES['exf_latest']` (funding_btc/dvol_btc/fng/taker — `adaptive_circuit_breaker.py:511,528-533`). Applied via `begin_day` / `update_acb_boost` (`esf_alpha_orchestrator.py:764,946`). | ❌ raw inputs are in HZ; the **scalar requires running the ACB** | +| `mc_scale` | `_day_mc_scale`, **derived** in `begin_day` from MC-Forewarner `mc_orange`/`mc_red` flags (`esf_alpha_orchestrator.py:962-964`: orange→0.5, red/TURTLE/HIBERNATE→…) | ❌ not a HZ scalar | +| `ob_median_imbalance` / `ob_agreement_pct` | `ob_engine.get_market(bar_idx, symbols)` over the live OB feed, **per asset** (`esf_alpha_orchestrator.py:590-595`) | ❌ computed live; not in HZ | +| `dc_status` | per-asset `signal.dc_status` from the signal generator (`esf_alpha_orchestrator.py:576`) | ❌ computed per-scan; not in HZ | + +`engine_snapshot` payload (the map BLUE publishes for consumers) was inspected in full +(`nautilus_event_trader.py:5092-5126`): it carries `posture`, `last_vel_div`, `vol_ok`, +`last_scan_number`, `capital`, leverage caps, position list — **and none of the five +organ-derived multipliers.** + +## Consequence for V3.4b sourcing (#2) + +A faithful, *complete* live-factor source is NOT a HZ scrape — it requires VIOLET to run +the same organs BLUE does: +- an **ACB** over `DOLPHIN_FEATURES['exf_latest']` → boost/beta, +- the **MC** flag→`mc_scale` derivation, +- an **OBFeatureEngine** over the OB feed → ob_*, +- a **signal generator** → dc_status. + +That is a multi-organ sprint (call it **V3.4c**), not a quick wiring. + +What IS sourceable now, from maps BLUE already publishes, read-only: +- **`posture`** ← `engine_snapshot['posture']` +- **`esof_score`** ← `DOLPHIN_FEATURES['esof_latest']` via BLUE's own `esof_score_from_payload` + +So the honest V3.4b increment (this PR) is a **pure adapter** — +`live_factor_source.py` — that sources those two faithfully and supplies BLUE's own +neutral sentinels for the organ-derived five (`boost=1.0`, `beta=0.0`, `mc_scale=1.0`, +`ob_*=None`, `dc_status="NONE"`), feeding `extract_live_sizing_factors`. The shadow +journal's V3.4 breakdown then records posture+esof live and the rest neutral — explicit, +not silently faked. The organ wiring (boost/beta/mc/ob/dc live) is deferred to V3.4c.