Files
siloqy/prod/clean_arch/violet/decision_engine.py
Codex a97bb90bf6 VIOLET V3.4: integrate full 5-factor VioletSizer into VioletDecisionEngine
Additive (non-breaking): decide(factors=None) keeps the V3a base-only path (existing
11 tests unchanged); decide(factors=SizingFactors(...)) produces BLUE-complete
conviction via VioletSizer (base_max=8 + dc/regime(ACB)/ob/esof, capped@9) with the
full factor breakdown on ShadowDecision (base_leverage/dc_lev_mult/regime_size_mult/
market_ob_mult/esof_size_mult, None on the base path). SizingFactors value object =
the live-plane inputs the launcher will source (V3.4b). 6 new tests incl. consistency
vs VioletSizer, STALKER cap, EsoF-stale haircut. 17 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:35:00 +02:00

237 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""VIOLET V3c: VioletDecisionEngine — reactor-resident SHADOW decision engine.
Composes the L1 alpha wrappers (V3a, live-kernel-faithful) with the Cadence Control
Plane (V3b) into a reactor-resident engine that, on each scan, produces a shadow
``ShadowDecision`` — and NOTHING ELSE. No venue, no intent, no execution: the decision
brain is online but MUTED (the V3 mandate). Execution stays off in the separate,
disabled ``PinkDirectRuntime`` path behind the ObserveOnlyVenue guard.
This is a NEW engine, distinct from ``prod.clean_arch.dita.decision.DecisionEngine``
(PINK's, built on the untrusted ``blue_parity`` distillation and disabled in V1). V3
models LIVE BLUE via the parity-validated wrappers, runs as a pure shadow alongside,
and feeds the V3d parity harness.
Decision contract mirrors BLUE/dita gating (short-regime): a decision fires only when
``vel_div < entry_threshold`` AND ``vol_ok`` AND the IRP selector returns a survivor;
sizing is the cubic-convex conviction leverage × alpha fraction. ``target_exposure =
capital × fraction × conviction_leverage`` (the conviction side of the dual-leverage —
exchange leverage is L3, never here). Actuation is gated by the cadence knobs for
ENTRY/SIZING (Q=scan by default); evaluation happens every call (shadow-logged).
"""
from __future__ import annotations
from collections import deque
from typing import Deque, Dict, List, Optional
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.
# MUST equal nautilus_event_trader.py:_STABLECOIN_SYMBOLS (BLUE removes these from
# prices_dict before selection, :3906) — asserted by test_violet_decision_engine.
# Following BLUE in all regards: picking stays muted-IRP as BLUE, this is BLUE's
# separate exclusion gate around it.
STABLECOIN_SYMBOLS = frozenset({
"USDCUSDT", "BUSDUSDT", "FDUSDUSDT", "USDTUSDT", "TUSDUSDT",
"DAIUSDT", "FRAXUSDT", "USDDUSDT", "USTCUSDT", "EURUSDT",
})
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."""
ts_ns: int = Field(ge=0)
scan_number: int = Field(ge=0)
asset: Symbol
side: str
vel_div: float = Field(allow_inf_nan=False)
fraction: float = Field(ge=0.0, allow_inf_nan=False)
conviction_leverage: float = Field(ge=0.0, allow_inf_nan=False)
notional_fraction: float = Field(ge=0.0, allow_inf_nan=False)
target_exposure: float = Field(ge=0.0, allow_inf_nan=False)
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:
"""Reactor-resident shadow engine: scans in, ShadowDecisions out (no execution).
``max_leverage`` (and the other sizer knobs) are EXPLICIT — pinned from live BLUE
by the parity harness, never a drifted default (V3a doctrine).
"""
def __init__(
self,
*,
control_plane: Optional[CadenceControlPlane] = None,
lookback: int = 0,
min_alignment: float = 0.0,
base_fraction: float = 0.20,
min_leverage: float = 0.5,
max_leverage: float = 9.0,
entry_vel_div_threshold: float = -0.02,
regime_direction: int = -1,
):
self.cp = control_plane or CadenceControlPlane()
self.selector = VioletAssetSelector(lookback_horizon=lookback, min_alignment=min_alignment)
self.sizer = VioletBetSizer(
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
# per-asset rolling price history (scan-accumulated; bookkeeping, not alpha)
self._history: Dict[str, Deque[float]] = {}
self._last_scan_number = -1
self._last_entry_actuation_ns: Optional[int] = None
# shadow-delta telemetry (evaluate vs actuate)
self.evaluations = 0
self.actuations = 0
self.suppressed_by_cadence = 0
# ── scan accumulation (mirrors PinkAssetPicker.observe bookkeeping) ──────────
def observe(self, scan_payload: Optional[dict], scan_number: int) -> bool:
"""Append one bar per NEW scan. Dedupe on scan_number (poll > scan rate)."""
payload = scan_payload or {}
sn = int(scan_number or 0)
if sn <= self._last_scan_number:
return False
assets = payload.get("assets") or []
prices = payload.get("asset_prices") or []
if not (isinstance(assets, list) and isinstance(prices, list) and assets):
return False
maxlen = self.lookback + 1
for asset, price in zip(assets, prices):
try:
px = float(price)
except (TypeError, ValueError):
continue
if px <= 0:
continue
sym = str(asset).upper()
if sym in STABLECOIN_SYMBOLS: # BLUE :3906 — never a trade asset
continue
hist = self._history.get(sym)
if hist is None:
hist = deque(maxlen=maxlen)
self._history[sym] = hist
hist.append(px)
self._last_scan_number = sn
return True
def _market_data(self) -> Dict[str, List[float]]:
need = self.lookback + 1
return {s: list(h) for s, h in self._history.items() if len(h) >= need}
# ── the muted decision ──────────────────────────────────────────────────────
@typed
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.
``vel_div`` is the chosen-asset velocity-divergence signal (the entry gate);
in V3e wiring it is extracted from the scan, as dita's ``fields.vdiv`` is.
"""
self.evaluations += 1
# BLUE/dita short-regime gate: no signal unless vel_div below threshold + vol_ok.
if vel_div >= self.entry_threshold or not vol_ok:
return None
pick: Optional[AssetPick] = self.selector.pick(self._market_data(), self.regime_direction)
if pick is None:
return None
# cadence: evaluate-always, actuate-at-Q (ENTRY knob).
actuate = self.cp.due(Action.ENTRY, now_ns, self._last_entry_actuation_ns)
if not actuate:
self.suppressed_by_cadence += 1
return None
self._last_entry_actuation_ns = int(now_ns)
self.actuations += 1
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),
fraction=size.fraction, conviction_leverage=size.conviction_leverage,
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,
)