VIOLET V3.4b/V3e: journal the full-sizing breakdown (DDL + row guard)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user