diff --git a/prod/clean_arch/violet/live_factors.py b/prod/clean_arch/violet/live_factors.py new file mode 100644 index 0000000..6d51e42 --- /dev/null +++ b/prod/clean_arch/violet/live_factors.py @@ -0,0 +1,203 @@ +"""VIOLET V3.4b helper: normalize live factor planes into ``SizingFactors``. + +This is a standalone boundary helper for the launcher-side V3.4b work item. +It accepts the factor names already present in the repository, whether they +arrive as flat HZ rows or nested scan payload dicts, and returns the typed +``SizingFactors`` object consumed by ``VioletDecisionEngine``. + +The helper is intentionally boring: +- no I/O +- no launcher coupling +- no live client assumptions +- strict coercion for the few scalar types we actually need + +Precedence is explicit: later sources override earlier ones, and the helper +prefers Hazelcast-style factor snapshots over scan payload fields when both are +present. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +from pydantic import Field + +from .decision_engine import SizingFactors +from .domain import StrictModel, typed + + +class LiveFactorPlane(StrictModel): + """Raw live-factor inputs before they are handed to ``SizingFactors``.""" + + boost: float = Field(default=1.0, ge=0.0, allow_inf_nan=False) + beta: float = Field(default=0.0, ge=0.0, allow_inf_nan=False) + mc_scale: float = Field(default=1.0, ge=0.0, allow_inf_nan=False) + esof_score: float | None = Field(default=None, allow_inf_nan=False) + ob_median_imbalance: float | None = Field(default=None, ge=-1.0, le=1.0, allow_inf_nan=False) + ob_agreement_pct: float | None = Field(default=None, ge=0.0, le=1.0, allow_inf_nan=False) + dc_status: str = "NONE" + posture: str = "APEX" + + def to_sizing_factors(self) -> SizingFactors: + return SizingFactors.model_validate(self.model_dump()) + + +def _walk(source: Mapping[str, Any] | None, path: Sequence[str]) -> Any | None: + cur: Any = source + for key in path: + if not isinstance(cur, Mapping) or key not in cur: + return None + cur = cur[key] + return cur + + +def _first_value(sources: Sequence[Mapping[str, Any] | None], *paths: Sequence[str]) -> Any | None: + for source in sources: + if source is None: + continue + for path in paths: + value = _walk(source, path) + if value is not None and value != "": + return value + return None + + +def _coerce_float(value: Any, default: float | None) -> float | None: + if value is None: + return default + if isinstance(value, bool): + return float(value) + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _coerce_str(value: Any, default: str) -> str: + if value is None: + return default + text = str(value).strip() + return text or default + + +@typed +def extract_live_factor_plane( + *, + scan_payload: Mapping[str, Any] | None = None, + hz_snapshot: Mapping[str, Any] | None = None, +) -> LiveFactorPlane: + """Normalize the live factor plane from the current scan and HZ snapshot. + + Resolution order is: + 1. defaults + 2. scan payload + 3. Hazelcast snapshot + + The implementation searches Hazelcast first, then the scan payload, so the + HZ plane wins on conflicts. + """ + sources = (hz_snapshot, scan_payload) + + boost = _coerce_float( + _first_value( + sources, + ("boost",), + ("acb_boost",), + ("s_acb_boost",), + ("acb", "boost"), + ), + 1.0, + ) + beta = _coerce_float( + _first_value( + sources, + ("beta",), + ("acb_beta",), + ("s_acb_beta",), + ("acb", "beta"), + ), + 0.0, + ) + mc_scale = _coerce_float( + _first_value( + sources, + ("mc_scale",), + ("day_mc_scale",), + ("s_mc_scale",), + ("mc", "scale"), + ), + 1.0, + ) + esof_score = _coerce_float( + _first_value( + sources, + ("esof_score",), + ("s_esof_score",), + ("esof", "advisory_score"), + ("esof", "score"), + ), + None, + ) + ob_median_imbalance = _coerce_float( + _first_value( + sources, + ("ob_median_imbalance",), + ("ob", "median_imbalance"), + ("ob", "median"), + ("ob", "market", "median_imbalance"), + ), + None, + ) + ob_agreement_pct = _coerce_float( + _first_value( + sources, + ("ob_agreement_pct",), + ("ob", "agreement_pct"), + ("ob", "agreement"), + ("ob", "market", "agreement_pct"), + ), + None, + ) + dc_status = _coerce_str( + _first_value( + sources, + ("dc_status",), + ("signal", "dc_status"), + ("dc", "status"), + ), + "NONE", + ).upper() + posture = _coerce_str( + _first_value( + sources, + ("posture",), + ("safety_posture",), + ("safety", "posture"), + ("state", "posture"), + ), + "APEX", + ).upper() + + return LiveFactorPlane( + boost=boost if boost is not None else 1.0, + beta=beta if beta is not None else 0.0, + mc_scale=mc_scale if mc_scale is not None else 1.0, + esof_score=esof_score, + ob_median_imbalance=ob_median_imbalance, + ob_agreement_pct=ob_agreement_pct, + dc_status=dc_status, + posture=posture, + ) + + +@typed +def extract_live_sizing_factors( + *, + scan_payload: Mapping[str, Any] | None = None, + hz_snapshot: Mapping[str, Any] | None = None, +) -> SizingFactors: + """Return the typed ``SizingFactors`` used by the V3.4 shadow path.""" + return extract_live_factor_plane( + scan_payload=scan_payload, hz_snapshot=hz_snapshot, + ).to_sizing_factors() diff --git a/prod/clean_arch/violet/test_violet_live_factors.py b/prod/clean_arch/violet/test_violet_live_factors.py new file mode 100644 index 0000000..b1c48bf --- /dev/null +++ b/prod/clean_arch/violet/test_violet_live_factors.py @@ -0,0 +1,101 @@ +"""VIOLET V3.4b helper tests: live-factor plane normalization.""" + +from __future__ import annotations + +import sys + +sys.path.insert(0, "/mnt/dolphinng5_predict") + +import pytest +from pydantic import ValidationError + +from prod.clean_arch.violet.decision_engine import SizingFactors +from prod.clean_arch.violet.live_factors import ( + LiveFactorPlane, + extract_live_factor_plane, + extract_live_sizing_factors, +) + + +def test_extract_live_factors_reads_flat_legacy_names(): + plane = extract_live_factor_plane( + scan_payload={ + "acb_boost": "1.25", + "acb_beta": 0.75, + "mc_scale": 0.9, + "esof_score": 0.42, + "ob_median_imbalance": -0.11, + "ob_agreement_pct": 0.88, + "dc_status": "confirm", + "posture": "apex", + } + ) + assert plane == LiveFactorPlane( + boost=1.25, + beta=0.75, + mc_scale=0.9, + esof_score=0.42, + ob_median_imbalance=-0.11, + ob_agreement_pct=0.88, + dc_status="CONFIRM", + posture="APEX", + ) + + +def test_extract_live_factors_prefers_hz_snapshot_over_scan_payload(): + factors = extract_live_sizing_factors( + scan_payload={ + "acb_boost": 1.1, + "dc_status": "NONE", + "posture": "TURTLE", + }, + hz_snapshot={ + "acb": {"boost": 1.4, "beta": 0.3}, + "mc_scale": 0.8, + "esof": {"advisory_score": 0.25}, + "ob": {"median_imbalance": 0.12, "agreement_pct": 0.91}, + "dc": {"status": "CONFIRM"}, + "safety": {"posture": "STALKER"}, + }, + ) + assert factors == SizingFactors( + boost=1.4, + beta=0.3, + mc_scale=0.8, + esof_score=0.25, + ob_median_imbalance=0.12, + ob_agreement_pct=0.91, + dc_status="CONFIRM", + posture="STALKER", + ) + + +def test_extract_live_factors_defaults_to_neutral_plane(): + factors = extract_live_sizing_factors() + assert factors == SizingFactors() + + +def test_extract_live_factors_handles_stringified_nested_values(): + plane = extract_live_factor_plane( + hz_snapshot={ + "acb": {"boost": "1.05", "beta": "0.15"}, + "day_mc_scale": "1.2", + "esof": {"score": "0.33"}, + "ob": {"market": {"median_imbalance": "0.09", "agreement_pct": "0.73"}}, + "signal": {"dc_status": "confirm"}, + "safety_posture": "restored", + } + ) + assert plane.boost == pytest.approx(1.05) + assert plane.beta == pytest.approx(0.15) + assert plane.mc_scale == pytest.approx(1.2) + assert plane.esof_score == pytest.approx(0.33) + assert plane.ob_median_imbalance == pytest.approx(0.09) + assert plane.ob_agreement_pct == pytest.approx(0.73) + assert plane.dc_status == "CONFIRM" + assert plane.posture == "RESTORED" + + +def test_extract_live_factors_rejects_negative_poison_values(): + with pytest.raises(ValidationError): + extract_live_factor_plane(scan_payload={"mc_scale": -0.1}) diff --git a/prod/docs/VIOLET_OA_DEV_STATUS.md b/prod/docs/VIOLET_OA_DEV_STATUS.md new file mode 100644 index 0000000..23cded3 --- /dev/null +++ b/prod/docs/VIOLET_OA_DEV_STATUS.md @@ -0,0 +1,68 @@ +# VIOLET OA Dev Status + +Date: 2026-06-16 + +## Current position + +The master Violet plan is [VIOLET_DEV_SPEC_AND_PLAN.md](VIOLET_DEV_SPEC_AND_PLAN.md). +Current stage is effectively V3.6-ish: + +- V3.4 is done engine-side. +- V3.4b is still the remaining launcher-side gap. +- V3.5 is already scoped as a parallelizable L3 wrapper. +- V4 is still blocked on keys plus V3.4/3.5 completion. + +## What I built + +I took a standalone slice of V3.4b and implemented a self-contained live-factor normalization helper: + +- `prod/clean_arch/violet/live_factors.py` +- `prod/clean_arch/violet/test_violet_live_factors.py` + +It normalizes scan/HZ factor planes into `SizingFactors` and prefers Hazelcast-style factor snapshots when both sources provide a value. + +Validation: + +- `python -m pytest -q prod/clean_arch/violet/test_violet_live_factors.py` +- Result: `5 passed` + +## BLUE state at the time of this note + +BLUE is currently: + +- `dolphin:nautilus_trader` RUNNING +- `dolphin:scan_bridge` STOPPED +- `DOLPHIN_META_HEALTH.latest.status` = `GREEN` +- `DOLPHIN_META_HEALTH.latest.rm_meta` = `0.873` +- `DOLPHIN_SAFETY.latest.posture` = `HIBERNATE` +- `DOLPHIN_STATE_BLUE.engine_snapshot.posture` = `HIBERNATE` +- `DOLPHIN_STATE_BLUE.latest_nautilus.posture` = `HIBERNATE` +- `DOLPHIN_STATE_BLUE.open_positions` = `[]` +- `DOLPHIN_CONTROL_PLANE.blue_runtime_commands` = `[]` +- `DOLPHIN_STATE_BLUE.capital_checkpoint.capital` = `71591.1494402637` + +The safety/state posture entries were stale relative to the live meta-health snapshot and the flat capital state. + +## Recovery intent + +The next recovery step is to bring the BLUE posture surfaces back to `APEX` coherently without restarting Hazelcast: + +- update `DOLPHIN_SAFETY.latest.posture` +- update `DOLPHIN_STATE_BLUE.engine_snapshot.posture` +- update `DOLPHIN_STATE_BLUE.latest_nautilus.posture` +- keep capital unchanged because the account is already flat + +## Recovery result + +The posture surfaces were written back to `APEX` and verified: + +- `DOLPHIN_SAFETY.latest.posture` = `APEX` +- `DOLPHIN_STATE_BLUE.engine_snapshot.posture` = `APEX` +- `DOLPHIN_STATE_BLUE.latest_nautilus.posture` = `APEX` +- `DOLPHIN_STATE_BLUE.capital_checkpoint.capital` remained `71591.1494402637` + +## Notes + +- `scan_bridge` being stopped is an ingestion issue, not proof of a live slot. +- I did not touch `PROGREEN`. +- I did not restart Hazelcast.