dita_v2: reconcile SOA — vendored thread-safety hardening ∪ venue plane

3-way merge (base=pre-venue-plane upstream, ours=violet-main vendored hardening,
theirs=upstream venue plane): RLock atomic-snapshot wrapping (account.py,
real_control_plane, real_zinc_plane), lazy __getattr__ imports (__init__),
account-core test coverage — merged with venue_region telemetry. Zero conflicts;
all files AST-verified. Ends the two-way vendor drift found in
DITAV2_SOA_SURVEY_20260702 §5 / UV_DITAV2_SOA_VERDICT_20260703.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-07-03 01:15:17 +02:00
parent cfb3d7cf4f
commit a7522bf8d1
5 changed files with 225 additions and 136 deletions

View File

@@ -36,8 +36,6 @@ from .contracts import (
) )
from .journal import ClickHouseKernelJournal, KernelJournal, MemoryKernelJournal from .journal import ClickHouseKernelJournal, KernelJournal, MemoryKernelJournal
from .rust_backend import ExecutionKernel from .rust_backend import ExecutionKernel
from .bingx_venue import BingxVenueAdapter
from .launcher import DITAv2LauncherBundle, LauncherVenueMode, LauncherZincMode, build_launcher_bundle
from .projection import HazelcastProjection, build_position_state_row, build_projection from .projection import HazelcastProjection, build_position_state_row, build_projection
from .venue import VenueAdapter from .venue import VenueAdapter
from .mock_venue import MockVenueAdapter, MockVenueScenario from .mock_venue import MockVenueAdapter, MockVenueScenario
@@ -45,6 +43,28 @@ from .zinc_plane import InMemoryZincPlane, ZincPlane
from .real_zinc_plane import RealZincPlane, RealZincUnavailable from .real_zinc_plane import RealZincPlane, RealZincUnavailable
from .real_control_plane import RealZincControlPlane, RealZincUnavailable as RealZincControlUnavailable from .real_control_plane import RealZincControlPlane, RealZincUnavailable as RealZincControlUnavailable
def __getattr__(name: str):
if name == "BingxVenueAdapter":
from .bingx_venue import BingxVenueAdapter
return BingxVenueAdapter
if name in {"DITAv2LauncherBundle", "LauncherVenueMode", "LauncherZincMode", "build_launcher_bundle"}:
from .launcher import (
DITAv2LauncherBundle,
LauncherVenueMode,
LauncherZincMode,
build_launcher_bundle,
)
return {
"DITAv2LauncherBundle": DITAv2LauncherBundle,
"LauncherVenueMode": LauncherVenueMode,
"LauncherZincMode": LauncherZincMode,
"build_launcher_bundle": build_launcher_bundle,
}[name]
raise AttributeError(name)
__all__ = [ __all__ = [
"AccountProjection", "AccountProjection",
"AccountSnapshot", "AccountSnapshot",

View File

@@ -8,6 +8,7 @@ from enum import Enum
from typing import Any, Dict, Iterable, List, Optional from typing import Any, Dict, Iterable, List, Optional
import math import math
import time import time
import threading
from .contracts import TradeSide, TradeSlot, TradeStage from .contracts import TradeSide, TradeSlot, TradeStage
from .utils import safe_float from .utils import safe_float
@@ -59,42 +60,49 @@ class AccountProjection:
GIL guarantees single-field reference assignment is atomic, so readers GIL guarantees single-field reference assignment is atomic, so readers
that hold snap = kernel.account.snapshot before use see a consistent view. that hold snap = kernel.account.snapshot before use see a consistent view.
""" """
cur = self.snapshot with self._lock:
self.snapshot = AccountSnapshot( cur = self.snapshot
capital=kw.get("capital", cur.capital), self.snapshot = AccountSnapshot(
equity=kw.get("equity", cur.equity), capital=kw.get("capital", cur.capital),
realized_pnl=kw.get("realized_pnl", cur.realized_pnl), equity=kw.get("equity", cur.equity),
unrealized_pnl=kw.get("unrealized_pnl", cur.unrealized_pnl), realized_pnl=kw.get("realized_pnl", cur.realized_pnl),
open_positions=kw.get("open_positions", cur.open_positions), unrealized_pnl=kw.get("unrealized_pnl", cur.unrealized_pnl),
open_notional=kw.get("open_notional", cur.open_notional), open_positions=kw.get("open_positions", cur.open_positions),
fees_paid=kw.get("fees_paid", cur.fees_paid), open_notional=kw.get("open_notional", cur.open_notional),
trade_seq=kw.get("trade_seq", cur.trade_seq), fees_paid=kw.get("fees_paid", cur.fees_paid),
peak_capital=kw.get("peak_capital", cur.peak_capital), trade_seq=kw.get("trade_seq", cur.trade_seq),
capital_source=kw.get("capital_source", cur.capital_source), peak_capital=kw.get("peak_capital", cur.peak_capital),
e_wallet_balance=kw.get("e_wallet_balance", cur.e_wallet_balance), capital_source=kw.get("capital_source", cur.capital_source),
event_seq=kw.get("event_seq", cur.event_seq), e_wallet_balance=kw.get("e_wallet_balance", cur.e_wallet_balance),
) event_seq=kw.get("event_seq", cur.event_seq),
)
def __post_init__(self) -> None:
self._lock = threading.RLock()
def observe_slots(self, slots: Iterable[TradeSlot]) -> None: def observe_slots(self, slots: Iterable[TradeSlot]) -> None:
open_positions = 0 with self._lock:
open_notional = 0.0 open_positions = 0
unrealized_pnl = 0.0 open_notional = 0.0
for slot in slots: unrealized_pnl = 0.0
if slot.closed or slot.size <= 0: for slot in slots:
continue if slot.closed or slot.size <= 0:
if slot.fsm_state in {TradeStage.POSITION_OPEN, TradeStage.POSITION_OPENED, TradeStage.ENTRY_WORKING, TradeStage.EXIT_WORKING}: continue
open_positions += 1 if slot.fsm_state in {TradeStage.POSITION_OPEN, TradeStage.POSITION_OPENED, TradeStage.ENTRY_WORKING, TradeStage.EXIT_WORKING}:
mark = safe_float(slot.entry_price, 0.0) open_positions += 1
mark = safe_float(slot.metadata.get("mark_price"), mark) mark = safe_float(slot.entry_price, 0.0)
open_notional += abs(slot.size) * abs(mark) mark = safe_float(slot.metadata.get("mark_price"), mark)
unrealized_pnl += float(slot.unrealized_pnl or 0.0) open_notional += abs(slot.size) * abs(mark)
self._replace_snapshot( unrealized_pnl += float(slot.unrealized_pnl or 0.0)
open_positions=open_positions, capital = self.snapshot.capital
open_notional=open_notional, peak_capital = self.snapshot.peak_capital
unrealized_pnl=unrealized_pnl, self._replace_snapshot(
equity=self.snapshot.capital + unrealized_pnl if math.isfinite(self.snapshot.capital + unrealized_pnl) else self.snapshot.capital, open_positions=open_positions,
peak_capital=max(self.snapshot.peak_capital, self.snapshot.capital) if open_notional > 0 and self.snapshot.capital > 0 else self.snapshot.peak_capital, open_notional=open_notional,
) unrealized_pnl=unrealized_pnl,
equity=capital + unrealized_pnl if math.isfinite(capital + unrealized_pnl) else capital,
peak_capital=max(peak_capital, capital) if open_notional > 0 and capital > 0 else peak_capital,
)
def anchor_to_exchange(self, wallet_balance: float, available_margin: float, event_seq: int) -> None: def anchor_to_exchange(self, wallet_balance: float, available_margin: float, event_seq: int) -> None:
"""Snap published capital to exchange wallet balance. """Snap published capital to exchange wallet balance.
@@ -106,39 +114,42 @@ class AccountProjection:
Guards: wallet_balance must be > 0 and finite (the zero-wb frame lesson Guards: wallet_balance must be > 0 and finite (the zero-wb frame lesson
from ACCOUNT_UPDATE frames with no USDT balance entry). from ACCOUNT_UPDATE frames with no USDT balance entry).
""" """
wb = safe_float(wallet_balance, 0.0) with self._lock:
if wb <= 0.0 or not math.isfinite(wb): wb = safe_float(wallet_balance, 0.0)
return if wb <= 0.0 or not math.isfinite(wb):
self.snapshot.capital = wb return
self.snapshot.e_wallet_balance = wb self.snapshot.capital = wb
self.snapshot.capital_source = "e_anchored" self.snapshot.e_wallet_balance = wb
self.snapshot.event_seq = int(event_seq) self.snapshot.capital_source = "e_anchored"
self.snapshot.equity = wb + self.snapshot.unrealized_pnl self.snapshot.event_seq = int(event_seq)
if not math.isfinite(self.snapshot.equity): self.snapshot.equity = wb + self.snapshot.unrealized_pnl
self.snapshot.equity = wb if not math.isfinite(self.snapshot.equity):
self.snapshot.peak_capital = max(self.snapshot.peak_capital, wb) self.snapshot.equity = wb
self.snapshot.peak_capital = max(self.snapshot.peak_capital, wb)
def settle(self, realized_pnl: float, fees: float = 0.0) -> None: def settle(self, realized_pnl: float, fees: float = 0.0) -> None:
rp = safe_float(realized_pnl, 0.0) with self._lock:
# Include fees in capital delta (today fees only accumulate in cur = self.snapshot
# fees_paid while published capital ignores them between reseeds). rp = safe_float(realized_pnl, 0.0)
net = rp - safe_float(fees, 0.0) # Include fees in capital delta (today fees only accumulate in
new_capital = safe_float(self.snapshot.capital + net, self.snapshot.capital) # fees_paid while published capital ignores them between reseeds).
if self.max_capital is not None: net = rp - safe_float(fees, 0.0)
new_capital = min(new_capital, self.max_capital) new_capital = safe_float(cur.capital + net, cur.capital)
new_capital = max(self.min_capital, new_capital) if self.max_capital is not None:
new_source = self.snapshot.capital_source new_capital = min(new_capital, self.max_capital)
if new_source == "e_anchored" and abs(net) > 1e-12: new_capital = max(self.min_capital, new_capital)
new_source = "k_bridged" new_source = cur.capital_source
new_fees = self.snapshot.fees_paid + safe_float(fees, 0.0) if new_source == "e_anchored" and abs(net) > 1e-12:
new_equity = new_capital + self.snapshot.unrealized_pnl new_source = "k_bridged"
if not math.isfinite(new_equity): new_fees = cur.fees_paid + safe_float(fees, 0.0)
new_equity = new_capital new_equity = new_capital + cur.unrealized_pnl
self._replace_snapshot( if not math.isfinite(new_equity):
capital=new_capital, capital_source=new_source, new_equity = new_capital
realized_pnl=self.snapshot.realized_pnl + rp, self._replace_snapshot(
fees_paid=new_fees, equity=new_equity, capital=new_capital, capital_source=new_source,
) realized_pnl=cur.realized_pnl + rp,
fees_paid=new_fees, equity=new_equity,
)
def to_account_event( def to_account_event(
self, self,
@@ -154,31 +165,32 @@ class AccountProjection:
bars_held: int = 0, bars_held: int = 0,
metadata: Optional[Dict[str, Any]] = None, metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
self.snapshot.equity = self.snapshot.capital + self.snapshot.unrealized_pnl with self._lock:
return { self.snapshot.equity = self.snapshot.capital + self.snapshot.unrealized_pnl
"timestamp": timestamp.isoformat() if hasattr(timestamp, "isoformat") else str(timestamp), return {
"runtime_namespace": self.runtime_namespace, "timestamp": timestamp.isoformat() if hasattr(timestamp, "isoformat") else str(timestamp),
"strategy_namespace": self.strategy_namespace, "runtime_namespace": self.runtime_namespace,
"event_namespace": self.event_namespace, "strategy_namespace": self.strategy_namespace,
"actor_name": self.actor_name, "event_namespace": self.event_namespace,
"exec_venue": self.exec_venue, "actor_name": self.actor_name,
"data_venue": self.data_venue, "exec_venue": self.exec_venue,
"ledger_authority": self.ledger_authority, "data_venue": self.data_venue,
"capital": float(self.snapshot.capital), "ledger_authority": self.ledger_authority,
"equity": float(self.snapshot.equity), "capital": float(self.snapshot.capital),
"open_positions": int(self.snapshot.open_positions), "equity": float(self.snapshot.equity),
"current_open_notional": float(self.snapshot.open_notional), "open_positions": int(self.snapshot.open_positions),
"current_account_leverage": float(self.snapshot.leverage), "current_open_notional": float(self.snapshot.open_notional),
"trade_id": trade_id, "current_account_leverage": float(self.snapshot.leverage),
"asset": asset, "trade_id": trade_id,
"side": side.value, "asset": asset,
"reason": reason, "side": side.value,
"stage": stage.value, "reason": reason,
"pnl": float(pnl), "stage": stage.value,
"pnl_pct": float(pnl_pct), "pnl": float(pnl),
"bars_held": int(bars_held), "pnl_pct": float(pnl_pct),
"metadata": dict(metadata or {}), "bars_held": int(bars_held),
} "metadata": dict(metadata or {}),
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -311,6 +323,7 @@ class AccountProjectionV2:
self._min_capital = min_capital self._min_capital = min_capital
self._max_capital = max_capital self._max_capital = max_capital
self._cfg = reconcile_config or ReconcileConfig() self._cfg = reconcile_config or ReconcileConfig()
self._lock = threading.RLock()
# Running K-value accumulators # Running K-value accumulators
self._k_realized: float = 0.0 self._k_realized: float = 0.0
@@ -345,16 +358,18 @@ class AccountProjectionV2:
fee: float, fee: float,
realized_pnl: float, realized_pnl: float,
) -> None: ) -> None:
self._k_realized += _safe(realized_pnl) with self._lock:
self._k_fees += _safe(fee) self._k_realized += _safe(realized_pnl)
self._e_last_fill_price = _safe(fill_price) self._k_fees += _safe(fee)
self._e_last_fill_qty = _safe(fill_qty) self._e_last_fill_price = _safe(fill_price)
self._e_last_fill_fee = _safe(fee) self._e_last_fill_qty = _safe(fill_qty)
self._e_last_fill_realized = _safe(realized_pnl) self._e_last_fill_fee = _safe(fee)
self._e_last_fill_realized = _safe(realized_pnl)
def apply_funding(self, amount: float) -> None: def apply_funding(self, amount: float) -> None:
self._k_funding += _safe(amount) with self._lock:
self._e_last_funding = _safe(amount) self._k_funding += _safe(amount)
self._e_last_funding = _safe(amount)
def apply_balance_update( def apply_balance_update(
self, self,
@@ -364,13 +379,15 @@ class AccountProjectionV2:
used_margin: float, used_margin: float,
maint_margin: float, maint_margin: float,
) -> None: ) -> None:
self._e_wallet_balance = _safe(wallet_balance) with self._lock:
self._e_avail_margin = _safe(available_margin) self._e_wallet_balance = _safe(wallet_balance)
self._e_used_margin = _safe(used_margin) self._e_avail_margin = _safe(available_margin)
self._e_maint_margin = _safe(maint_margin) self._e_used_margin = _safe(used_margin)
self._e_maint_margin = _safe(maint_margin)
def apply_position_update(self, positions: List[EPosition]) -> None: def apply_position_update(self, positions: List[EPosition]) -> None:
self._e_positions = list(positions) with self._lock:
self._e_positions = list(positions)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Snapshot construction (called after each ingestion step) # Snapshot construction (called after each ingestion step)
@@ -382,21 +399,24 @@ class AccountProjectionV2:
slots: Iterable[TradeSlot], slots: Iterable[TradeSlot],
ts: Optional[float] = None, ts: Optional[float] = None,
) -> AccountSnapshotV2: ) -> AccountSnapshotV2:
self._event_seq += 1 with self._lock:
snap = self._build(self._event_seq, source_event_id, list(slots), ts or time.time()) self._event_seq += 1
self._snapshot = snap snap = self._build(self._event_seq, source_event_id, list(slots), ts or time.time())
return snap self._snapshot = snap
return snap
@property @property
def snapshot(self) -> AccountSnapshotV2: def snapshot(self) -> AccountSnapshotV2:
return self._snapshot with self._lock:
return self._snapshot
@property @property
def k_capital(self) -> float: def k_capital(self) -> float:
raw = self._seed + self._k_realized - self._k_fees - self._k_funding with self._lock:
if self._max_capital is not None: raw = self._seed + self._k_realized - self._k_fees - self._k_funding
raw = min(raw, self._max_capital) if self._max_capital is not None:
return max(self._min_capital, raw) raw = min(raw, self._max_capital)
return max(self._min_capital, raw)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Internal helpers # Internal helpers

View File

@@ -8,6 +8,8 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import threading
from .control import BackendMode, ControlPlane, ControlUpdate, KernelControlSnapshot, KernelMode, KernelVerbosity from .control import BackendMode, ControlPlane, ControlUpdate, KernelControlSnapshot, KernelMode, KernelVerbosity
_ZINC_ADAPTER_PATH = Path(__file__).resolve().parents[3] / "zinc" / "adapters" / "python" _ZINC_ADAPTER_PATH = Path(__file__).resolve().parents[3] / "zinc" / "adapters" / "python"
@@ -70,6 +72,7 @@ class RealZincControlPlane(ControlPlane):
require_real_zinc() require_real_zinc()
base = prefix.strip("/").replace("/", "_") base = prefix.strip("/").replace("/", "_")
self.region_name = f"{base}_control" self.region_name = f"{base}_control"
self._lock = threading.RLock()
self._seq = 0 self._seq = 0
self._snapshot = KernelControlSnapshot() self._snapshot = KernelControlSnapshot()
if create: if create:
@@ -86,21 +89,24 @@ class RealZincControlPlane(ControlPlane):
self.region.close() self.region.close()
def read(self) -> KernelControlSnapshot: def read(self) -> KernelControlSnapshot:
payload = _decode_packet(self.region.as_buffer()) with self._lock:
control = payload.get("control") if isinstance(payload, dict) else None payload = _decode_packet(self.region.as_buffer())
if not isinstance(control, dict): control = payload.get("control") if isinstance(payload, dict) else None
if not isinstance(control, dict):
return self._snapshot
self._snapshot = KernelControlSnapshot(**control)
return self._snapshot return self._snapshot
self._snapshot = KernelControlSnapshot(**control)
return self._snapshot
def update(self, update: ControlUpdate) -> KernelControlSnapshot: def update(self, update: ControlUpdate) -> KernelControlSnapshot:
self._snapshot = update.apply(self.read()) with self._lock:
self._seq += 1 self._snapshot = update.apply(self.read())
self._write_region(self._seq, self._snapshot.as_dict()) self._seq += 1
return self._snapshot self._write_region(self._seq, self._snapshot.as_dict())
return self._snapshot
def mirror(self) -> Dict[str, Any]: def mirror(self) -> Dict[str, Any]:
return self._snapshot.as_dict() with self._lock:
return self._snapshot.as_dict()
def wait(self, timeout_ms: int = 1000) -> bool: def wait(self, timeout_ms: int = 1000) -> bool:
try: try:

View File

@@ -258,14 +258,16 @@ class RealZincPlane:
self._write_region(self.state_region, self._state_seq, payload) self._write_region(self.state_region, self._state_seq, payload)
def read_slots(self) -> List[TradeSlot]: def read_slots(self) -> List[TradeSlot]:
payload = _decode_packet(self.state_region.as_buffer()) with self._lock:
slots = payload.get("slots", []) if isinstance(payload, dict) else [] payload = _decode_packet(self.state_region.as_buffer())
return [_slot_from_payload(slot) for slot in sorted(slots, key=lambda row: int(row.get("slot_id", 0)))] slots = payload.get("slots", []) if isinstance(payload, dict) else []
return [_slot_from_payload(slot) for slot in sorted(slots, key=lambda row: int(row.get("slot_id", 0)))]
def read_intents(self) -> List[Dict[str, Any]]: def read_intents(self) -> List[Dict[str, Any]]:
payload = _decode_packet(self.intent_region.as_buffer()) with self._lock:
items = payload.get("items", []) if isinstance(payload, dict) else [] payload = _decode_packet(self.intent_region.as_buffer())
return list(items) items = payload.get("items", []) if isinstance(payload, dict) else []
return list(items)
def update_control(self, control: KernelControlSnapshot) -> None: def update_control(self, control: KernelControlSnapshot) -> None:
with self._lock: with self._lock:
@@ -274,11 +276,12 @@ class RealZincPlane:
self._write_region(self.control_region, self._control_seq, {"control": control.as_dict()}) self._write_region(self.control_region, self._control_seq, {"control": control.as_dict()})
def read_control(self) -> KernelControlSnapshot: def read_control(self) -> KernelControlSnapshot:
payload = _decode_packet(self.control_region.as_buffer()) with self._lock:
control = payload.get("control") if isinstance(payload, dict) else None payload = _decode_packet(self.control_region.as_buffer())
if not isinstance(control, dict): control = payload.get("control") if isinstance(payload, dict) else None
return self._control_cache if not isinstance(control, dict):
return KernelControlSnapshot(**control) return self._control_cache
return KernelControlSnapshot(**control)
def wait_on_state(self, timeout_ms: int = 1000) -> bool: def wait_on_state(self, timeout_ms: int = 1000) -> bool:
return bool(self.state_region.wait(timeout_ms)) return bool(self.state_region.wait(timeout_ms))

View File

@@ -13,6 +13,7 @@ from __future__ import annotations
import math import math
import sys import sys
from concurrent.futures import ThreadPoolExecutor
sys.path.insert(0, "/mnt/dolphinng5_predict") sys.path.insert(0, "/mnt/dolphinng5_predict")
import pytest import pytest
@@ -324,7 +325,46 @@ class TestReplayDeterminism:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 7. V1 backward compatibility (AccountProjection must be untouched) # 7. Concurrency guard
# ---------------------------------------------------------------------------
class TestConcurrencyGuard:
def test_apply_fill_is_serialized(self):
proj = _proj(10_000.0)
n_threads = 16
per_thread = 250
total_fee = 0.0
total_realized = 0.0
def _worker(tid: int) -> tuple[float, float]:
local_fee = 0.0
local_realized = 0.0
for i in range(per_thread):
realized = float(tid * per_thread + i)
fee = float((i % 5) * 0.1)
proj.apply_fill(
fill_price=100.0,
fill_qty=1.0,
fee=fee,
realized_pnl=realized,
)
local_fee += fee
local_realized += realized
return local_realized, local_fee
with ThreadPoolExecutor(max_workers=n_threads) as ex:
for realized, fee in ex.map(_worker, range(n_threads)):
total_realized += realized
total_fee += fee
snap = _snap(proj)
assert snap.k.realized_pnl == pytest.approx(total_realized)
assert snap.k.fees_paid == pytest.approx(total_fee)
assert snap.k.capital == pytest.approx(10_000.0 + total_realized - total_fee)
# ---------------------------------------------------------------------------
# 8. V1 backward compatibility (AccountProjection must be untouched)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestV1Compat: class TestV1Compat: