From 722fd9f054d6b6ea481ee89bd3cd63232b012573 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 16 Jun 2026 13:04:30 +0200 Subject: [PATCH] VIOLET V3.4b/V3e: journal the full-sizing breakdown (DDL + row guard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShadowDecision already carries the V3.4 5-factor breakdown (base_leverage, dc_lev_mult, regime_size_mult, market_ob_mult, esof_size_mult), but the journal row and CH DDL dropped it — so a DARK soak would record decisions WITHOUT the factor decomposition that V3.4 exists to expose. Thread it through: - 22_violet_decisions.sql: 5 additive Nullable(Float64) breakdown columns. NULL on the legacy base-only path; populated once the launcher feeds live factor planes. Note added: on a pre-existing table use ALTER ... ADD COLUMN instead of the CREATE IF NOT EXISTS (no live table yet — VIOLET is DARK, never soaked). - shadow_journal.py: DecisionRow gains the 5 Optional[float] fields (ge=0.0, finite-guarded); journal() populates them via getattr(..., None) so the base-only path and duck-typed reject tests stay NULL/rejected rather than raising on attribute access. - test_violet_shadow_journal.py: breakdown round-trips on the full path; NULL on base-only; a negative multiplier is rejected at the row guard. The existing DecisionRow-fields == DDL-columns parity test still holds with the new columns. violet-only; no shared-file edits; no soak. 29 violet tests green (7 journal + 22 engine/DDL-apply). Co-Authored-By: Claude Opus 4.8 --- prod/clean_arch/violet/shadow_journal.py | 15 +++++++ .../violet/test_violet_shadow_journal.py | 44 +++++++++++++++++++ .../clickhouse/violet/22_violet_decisions.sql | 11 ++++- 3 files changed, 69 insertions(+), 1 deletion(-) 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)