dita_v2: backport asex_account.py (from violet main) + test (from PASS9) to canonical upstream
Heals reverse vendor drift: the ASEx AccountProjectionV2 adapter was committed on the vendored copy (violet.git main 824c5cf) instead of upstream, violating the edit-upstream-then-sync doctrine. Test was orphaned on the PASS9 worktree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
168
prod/clean_arch/dita_v2/test_asex_account.py
Normal file
168
prod/clean_arch/dita_v2/test_asex_account.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""ASEx AccountProjectionV2 wrapper tests — race-proof, stress, seam."""
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from .account import AccountProjectionV2, AccountSnapshotV2, EPosition, TradeStage, TradeSide
|
||||
from .asex_account import ASEXAccountV2, _AccountBackend
|
||||
from asex.guarded import ValidationError
|
||||
|
||||
|
||||
def _empty_slots(n=1):
|
||||
return [SimpleNamespace(
|
||||
slot_id=i, trade_id=f"t{i}", asset="BTCUSDT",
|
||||
side=TradeSide.LONG, entry_price=0.0, size=0.0,
|
||||
initial_size=0.0, leverage=1.0, realized_pnl=0.0, closed=False,
|
||||
fsm_state=TradeStage.IDLE, exit_leg_ratios=(1.0,),
|
||||
active_leg_index=0, active_exit_order=None, active_entry_order=None,
|
||||
close_reason="", entry_time=None, last_event_time=None,
|
||||
seen_event_ids=(), metadata={}, unrealized_pnl=0.0, to_dict=lambda: {},
|
||||
) for i in range(n)]
|
||||
|
||||
|
||||
def _clean():
|
||||
gc.collect(); gc.collect()
|
||||
|
||||
|
||||
def _force_race(obj, attr, n=10):
|
||||
b = threading.Barrier(n)
|
||||
def _w():
|
||||
b.wait(); v = getattr(obj, attr); b.wait(); setattr(obj, attr, v + 1)
|
||||
ts = [threading.Thread(target=_w) for _ in range(n)]
|
||||
for t in ts: t.start()
|
||||
for t in ts: t.join(timeout=10)
|
||||
return getattr(obj, attr)
|
||||
|
||||
|
||||
class TestRaceProof:
|
||||
def test_k_realized_races(self):
|
||||
assert _force_race(AccountProjectionV2(0.0), "_k_realized", 50) == 1
|
||||
def test_k_fees_races(self):
|
||||
assert _force_race(AccountProjectionV2(0.0), "_k_fees", 50) == 1
|
||||
def test_k_funding_races(self):
|
||||
assert _force_race(AccountProjectionV2(0.0), "_k_funding", 50) == 1
|
||||
def test_event_seq_races(self):
|
||||
assert _force_race(AccountProjectionV2(0.0), "_event_seq", 50) == 1
|
||||
|
||||
|
||||
class TestASExBasic:
|
||||
def test_seed_only(self):
|
||||
_clean(); p = ASEXAccountV2(10000.0)
|
||||
s = p.build_snapshot(source_event_id="t", slots=_empty_slots(), ts=1e6)
|
||||
assert s.k.capital == pytest.approx(10000.0); p.close(); _clean()
|
||||
def test_realized_adds(self):
|
||||
_clean(); p = ASEXAccountV2(10000.0)
|
||||
p.apply_fill(fill_price=100, fill_qty=1, fee=0, realized_pnl=500)
|
||||
s = p.build_snapshot(source_event_id="t", slots=_empty_slots(), ts=1e6)
|
||||
assert s.k.capital == pytest.approx(10500.0); p.close(); _clean()
|
||||
def test_fee_subtracts(self):
|
||||
_clean(); p = ASEXAccountV2(10000.0)
|
||||
p.apply_fill(fill_price=100, fill_qty=1, fee=3.5, realized_pnl=0)
|
||||
s = p.build_snapshot(source_event_id="t", slots=_empty_slots(), ts=1e6)
|
||||
assert s.k.capital == pytest.approx(9996.5); p.close(); _clean()
|
||||
def test_funding_subtracts(self):
|
||||
_clean(); p = ASEXAccountV2(10000.0)
|
||||
p.apply_funding(7.25)
|
||||
s = p.build_snapshot(source_event_id="t", slots=_empty_slots(), ts=1e6)
|
||||
assert s.k.capital == pytest.approx(9992.75); p.close(); _clean()
|
||||
def test_combined(self):
|
||||
_clean(); p = ASEXAccountV2(10000.0)
|
||||
p.apply_fill(fill_price=50, fill_qty=2, fee=2, realized_pnl=100)
|
||||
p.apply_funding(5)
|
||||
s = p.build_snapshot(source_event_id="t", slots=_empty_slots(), ts=1e6)
|
||||
assert s.k.capital == pytest.approx(10093.0)
|
||||
p.close(); _clean()
|
||||
|
||||
|
||||
class TestASExConcurrency:
|
||||
@pytest.mark.parametrize("n,ops", [(10, 100), (20, 100), (50, 50)])
|
||||
def test_no_lost_updates(self, n, ops):
|
||||
_clean(); p = ASEXAccountV2(0.0)
|
||||
def _w(tid):
|
||||
for i in range(ops):
|
||||
p.apply_fill(fill_price=100, fill_qty=1, fee=0, realized_pnl=float(tid * ops + i))
|
||||
with ThreadPoolExecutor(max_workers=n) as ex:
|
||||
for f in as_completed([ex.submit(_w, i) for i in range(n)]): f.result(timeout=60)
|
||||
expected = sum(tid * ops + i for tid in range(n) for i in range(ops))
|
||||
assert p._backend._proj._k_realized == pytest.approx(float(expected))
|
||||
p.close(); _clean()
|
||||
|
||||
def test_event_seq_monotonic(self):
|
||||
_clean(); p = ASEXAccountV2(0.0); seqs = []
|
||||
def _w(tid):
|
||||
for i in range(50):
|
||||
s = p.build_snapshot(source_event_id=f"e{tid}_{i}", slots=_empty_slots(), ts=float(i))
|
||||
seqs.append(s.event_seq)
|
||||
with ThreadPoolExecutor(max_workers=10) as ex:
|
||||
for f in as_completed([ex.submit(_w, i) for i in range(10)]): f.result(timeout=60)
|
||||
assert sorted(seqs) == list(range(1, 501))
|
||||
p.close(); _clean()
|
||||
|
||||
def test_mixed_ops(self):
|
||||
_clean(); p = ASEXAccountV2(10000.0)
|
||||
def _f(tid):
|
||||
for i in range(100): p.apply_fill(fill_price=float(i), fill_qty=1, fee=0, realized_pnl=1)
|
||||
def _fu(tid):
|
||||
for i in range(50): p.apply_funding(0.5)
|
||||
with ThreadPoolExecutor(max_workers=4) as ex:
|
||||
for f in as_completed([ex.submit(_f, i) for i in [0,1]] + [ex.submit(_fu, i) for i in [0,1]]):
|
||||
f.result(timeout=60)
|
||||
assert p._backend._proj._k_realized == pytest.approx(200.0)
|
||||
assert p._backend._proj._k_funding == pytest.approx(100.0)
|
||||
p.close(); _clean()
|
||||
|
||||
|
||||
class TestASExSeam:
|
||||
def test_backend_rejects_no_op(self):
|
||||
assert not _AccountBackend(0.0)._validate({})
|
||||
def test_backend_rejects_unknown(self):
|
||||
b = _AccountBackend(0.0)
|
||||
with pytest.raises(Exception): b.mutate({"op": "nope"})
|
||||
def test_backend_applied(self):
|
||||
b = _AccountBackend(0.0)
|
||||
b.mutate({"op": "apply_fill", "args": {"fill_price": 100, "fill_qty": 1, "fee": 0, "realized_pnl": 10}})
|
||||
assert b.applied == 1
|
||||
def test_worker_alive(self):
|
||||
p = ASEXAccountV2(0.0); assert p._worker._worker.is_alive(); p.close()
|
||||
def test_double_close(self):
|
||||
p = ASEXAccountV2(0.0); p.close(); p.close()
|
||||
def test_context_manager(self):
|
||||
with ASEXAccountV2(10000.0) as p:
|
||||
p.apply_fill(fill_price=100, fill_qty=1, fee=0, realized_pnl=50)
|
||||
s = p.build_snapshot(source_event_id="t", slots=_empty_slots(), ts=1e6)
|
||||
assert s.k.realized_pnl == pytest.approx(50.0)
|
||||
def test_no_thread_leak(self):
|
||||
_clean(); b = threading.active_count()
|
||||
for _ in range(50):
|
||||
p = ASEXAccountV2(0.0); p.apply_fill(fill_price=100, fill_qty=1, fee=0, realized_pnl=1); p.close()
|
||||
_clean(); assert threading.active_count() - b <= 2
|
||||
|
||||
|
||||
class TestLockProof:
|
||||
def test_k_realized_lock_proof(self):
|
||||
ap, l, n = AccountProjectionV2(0.0), threading.Lock(), 50
|
||||
b = threading.Barrier(n)
|
||||
def _w():
|
||||
b.wait()
|
||||
with l: ap._k_realized += 1
|
||||
ts = [threading.Thread(target=_w) for _ in range(n)]
|
||||
for t in ts: t.start()
|
||||
for t in ts: t.join(timeout=10)
|
||||
assert ap._k_realized == n
|
||||
def test_event_seq_lock_proof(self):
|
||||
ap, l, n = AccountProjectionV2(0.0), threading.Lock(), 50
|
||||
b = threading.Barrier(n)
|
||||
def _w():
|
||||
b.wait()
|
||||
with l: ap._event_seq += 1
|
||||
ts = [threading.Thread(target=_w) for _ in range(n)]
|
||||
for t in ts: t.start()
|
||||
for t in ts: t.join(timeout=10)
|
||||
assert ap._event_seq == n
|
||||
Reference in New Issue
Block a user