VIOLET V3.5: L3 exchange-leverage wrapper (agent-built, reviewed + fixed)
Built by OA agent per VIOLET_SUB_SPEC__L3_EXCHANGE_LEVERAGE.md; reviewed for compliance/flaws. VERIFIED: wraps real prod/bingx/leverage.py (untouched), constants imported from it, NO arbitrary upper caps, exact == bit-identity (1e6 gate 0 mismatches), ROUND_HALF_EVEN explicitly tested (1.5->2 AND 2.5->2), clamping+non-default caps+frozen model. 38 tests pass on independent rerun. REVIEW FIX: to_exchange clamped negative internal_conviction to 0 in the trace field (reused ConvictionLeverage ge=0); changed trace field to plain float (poison guard only) so it records the ACTUAL input faithfully; dropped the clamp + unused import. Relocated 8 off-spec leverage-spike scratch files off repo root -> prod/VIOLET_dev/l3_spike/. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
101
prod/clean_arch/violet/exchange_leverage.py
Normal file
101
prod/clean_arch/violet/exchange_leverage.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""VIOLET L3 exchange leverage wrapper.
|
||||
|
||||
Dual-leverage doctrine:
|
||||
- internal conviction leverage sizes quantity
|
||||
- exchange leverage is derived at the venue boundary
|
||||
|
||||
This module is a typed wrapper around the authoritative BingX mapping in
|
||||
``prod/bingx/leverage.py``. It must stay bit-identical to the production
|
||||
functions and exists so V4 can consume the derived exchange leverage with a
|
||||
traceable boundary model.
|
||||
|
||||
References:
|
||||
- ``prod/docs/FRACTIONAL_LEVERAGE_TO_BINGX_FIX.md``
|
||||
- ``prod/docs/VIOLET_V3_FINDINGS.md`` §2
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from .domain import StrictModel, typed
|
||||
|
||||
from prod.bingx import leverage as bingx_leverage
|
||||
|
||||
CONVICTION_MIN = bingx_leverage.CONVICTION_MIN
|
||||
CONVICTION_MAX = bingx_leverage.CONVICTION_MAX
|
||||
EXCHANGE_LEV_MIN = bingx_leverage.EXCHANGE_LEV_MIN
|
||||
EXCHANGE_LEV_MAX = bingx_leverage.EXCHANGE_LEV_MAX
|
||||
LEVERAGE_MAPPING_RULE = bingx_leverage.LEVERAGE_MAPPING_RULE
|
||||
|
||||
ExchangeLeverage = Annotated[int, Field(ge=1)]
|
||||
|
||||
__all__ = [
|
||||
"CONVICTION_MIN",
|
||||
"CONVICTION_MAX",
|
||||
"EXCHANGE_LEV_MIN",
|
||||
"EXCHANGE_LEV_MAX",
|
||||
"LEVERAGE_MAPPING_RULE",
|
||||
"ExchangeLeverage",
|
||||
"ExchangeLeverageDecision",
|
||||
"VioletExchangeLeverage",
|
||||
]
|
||||
|
||||
|
||||
class ExchangeLeverageDecision(StrictModel):
|
||||
"""Traceable exchange-leverage mapping decision."""
|
||||
|
||||
# Plain float (allow_inf_nan poison guard only) so the trace records the ACTUAL
|
||||
# input faithfully — incl. out-of-domain negatives (which leverage.py clamps
|
||||
# internally) rather than masking them by clamping the trace to 0 (review fix).
|
||||
internal_conviction: float = Field(allow_inf_nan=False)
|
||||
target_exchange_leverage: float = Field(allow_inf_nan=False)
|
||||
exchange_leverage: ExchangeLeverage
|
||||
exchange_min: int
|
||||
exchange_max: int
|
||||
|
||||
|
||||
class VioletExchangeLeverage:
|
||||
"""Typed wrapper around ``prod.bingx.leverage``."""
|
||||
|
||||
def __init__(self, *, exchange_min: int = EXCHANGE_LEV_MIN, exchange_max: int = EXCHANGE_LEV_MAX):
|
||||
self.exchange_min = int(exchange_min)
|
||||
self.exchange_max = int(exchange_max)
|
||||
self._mod = self._import_leverage()
|
||||
|
||||
def _import_leverage(self) -> Any:
|
||||
return bingx_leverage
|
||||
|
||||
@typed
|
||||
def map_target(self, internal_conviction: float) -> float:
|
||||
return self._mod.map_internal_conviction_to_exchange_leverage_target(
|
||||
internal_conviction,
|
||||
exchange_min=self.exchange_min,
|
||||
exchange_max=self.exchange_max,
|
||||
)
|
||||
|
||||
@typed
|
||||
def normalize(self, leverage: float) -> int:
|
||||
return self._mod.normalize_bingx_leverage_value(
|
||||
leverage,
|
||||
exchange_min=self.exchange_min,
|
||||
exchange_max=self.exchange_max,
|
||||
)
|
||||
|
||||
@typed
|
||||
def to_exchange(self, internal_conviction: float) -> ExchangeLeverageDecision:
|
||||
target = self.map_target(internal_conviction)
|
||||
exchange = self._mod.map_internal_conviction_to_exchange_leverage(
|
||||
internal_conviction,
|
||||
exchange_min=self.exchange_min,
|
||||
exchange_max=self.exchange_max,
|
||||
)
|
||||
return ExchangeLeverageDecision(
|
||||
internal_conviction=float(internal_conviction),
|
||||
target_exchange_leverage=target,
|
||||
exchange_leverage=exchange,
|
||||
exchange_min=self.exchange_min,
|
||||
exchange_max=self.exchange_max,
|
||||
)
|
||||
185
prod/clean_arch/violet/test_violet_exchange_leverage.py
Normal file
185
prod/clean_arch/violet/test_violet_exchange_leverage.py
Normal file
@@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from hypothesis import given, settings, strategies as st
|
||||
from pydantic import ValidationError
|
||||
|
||||
from prod.clean_arch.violet.exchange_leverage import (
|
||||
CONVICTION_MAX,
|
||||
CONVICTION_MIN,
|
||||
EXCHANGE_LEV_MAX,
|
||||
EXCHANGE_LEV_MIN,
|
||||
ExchangeLeverageDecision,
|
||||
VioletExchangeLeverage,
|
||||
)
|
||||
from prod.bingx import leverage as bingx_leverage
|
||||
|
||||
REPORTS_DIR = Path("/mnt/dolphinng5_predict/prod/VIOLET_dev/reports")
|
||||
|
||||
|
||||
def _write_gate_report(name: str, **fields):
|
||||
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
payload = {
|
||||
"generated_utc": datetime.now(timezone.utc).isoformat(),
|
||||
"layer": f"violet_v3_{name}",
|
||||
**fields,
|
||||
}
|
||||
path = REPORTS_DIR / f"violet_v3_{name}_{ts}.json"
|
||||
path.write_text(json.dumps(payload, indent=2, default=str))
|
||||
return path
|
||||
|
||||
|
||||
def _wrapper(exchange_min: int = EXCHANGE_LEV_MIN, exchange_max: int = EXCHANGE_LEV_MAX) -> VioletExchangeLeverage:
|
||||
return VioletExchangeLeverage(exchange_min=exchange_min, exchange_max=exchange_max)
|
||||
|
||||
|
||||
def test_defaults_and_constants_match_blue_module():
|
||||
w = _wrapper()
|
||||
assert w.exchange_min == 1
|
||||
assert w.exchange_max == 3
|
||||
assert CONVICTION_MIN == bingx_leverage.CONVICTION_MIN
|
||||
assert CONVICTION_MAX == bingx_leverage.CONVICTION_MAX
|
||||
assert EXCHANGE_LEV_MIN == bingx_leverage.EXCHANGE_LEV_MIN
|
||||
assert EXCHANGE_LEV_MAX == bingx_leverage.EXCHANGE_LEV_MAX
|
||||
|
||||
|
||||
def test_endpoints_map_cleanly():
|
||||
w = _wrapper()
|
||||
assert w.map_target(0.5) == 1.0
|
||||
assert w.map_target(9.0) == 3.0
|
||||
assert w.normalize(1.0) == 1
|
||||
assert w.normalize(3.0) == 3
|
||||
|
||||
|
||||
def test_round_half_even_boundary_cases():
|
||||
w = _wrapper()
|
||||
low = 0.5 + ((1.5 - 1.0) / (3.0 - 1.0)) * (9.0 - 0.5)
|
||||
high = 0.5 + ((2.5 - 1.0) / (3.0 - 1.0)) * (9.0 - 0.5)
|
||||
assert w.map_target(low) == pytest.approx(1.5)
|
||||
assert w.map_target(high) == pytest.approx(2.5)
|
||||
assert w.normalize(w.map_target(low)) == 2
|
||||
assert w.normalize(w.map_target(high)) == 2
|
||||
assert w.to_exchange(low).exchange_leverage == 2
|
||||
assert w.to_exchange(high).exchange_leverage == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("conviction", [-10.0, -1.0, 0.0, 0.49, 9.1, 64.0])
|
||||
@pytest.mark.parametrize("exchange_max", [1, 2, 3, 5, 9])
|
||||
def test_out_of_range_clamps_like_blue(conviction, exchange_max):
|
||||
w = _wrapper(exchange_max=exchange_max)
|
||||
assert w.map_target(conviction) == bingx_leverage.map_internal_conviction_to_exchange_leverage_target(
|
||||
conviction,
|
||||
exchange_min=1,
|
||||
exchange_max=exchange_max,
|
||||
)
|
||||
assert w.normalize(conviction) == bingx_leverage.normalize_bingx_leverage_value(
|
||||
conviction,
|
||||
exchange_min=1,
|
||||
exchange_max=exchange_max,
|
||||
)
|
||||
assert w.to_exchange(conviction).exchange_leverage == bingx_leverage.map_internal_conviction_to_exchange_leverage(
|
||||
conviction,
|
||||
exchange_min=1,
|
||||
exchange_max=exchange_max,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exchange_max", [5, 9])
|
||||
def test_non_default_exchange_max_flows_through(exchange_max):
|
||||
w = _wrapper(exchange_max=exchange_max)
|
||||
conviction = 6.25
|
||||
decision = w.to_exchange(conviction)
|
||||
assert decision.exchange_min == 1
|
||||
assert decision.exchange_max == exchange_max
|
||||
assert decision.target_exchange_leverage == bingx_leverage.map_internal_conviction_to_exchange_leverage_target(
|
||||
conviction, exchange_min=1, exchange_max=exchange_max
|
||||
)
|
||||
assert decision.exchange_leverage == bingx_leverage.map_internal_conviction_to_exchange_leverage(
|
||||
conviction, exchange_min=1, exchange_max=exchange_max
|
||||
)
|
||||
|
||||
|
||||
def test_exchange_leverage_decision_is_frozen():
|
||||
d = ExchangeLeverageDecision(
|
||||
internal_conviction=1.5,
|
||||
target_exchange_leverage=1.25,
|
||||
exchange_leverage=1,
|
||||
exchange_min=1,
|
||||
exchange_max=3,
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
d.exchange_leverage = 2
|
||||
|
||||
|
||||
@given(
|
||||
conviction=st.floats(min_value=-5.0, max_value=64.0, allow_nan=False, allow_infinity=False),
|
||||
exchange_max=st.sampled_from([1, 2, 3, 5, 9]),
|
||||
)
|
||||
@settings(max_examples=200, deadline=None)
|
||||
def test_property_bit_identity(conviction, exchange_max):
|
||||
w = _wrapper(exchange_max=exchange_max)
|
||||
target = w.map_target(conviction)
|
||||
blue_target = bingx_leverage.map_internal_conviction_to_exchange_leverage_target(
|
||||
conviction,
|
||||
exchange_min=1,
|
||||
exchange_max=exchange_max,
|
||||
)
|
||||
assert target == blue_target
|
||||
final = w.to_exchange(conviction)
|
||||
blue_final = bingx_leverage.map_internal_conviction_to_exchange_leverage(
|
||||
conviction,
|
||||
exchange_min=1,
|
||||
exchange_max=exchange_max,
|
||||
)
|
||||
assert final.target_exchange_leverage == blue_target
|
||||
assert final.exchange_leverage == blue_final
|
||||
assert isinstance(final.exchange_leverage, int)
|
||||
assert 1 <= final.exchange_leverage <= exchange_max
|
||||
|
||||
|
||||
@pytest.mark.gate
|
||||
def test_gate_exchange_leverage_bit_identity():
|
||||
rng = np.random.default_rng(0)
|
||||
n = 1_000_000
|
||||
conviction = rng.uniform(-1.0, 64.0, n)
|
||||
exchange_max = rng.choice(np.array([1, 2, 3, 5, 9], dtype=np.int64), n)
|
||||
|
||||
violet = np.empty(n, dtype=np.int64)
|
||||
blue = np.empty(n, dtype=np.int64)
|
||||
violet_target = np.empty(n, dtype=np.float64)
|
||||
blue_target = np.empty(n, dtype=np.float64)
|
||||
|
||||
w_cache: dict[int, VioletExchangeLeverage] = {}
|
||||
for i in range(n):
|
||||
ex_max = int(exchange_max[i])
|
||||
w = w_cache.get(ex_max)
|
||||
if w is None:
|
||||
w = w_cache[ex_max] = _wrapper(exchange_max=ex_max)
|
||||
conv = float(conviction[i])
|
||||
violet_target[i] = w.map_target(conv)
|
||||
violet[i] = w.to_exchange(conv).exchange_leverage
|
||||
blue_target[i] = bingx_leverage.map_internal_conviction_to_exchange_leverage_target(
|
||||
conv, exchange_min=1, exchange_max=ex_max
|
||||
)
|
||||
blue[i] = bingx_leverage.map_internal_conviction_to_exchange_leverage(
|
||||
conv, exchange_min=1, exchange_max=ex_max
|
||||
)
|
||||
|
||||
target_mismatches = int(np.count_nonzero(violet_target != blue_target))
|
||||
final_mismatches = int(np.count_nonzero(violet != blue))
|
||||
total_mismatches = target_mismatches + final_mismatches
|
||||
_write_gate_report(
|
||||
"exchange_leverage",
|
||||
N=n,
|
||||
target_mismatches=target_mismatches,
|
||||
final_mismatches=final_mismatches,
|
||||
mismatches=total_mismatches,
|
||||
exchange_max_values=[1, 2, 3, 5, 9],
|
||||
)
|
||||
assert total_mismatches == 0
|
||||
Reference in New Issue
Block a user