PINK DITAv2: Hz writes + vol_ok gate + leverage logging + 8 new tests (94/94 green)
This commit is contained in:
176
prod/clean_arch/dita_v2/hazelcast_projection.py
Normal file
176
prod/clean_arch/dita_v2/hazelcast_projection.py
Normal file
@@ -0,0 +1,176 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
from .contracts import KernelTransition, TradeSlot
|
||||
from .control import KernelControlSnapshot
|
||||
from .journal import _transition_row
|
||||
from .projection import build_position_state_row
|
||||
from .utils import json_safe
|
||||
|
||||
|
||||
# ── Fire-and-forget Hz write helpers ─────────────────────────────────────────
|
||||
|
||||
def _hz_write_no_wait(hz_map: Any, key: str, value: str) -> None:
|
||||
"""Submit Hz write to the client's internal thread pool. Never blocks.
|
||||
|
||||
.put() without .blocking() returns a hazelcast Future immediately.
|
||||
The Future is intentionally discarded — the network write is already
|
||||
queued in the Hz client's thread pool and is not cancelled by GC.
|
||||
Hz writes are observability-only; any failure must never affect trading.
|
||||
"""
|
||||
try:
|
||||
hz_map.put(key, value)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _json_encode(payload: dict) -> str:
|
||||
return json.dumps(payload, separators=(",", ":"), ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _utcnow_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _today_iso() -> str:
|
||||
return datetime.now(timezone.utc).date().isoformat()
|
||||
|
||||
|
||||
class HazelcastClientLike(Protocol):
|
||||
def get_map(self, name: str): ...
|
||||
def get_topic(self, name: str): ...
|
||||
|
||||
|
||||
class HazelcastProjector:
|
||||
"""Durable BLUE/PINK-compatible projection mirror."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: HazelcastClientLike | None = None,
|
||||
*,
|
||||
active_slots_map: str = "dita_active_slots",
|
||||
events_topic: str = "dita_trade_events",
|
||||
) -> None:
|
||||
self.client = client
|
||||
self.active_slots_map = active_slots_map
|
||||
self.events_topic = events_topic
|
||||
|
||||
def publish_slot(self, slot: TradeSlot) -> None:
|
||||
if self.client is None:
|
||||
return
|
||||
self.client.get_map(self.active_slots_map).put(slot.trade_id, build_position_state_row(slot))
|
||||
|
||||
def publish_event(self, event_type: str, payload: dict[str, Any]) -> None:
|
||||
if self.client is None:
|
||||
return
|
||||
topic = self.client.get_topic(self.events_topic)
|
||||
topic.publish(
|
||||
json.dumps(
|
||||
{"event_type": event_type, "payload": json_safe(payload)},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
default=str,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class HazelcastRowWriter:
|
||||
"""Callback bridge for ``HazelcastProjection`` writer hooks."""
|
||||
|
||||
def __init__(self, client: HazelcastClientLike) -> None:
|
||||
self.client = client
|
||||
|
||||
def __call__(self, name: str, row: dict[str, Any]) -> None:
|
||||
if name.endswith("trade_events"):
|
||||
self.client.get_topic(name).publish(
|
||||
json.dumps(row, ensure_ascii=False, sort_keys=True, default=str)
|
||||
)
|
||||
return
|
||||
if name.endswith("control"):
|
||||
key = "control"
|
||||
else:
|
||||
key = str(row.get("trade_id", row.get("slot_id", row.get("event_id", ""))))
|
||||
self.client.get_map(name).put(key, json_safe(row))
|
||||
|
||||
|
||||
# ── PINK DITAv2 non-blocking Hz state writer ──────────────────────────────────
|
||||
|
||||
class PinkHzStateWriter:
|
||||
"""Non-blocking Hz writer for PINK DITAv2 kernel state.
|
||||
|
||||
Dedicated Hz client (separate from the data-feed read client).
|
||||
All writes are fire-and-forget: .put() returns a Future that is intentionally
|
||||
discarded. A failed write = missed TUI update only — never affects trading.
|
||||
|
||||
BLUE-compatible schema (same shape as DOLPHIN_STATE_BLUE) written to
|
||||
DOLPHIN_STATE_PINK / DOLPHIN_PNL_PINK — no overlap with BLUE maps.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cluster: str,
|
||||
host: str,
|
||||
state_map_name: str,
|
||||
pnl_map_name: str,
|
||||
) -> None:
|
||||
import hazelcast
|
||||
self._client = hazelcast.HazelcastClient(
|
||||
cluster_name=cluster,
|
||||
cluster_members=[host],
|
||||
)
|
||||
# Non-blocking proxies (.put() returns Future, does NOT block)
|
||||
self._state_map = self._client.get_map(state_map_name)
|
||||
self._pnl_map = self._client.get_map(pnl_map_name)
|
||||
|
||||
def write_engine_snapshot(
|
||||
self,
|
||||
slot_dict: dict,
|
||||
acc_dict: dict,
|
||||
posture: str = "APEX",
|
||||
our_leverage: float = 0.0,
|
||||
) -> None:
|
||||
"""Write full engine state. Called after every kernel mutation (non-blocking)."""
|
||||
payload: dict[str, Any] = {
|
||||
"strategy": "pink",
|
||||
"capital": acc_dict.get("capital", 0.0),
|
||||
"equity": acc_dict.get("equity", 0.0),
|
||||
"available_capital": acc_dict.get("available_capital", 0.0),
|
||||
"pnl": acc_dict.get("realized_pnl_total", 0.0),
|
||||
"fee_total": acc_dict.get("fee_total", 0.0),
|
||||
"open_positions": int(acc_dict.get("open_positions", 0)),
|
||||
"trade_seq": int(acc_dict.get("trade_seq", 0)),
|
||||
"posture": posture,
|
||||
"capital_frozen": bool(acc_dict.get("capital_frozen", False)),
|
||||
"our_leverage": our_leverage,
|
||||
"slot": slot_dict,
|
||||
"updated_at": _utcnow_iso(),
|
||||
}
|
||||
_hz_write_no_wait(self._state_map, "engine_snapshot", _json_encode(payload))
|
||||
# Compact "latest" key — same shape as BLUE's DOLPHIN_STATE_BLUE["latest"]
|
||||
_hz_write_no_wait(self._state_map, "latest", _json_encode({
|
||||
"strategy": "pink",
|
||||
"capital": payload["capital"],
|
||||
"date": _today_iso(),
|
||||
"pnl": payload["pnl"],
|
||||
"trades": payload["trade_seq"],
|
||||
"posture": posture,
|
||||
"updated_at": payload["updated_at"],
|
||||
}))
|
||||
|
||||
def write_daily_pnl(self, acc_dict: dict, posture: str = "APEX") -> None:
|
||||
"""Write per-date PnL row. Called on trade close only."""
|
||||
_hz_write_no_wait(self._pnl_map, _today_iso(), _json_encode({
|
||||
"pnl": acc_dict.get("realized_pnl_total", 0.0),
|
||||
"capital": acc_dict.get("capital", 0.0),
|
||||
"trades": int(acc_dict.get("trade_seq", 0)),
|
||||
"posture": posture,
|
||||
}))
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self._client.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1252,3 +1252,143 @@ class TestW10HttpErrorMapping:
|
||||
|
||||
def test_dns_error_is_rate_limited(self):
|
||||
assert self._status("Name or service not known") == "RATE_LIMITED"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# PinkHzStateWriter: non-blocking Hz write correctness
|
||||
# ============================================================
|
||||
|
||||
class TestPinkHzStateWriter:
|
||||
"""PinkHzStateWriter: payload shape, vol_ok gate, and non-blocking guarantees."""
|
||||
|
||||
def _make_writer_no_hz(self):
|
||||
"""Build a PinkHzStateWriter with a mock client that captures writes."""
|
||||
from prod.clean_arch.dita_v2.hazelcast_projection import PinkHzStateWriter
|
||||
import unittest.mock as mock
|
||||
|
||||
w = object.__new__(PinkHzStateWriter)
|
||||
w._writes = {} # {(map_attr, key): value}
|
||||
|
||||
# Build fake non-blocking IMap proxy
|
||||
def _make_map(name):
|
||||
m = mock.MagicMock(name=f"map:{name}")
|
||||
def _put(key, value):
|
||||
w._writes[(name, key)] = value
|
||||
m.put.side_effect = _put
|
||||
return m
|
||||
|
||||
w._state_map = _make_map("DOLPHIN_STATE_PINK")
|
||||
w._pnl_map = _make_map("DOLPHIN_PNL_PINK")
|
||||
w._client = mock.MagicMock()
|
||||
return w
|
||||
|
||||
def test_engine_snapshot_writes_two_keys(self):
|
||||
w = self._make_writer_no_hz()
|
||||
w.write_engine_snapshot(
|
||||
{"slot_id": 0, "fsm_state": "IDLE"},
|
||||
{"capital": 25000.0, "trade_seq": 42},
|
||||
posture="APEX",
|
||||
)
|
||||
assert ("DOLPHIN_STATE_PINK", "engine_snapshot") in w._writes, (
|
||||
"PinkHzStateWriter must write engine_snapshot key"
|
||||
)
|
||||
assert ("DOLPHIN_STATE_PINK", "latest") in w._writes, (
|
||||
"PinkHzStateWriter must write latest key (BLUE-compatible)"
|
||||
)
|
||||
|
||||
def test_engine_snapshot_has_strategy_pink(self):
|
||||
import json
|
||||
w = self._make_writer_no_hz()
|
||||
w.write_engine_snapshot({"slot_id": 0}, {"capital": 10000.0})
|
||||
snap = json.loads(w._writes[("DOLPHIN_STATE_PINK", "engine_snapshot")])
|
||||
assert snap["strategy"] == "pink", "engine_snapshot must identify as pink"
|
||||
|
||||
def test_latest_key_has_blue_compatible_fields(self):
|
||||
import json
|
||||
w = self._make_writer_no_hz()
|
||||
w.write_engine_snapshot({"slot_id": 0}, {"capital": 5000.0, "realized_pnl_total": 123.4, "trade_seq": 7})
|
||||
latest = json.loads(w._writes[("DOLPHIN_STATE_PINK", "latest")])
|
||||
for field in ("strategy", "capital", "date", "pnl", "trades", "posture", "updated_at"):
|
||||
assert field in latest, f"BLUE-compatible 'latest' key missing field: {field}"
|
||||
|
||||
def test_our_leverage_in_snapshot(self):
|
||||
import json
|
||||
w = self._make_writer_no_hz()
|
||||
w.write_engine_snapshot(
|
||||
{"slot_id": 0, "size": 0.5, "entry_price": 50000.0},
|
||||
{"capital": 25000.0},
|
||||
our_leverage=1.0,
|
||||
)
|
||||
snap = json.loads(w._writes[("DOLPHIN_STATE_PINK", "engine_snapshot")])
|
||||
assert "our_leverage" in snap, "our_leverage (dual-leverage: system layer) must be in Hz snapshot"
|
||||
|
||||
def test_daily_pnl_write(self):
|
||||
import json
|
||||
w = self._make_writer_no_hz()
|
||||
w.write_daily_pnl({"realized_pnl_total": 45.6, "capital": 25000.0, "trade_seq": 3})
|
||||
key = next((k for k in w._writes if k[0] == "DOLPHIN_PNL_PINK"), None)
|
||||
assert key is not None, "write_daily_pnl must write to DOLPHIN_PNL_PINK"
|
||||
row = json.loads(w._writes[key])
|
||||
assert row["pnl"] == 45.6
|
||||
|
||||
def test_write_survives_exception(self):
|
||||
"""Hz write failure must never propagate — observability must not affect trading."""
|
||||
from prod.clean_arch.dita_v2.hazelcast_projection import _hz_write_no_wait
|
||||
import unittest.mock as mock
|
||||
bad_map = mock.MagicMock()
|
||||
bad_map.put.side_effect = RuntimeError("Hz down")
|
||||
_hz_write_no_wait(bad_map, "key", "value") # must not raise
|
||||
|
||||
|
||||
# ============================================================
|
||||
# vol_ok gate in DecisionEngine
|
||||
# ============================================================
|
||||
|
||||
class TestVolOkGate:
|
||||
"""DecisionEngine must block ENTERs when vol_ok=False in scan_payload."""
|
||||
|
||||
def _make_snapshot(self, vol_ok: bool, vdiv: float = -0.03, irp: float = 0.60):
|
||||
from prod.clean_arch.ports.data_feed import MarketSnapshot
|
||||
from datetime import datetime, timezone
|
||||
return MarketSnapshot(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
symbol="BTCUSDT",
|
||||
price=50000.0,
|
||||
velocity_divergence=vdiv,
|
||||
irp_alignment=irp,
|
||||
scan_payload={"vol_ok": vol_ok, "posture": "APEX"},
|
||||
)
|
||||
|
||||
def _engine(self):
|
||||
from prod.clean_arch.dita.decision import DecisionEngine, DecisionConfig
|
||||
cfg = DecisionConfig(
|
||||
vel_div_threshold=-0.02,
|
||||
vel_div_extreme=-0.05,
|
||||
fixed_tp_pct=0.0020,
|
||||
max_hold_bars=250,
|
||||
capital_fraction=0.20,
|
||||
max_leverage=3.0,
|
||||
allow_short=True,
|
||||
allow_long=False,
|
||||
)
|
||||
return DecisionEngine(cfg)
|
||||
|
||||
def _ctx(self, open_positions: int = 0, capital: float = 25000.0):
|
||||
from prod.clean_arch.dita.contracts import DecisionContext
|
||||
return DecisionContext(capital=capital, open_positions=open_positions)
|
||||
|
||||
def test_vol_ok_false_blocks_enter(self):
|
||||
eng = self._engine()
|
||||
snap = self._make_snapshot(vol_ok=False)
|
||||
decision = eng.decide(snap, self._ctx())
|
||||
assert decision.action.value in ("HOLD", "NO_ACTION", "SKIP", "VOL_GATE"), (
|
||||
f"vol_ok=False must block ENTER, got action={decision.action.value!r} reason={getattr(decision, 'reason', '?')!r}"
|
||||
)
|
||||
|
||||
def test_vol_ok_true_allows_enter(self):
|
||||
eng = self._engine()
|
||||
snap = self._make_snapshot(vol_ok=True)
|
||||
decision = eng.decide(snap, self._ctx())
|
||||
assert decision.action.value not in ("VOL_GATE",), (
|
||||
"vol_ok=True must not block on vol_ok gate"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user