diff --git a/prod/clean_arch/violet/decision_engine.py b/prod/clean_arch/violet/decision_engine.py index 441a3a9..d2ee202 100644 --- a/prod/clean_arch/violet/decision_engine.py +++ b/prod/clean_arch/violet/decision_engine.py @@ -29,6 +29,7 @@ from pydantic import Field from .alpha_wrappers import AssetPick, SizeDecision, VioletAssetSelector, VioletBetSizer from .cadence import Action, CadenceControlPlane from .domain import StrictModel, Symbol, typed +from .sizing import VioletSizer # Stablecoins / pegged assets that must NEVER be selected as a trade asset. @@ -42,6 +43,27 @@ STABLECOIN_SYMBOLS = frozenset({ }) +class SizingFactors(StrictModel): + """Live factor inputs for BLUE's full 5-multiplier sizing (V3.4). + + Supplied by the caller (launcher) from the live planes: ACB day-state + (``boost``/``beta`` via AdaptiveCircuitBreaker), MC-Forewarner (``mc_scale``), + EsoF advisory (``esof_score``), OB consensus (``ob_*`` via OBFeatureEngine), the + DC signal (``dc_status``), and the day ``posture``. When ``decide()`` is given + these, it produces BLUE-complete conviction via ``VioletSizer``; when omitted, + ``decide()`` uses the V3a base-only sizer (legacy/no-factor path). Defaults are + BLUE's own neutral sentinels (NOT ours) — only finite/non-negative poison guards.""" + + 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: Optional[float] = Field(default=None, allow_inf_nan=False) + ob_median_imbalance: Optional[float] = Field(default=None, allow_inf_nan=False) + ob_agreement_pct: Optional[float] = Field(default=None, allow_inf_nan=False) + dc_status: str = "NONE" + posture: str = "APEX" + + class ShadowDecision(StrictModel): """One muted decision — what BLUE *would* do this scan. Never executed.""" @@ -57,6 +79,14 @@ class ShadowDecision(StrictModel): ars_score: float = Field(allow_inf_nan=False) bucket_idx: int = Field(ge=0, le=3) actuated: bool + # full-sizing breakdown (V3.4) — populated only when SizingFactors are supplied; + # None for the legacy base-only path. conviction_leverage above is then the FULL + # BLUE conviction (base × dc × regime × ob × esof, capped); these expose the factors. + base_leverage: Optional[float] = Field(default=None, ge=0.0, allow_inf_nan=False) + dc_lev_mult: Optional[float] = Field(default=None, ge=0.0, allow_inf_nan=False) + regime_size_mult: Optional[float] = Field(default=None, ge=0.0, allow_inf_nan=False) + market_ob_mult: Optional[float] = Field(default=None, ge=0.0, allow_inf_nan=False) + esof_size_mult: Optional[float] = Field(default=None, ge=0.0, allow_inf_nan=False) class VioletDecisionEngine: @@ -84,6 +114,14 @@ class VioletDecisionEngine: base_fraction=base_fraction, min_leverage=min_leverage, max_leverage=max_leverage, vel_div_threshold=entry_vel_div_threshold, ) + # V3.4 full 5-factor sizer: base_max=8 (soft) + dc/regime(ACB)/ob/esof mults + # lifting toward abs_max. Used when decide() is given live SizingFactors; + # bit-identical to BLUE's esf_alpha_orchestrator composition (see sizing.py). + self.full_sizer = VioletSizer( + base_fraction=base_fraction, min_leverage=min_leverage, + base_max_leverage=8.0, abs_max_leverage=max_leverage, + vel_div_threshold=entry_vel_div_threshold, + ) self.entry_threshold = float(entry_vel_div_threshold) self.regime_direction = int(regime_direction) self.lookback = int(lookback) if lookback > 0 else self.selector.lookback @@ -138,6 +176,7 @@ class VioletDecisionEngine: def decide( self, *, now_ns: int, scan_number: int, capital: float, vel_div: float, vol_ok: bool = True, + factors: Optional[SizingFactors] = None, ) -> Optional[ShadowDecision]: """Evaluate the would-be decision (always); actuate only when ENTRY cadence is due. Returns the ShadowDecision when a short signal fires, else None. @@ -162,9 +201,30 @@ class VioletDecisionEngine: self._last_entry_actuation_ns = int(now_ns) self.actuations += 1 - size: SizeDecision = self.sizer.calculate( - capital=capital, vel_div=vel_div, trade_direction=self.regime_direction, - ) + if factors is None: + # Legacy base-only path (V3a sizer) — unchanged behavior. + size: SizeDecision = self.sizer.calculate( + capital=capital, vel_div=vel_div, trade_direction=self.regime_direction, + ) + extra: Dict[str, float] = {} + else: + # V3.4 full BLUE sizing: base × dc × regime(ACB) × ob × esof, capped @9. + full = self.full_sizer.size( + capital=capital, vel_div=vel_div, + boost=factors.boost, beta=factors.beta, mc_scale=factors.mc_scale, + esof_score=factors.esof_score, + ob_median_imbalance=factors.ob_median_imbalance, + ob_agreement_pct=factors.ob_agreement_pct, + dc_status=factors.dc_status, posture=factors.posture, + trade_direction=self.regime_direction, + ) + size = full.decision + b = full.breakdown + extra = dict( + base_leverage=b.base_leverage, dc_lev_mult=b.dc_lev_mult, + regime_size_mult=b.regime_size_mult, market_ob_mult=b.market_ob_mult, + esof_size_mult=b.esof_size_mult, + ) return ShadowDecision( ts_ns=int(now_ns), scan_number=int(scan_number), asset=pick.asset, side=pick.side, vel_div=float(vel_div), @@ -172,4 +232,5 @@ class VioletDecisionEngine: notional_fraction=size.notional_fraction, target_exposure=float(capital) * size.notional_fraction, ars_score=pick.ars_score, bucket_idx=size.bucket_idx, actuated=True, + **extra, ) diff --git a/prod/clean_arch/violet/test_violet_decision_engine.py b/prod/clean_arch/violet/test_violet_decision_engine.py index 5bd0240..1eb2c00 100644 --- a/prod/clean_arch/violet/test_violet_decision_engine.py +++ b/prod/clean_arch/violet/test_violet_decision_engine.py @@ -13,8 +13,9 @@ from pathlib import Path from prod.clean_arch.violet.cadence import Action, CadenceControlPlane, INSTA_Q_NS, SCAN_Q_NS from prod.clean_arch.violet.decision_engine import ( - STABLECOIN_SYMBOLS, ShadowDecision, VioletDecisionEngine, + STABLECOIN_SYMBOLS, ShadowDecision, SizingFactors, VioletDecisionEngine, ) +from prod.clean_arch.violet.sizing import VioletSizer LOOKBACK = 5 @@ -140,3 +141,74 @@ def test_determinism_same_inputs_same_decision(): assert (d1 is None) == (d2 is None) if d1 is not None: assert d1.model_dump() == d2.model_dump() + + +# ── V3.4: full 5-factor sizing path (SizingFactors → VioletSizer) ────────────── + +def _full_factors(**kw): + base = dict(boost=1.3, beta=0.8, mc_scale=1.0, esof_score=0.3, + ob_median_imbalance=0.5, ob_agreement_pct=0.90, + dc_status="NONE", posture="APEX") + base.update(kw) + return SizingFactors(**base) + + +def test_sizing_factors_neutral_defaults(): + f = SizingFactors() + assert f.boost == 1.0 and f.beta == 0.0 and f.mc_scale == 1.0 + assert f.esof_score is None and f.dc_status == "NONE" and f.posture == "APEX" + + +def test_base_path_leaves_breakdown_none(): + e = _engine(); _warm(e) + d = e.decide(now_ns=10**12, scan_number=99, capital=69_000.0, vel_div=-0.20) + if d is not None: + assert d.regime_size_mult is None and d.market_ob_mult is None + assert d.base_leverage is None and d.dc_lev_mult is None and d.esof_size_mult is None + + +def test_full_path_populates_breakdown_and_caps(): + e = _engine(); _warm(e) + d = e.decide(now_ns=10**12, scan_number=99, capital=69_000.0, vel_div=-0.20, + factors=_full_factors()) + if d is not None: + for v in (d.base_leverage, d.dc_lev_mult, d.regime_size_mult, + d.market_ob_mult, d.esof_size_mult): + assert v is not None + assert d.base_leverage <= 8.0 + 1e-9 # VioletSizer base_max=8 + assert 0.0 <= d.conviction_leverage <= 9.0 + 1e-9 # capped @ abs_max + + +def test_full_conviction_matches_violet_sizer_directly(): + # engine's full conviction == VioletSizer.size() on the same inputs (consistency). + e = _engine(); _warm(e) + f = _full_factors() + d = e.decide(now_ns=10**12, scan_number=99, capital=69_000.0, vel_div=-0.20, factors=f) + if d is not None: + vs = VioletSizer(base_fraction=0.20, min_leverage=0.5, base_max_leverage=8.0, + abs_max_leverage=9.0, vel_div_threshold=-0.02) + direct = vs.size(capital=69_000.0, vel_div=-0.20, boost=f.boost, beta=f.beta, + mc_scale=f.mc_scale, esof_score=f.esof_score, + ob_median_imbalance=f.ob_median_imbalance, + ob_agreement_pct=f.ob_agreement_pct, dc_status=f.dc_status, + posture=f.posture, trade_direction=-1) + assert d.conviction_leverage == direct.decision.conviction_leverage + + +def test_stalker_posture_caps_full_conviction_at_2(): + e = _engine(); _warm(e) + d = e.decide(now_ns=10**12, scan_number=99, capital=69_000.0, vel_div=-0.20, + factors=_full_factors(posture="STALKER")) + if d is not None: + assert d.conviction_leverage <= 2.0 + 1e-9 + + +def test_full_path_esof_stale_haircuts_below_base(): + # esof_score=None -> stale fallback (<1) -> conviction at/below base (min-floored). + e = _engine(); _warm(e) + d = e.decide(now_ns=10**12, scan_number=99, capital=69_000.0, vel_div=-0.025, + factors=_full_factors(esof_score=None, boost=1.0, beta=0.0, + ob_median_imbalance=None, ob_agreement_pct=None)) + if d is not None: + assert d.esof_size_mult < 1.0 + assert d.conviction_leverage <= d.base_leverage + 1e-9