diff --git a/prod/clean_arch/violet/shadow_journal.py b/prod/clean_arch/violet/shadow_journal.py index 87eeeff..5c21bff 100644 --- a/prod/clean_arch/violet/shadow_journal.py +++ b/prod/clean_arch/violet/shadow_journal.py @@ -40,6 +40,14 @@ class DecisionRow(StrictModel): ars_score: float = Field(allow_inf_nan=False) bucket_idx: int = Field(ge=0, le=255) # UInt8 actuated: int = Field(ge=0, le=1) + # V3.4 full-sizing breakdown (Nullable(Float64) in DDL): NULL on the base-only + # path, populated when live SizingFactors drive the decision. ge=0.0 — leverage + # and all four multipliers are non-negative by BLUE's construction. + 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 VioletDecisionJournal: @@ -70,6 +78,13 @@ class VioletDecisionJournal: ars_score=float(decision.ars_score), bucket_idx=int(decision.bucket_idx), actuated=1 if decision.actuated else 0, + # getattr-with-None: duck-typed decisions (and the base-only path) + # may omit the breakdown; it stays NULL rather than raising here. + base_leverage=getattr(decision, "base_leverage", None), + dc_lev_mult=getattr(decision, "dc_lev_mult", None), + regime_size_mult=getattr(decision, "regime_size_mult", None), + market_ob_mult=getattr(decision, "market_ob_mult", None), + esof_size_mult=getattr(decision, "esof_size_mult", None), ) except ValidationError as exc: self.rows_rejected += 1 diff --git a/prod/clean_arch/violet/test_violet_shadow_journal.py b/prod/clean_arch/violet/test_violet_shadow_journal.py index 3622002..86c1433 100644 --- a/prod/clean_arch/violet/test_violet_shadow_journal.py +++ b/prod/clean_arch/violet/test_violet_shadow_journal.py @@ -71,3 +71,47 @@ def test_negative_exposure_rejected(): ) assert j.journal(bad, mono_ns=1) is False assert j.rows_rejected == 1 + + +_BREAKDOWN_COLS = ( + "base_leverage", "dc_lev_mult", "regime_size_mult", + "market_ob_mult", "esof_size_mult", +) + + +def test_full_factor_breakdown_round_trips_into_row(): + captured = [] + j = VioletDecisionJournal(sink=lambda t, r: captured.append(r), session_id="s") + dec = _decision( + base_leverage=4.0, dc_lev_mult=1.5, regime_size_mult=1.2, + market_ob_mult=1.4, esof_size_mult=0.95, + ) + assert j.journal(dec, mono_ns=1) and j.rows_emitted == 1 + row = captured[0] + assert set(row.keys()) == _ddl_columns() # parity holds with new columns + assert row["base_leverage"] == 4.0 and row["dc_lev_mult"] == 1.5 + assert row["regime_size_mult"] == 1.2 and row["market_ob_mult"] == 1.4 + assert row["esof_size_mult"] == 0.95 + + +def test_base_only_path_leaves_breakdown_null(): + captured = [] + j = VioletDecisionJournal(sink=lambda t, r: captured.append(r), session_id="s") + assert j.journal(_decision(), mono_ns=1) # no breakdown supplied + row = captured[0] + assert all(row[c] is None for c in _BREAKDOWN_COLS) + + +def test_negative_breakdown_multiplier_rejected_at_source(): + j = VioletDecisionJournal(sink=lambda t, r: None, session_id="s") + # ShadowDecision itself guards ge=0.0, so build a duck-typed decision that + # smuggles a negative multiplier past the engine to prove the row guard catches it. + bad = SimpleNamespace( + scan_number=1, asset="BTCUSDT", side="SHORT", vel_div=-0.2, + fraction=0.2, conviction_leverage=9.0, notional_fraction=1.8, + target_exposure=1.0, ars_score=1.0, bucket_idx=1, actuated=True, + base_leverage=4.0, dc_lev_mult=-0.5, regime_size_mult=1.0, + market_ob_mult=1.0, esof_size_mult=1.0, + ) + assert j.journal(bad, mono_ns=1) is False + assert j.rows_rejected == 1 diff --git a/prod/clickhouse/violet/22_violet_decisions.sql b/prod/clickhouse/violet/22_violet_decisions.sql index e4a8307..dad94d8 100644 --- a/prod/clickhouse/violet/22_violet_decisions.sql +++ b/prod/clickhouse/violet/22_violet_decisions.sql @@ -19,7 +19,16 @@ CREATE TABLE IF NOT EXISTS dolphin_violet.violet_decisions `target_exposure` Float64, `ars_score` Float64, `bucket_idx` UInt8, - `actuated` UInt8 + `actuated` UInt8, + -- V3.4 full-sizing breakdown: conviction = base × dc × regime(ACB) × ob × esof, + -- capped @9. NULL on the legacy base-only path (no live SizingFactors); populated + -- when the launcher feeds live factor planes. Additive columns — on a pre-existing + -- table apply the matching `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` instead. + `base_leverage` Nullable(Float64), + `dc_lev_mult` Nullable(Float64), + `regime_size_mult` Nullable(Float64), + `market_ob_mult` Nullable(Float64), + `esof_size_mult` Nullable(Float64) ) ENGINE = MergeTree ORDER BY (asset, ts)