2026-06-13 18:51:27 +02:00
|
|
|
|
"""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
|
2026-06-15 23:35:00 +02:00
|
|
|
|
from .sizing import VioletSizer
|
2026-06-13 18:51:27 +02:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 21:51:09 +02:00
|
|
|
|
# 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",
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-15 23:35:00 +02:00
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-13 18:51:27 +02:00
|
|
|
|
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
|
2026-06-15 23:35:00 +02:00
|
|
|
|
# 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)
|
2026-06-13 18:51:27 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-06-15 23:35:00 +02:00
|
|
|
|
# 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,
|
|
|
|
|
|
)
|
2026-06-13 18:51:27 +02:00
|
|
|
|
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()
|
2026-06-14 21:51:09 +02:00
|
|
|
|
if sym in STABLECOIN_SYMBOLS: # BLUE :3906 — never a trade asset
|
|
|
|
|
|
continue
|
2026-06-13 18:51:27 +02:00
|
|
|
|
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,
|
2026-06-15 23:35:00 +02:00
|
|
|
|
factors: Optional[SizingFactors] = None,
|
2026-06-13 18:51:27 +02:00
|
|
|
|
) -> 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
|
|
|
|
|
|
|
2026-06-15 23:35:00 +02:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-06-13 18:51:27 +02:00
|
|
|
|
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,
|
2026-06-15 23:35:00 +02:00
|
|
|
|
**extra,
|
2026-06-13 18:51:27 +02:00
|
|
|
|
)
|