203 lines
6.7 KiB
Python
203 lines
6.7 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
import random
|
||
|
|
import threading
|
||
|
|
from dataclasses import dataclass
|
||
|
|
import importlib.util
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
_MOD_PATH = Path("/mnt/dolphinng5_predict/prod/nautilus_event_trader.py")
|
||
|
|
_SPEC = importlib.util.spec_from_file_location("nautilus_event_trader_mod", _MOD_PATH)
|
||
|
|
assert _SPEC and _SPEC.loader
|
||
|
|
mod = importlib.util.module_from_spec(_SPEC)
|
||
|
|
_SPEC.loader.exec_module(mod) # type: ignore[arg-type]
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class _Pos:
|
||
|
|
trade_id: str
|
||
|
|
asset: str
|
||
|
|
entry_price: float
|
||
|
|
notional: float
|
||
|
|
current_price: float = 0.0
|
||
|
|
pnl_pct: float = 0.0
|
||
|
|
|
||
|
|
|
||
|
|
class _ExitMgr:
|
||
|
|
def __init__(self):
|
||
|
|
self._positions: dict[str, dict] = {}
|
||
|
|
|
||
|
|
|
||
|
|
class _Eng:
|
||
|
|
def __init__(self, pos: _Pos | None):
|
||
|
|
self.position = pos
|
||
|
|
self.capital = 25_000.0
|
||
|
|
self.exit_manager = _ExitMgr()
|
||
|
|
if pos:
|
||
|
|
self.exit_manager._positions[pos.trade_id] = {"dummy": True}
|
||
|
|
|
||
|
|
|
||
|
|
class _Map:
|
||
|
|
def __init__(self):
|
||
|
|
self._d = {"blue_runtime_commands": "[]"}
|
||
|
|
self._lock = threading.Lock()
|
||
|
|
|
||
|
|
def blocking(self):
|
||
|
|
return self
|
||
|
|
|
||
|
|
def get(self, key):
|
||
|
|
with self._lock:
|
||
|
|
return self._d.get(key)
|
||
|
|
|
||
|
|
def put(self, key, val):
|
||
|
|
with self._lock:
|
||
|
|
self._d[key] = val
|
||
|
|
class _F:
|
||
|
|
def add_done_callback(self, _cb):
|
||
|
|
return None
|
||
|
|
return _F()
|
||
|
|
|
||
|
|
|
||
|
|
def _mk_trader():
|
||
|
|
t = object.__new__(mod.DolphinLiveTrader)
|
||
|
|
t.eng_lock = threading.Lock()
|
||
|
|
t.control_map = _Map()
|
||
|
|
t._processed_retract_commands = mod.deque(maxlen=5000)
|
||
|
|
t._processed_retract_set = set()
|
||
|
|
t._pending_entries = {}
|
||
|
|
t.current_day = "2026-05-12"
|
||
|
|
t.bar_idx = 100
|
||
|
|
return t
|
||
|
|
|
||
|
|
|
||
|
|
def _install_open_position(t, *, trade_id="T", asset="STXUSDT", entry_price=1.0, notional=1000.0):
|
||
|
|
p = _Pos(trade_id, asset, entry_price, notional, current_price=entry_price)
|
||
|
|
t.eng = _Eng(p)
|
||
|
|
t._pending_entries[trade_id] = {
|
||
|
|
"trade_id": trade_id,
|
||
|
|
"asset": asset,
|
||
|
|
"side": "SHORT",
|
||
|
|
"entry_price": entry_price,
|
||
|
|
"entry_bar": 90,
|
||
|
|
"entry_date": "2026-05-12",
|
||
|
|
"notional": notional,
|
||
|
|
"notional_entry": notional,
|
||
|
|
"retraction_legs": 0,
|
||
|
|
"realized_pnl_legs_total": 0.0,
|
||
|
|
}
|
||
|
|
t._pending_entries[trade_id].update(t._chain_state_for_pending(
|
||
|
|
trade_id,
|
||
|
|
t._pending_entries[trade_id],
|
||
|
|
chain_mode="LIVE",
|
||
|
|
chain_head_leg_id=f"{trade_id}:open",
|
||
|
|
chain_prev_leg_id="",
|
||
|
|
chain_seq=0,
|
||
|
|
))
|
||
|
|
|
||
|
|
|
||
|
|
def test_fuzz_retraction_invariants_hold_under_random_command_stream(monkeypatch):
|
||
|
|
monkeypatch.setattr(mod, "ch_put", lambda *_args, **_kwargs: None)
|
||
|
|
monkeypatch.setattr(mod, "_ch_ts_us", lambda: 123)
|
||
|
|
|
||
|
|
rng = random.Random(20260512)
|
||
|
|
t = _mk_trader()
|
||
|
|
_install_open_position(t, trade_id="T-FUZZ", asset="STXUSDT", entry_price=1.0, notional=10_000.0)
|
||
|
|
|
||
|
|
seen_ids: set[str] = set()
|
||
|
|
baseline_cap = t.eng.capital
|
||
|
|
|
||
|
|
for i in range(2500):
|
||
|
|
if t.eng.position is None:
|
||
|
|
break
|
||
|
|
px = max(0.00001, 1.0 + rng.uniform(-0.25, 0.25))
|
||
|
|
# Mix valid and invalid commands.
|
||
|
|
frac_choice = rng.choice([
|
||
|
|
rng.uniform(0.01, 1.0), # valid
|
||
|
|
0.0, # invalid
|
||
|
|
-0.1, # invalid
|
||
|
|
1.2, # invalid
|
||
|
|
])
|
||
|
|
# inject duplicate ids often
|
||
|
|
if i > 0 and rng.random() < 0.2:
|
||
|
|
cid = rng.choice(tuple(seen_ids)) if seen_ids else f"c-{i}"
|
||
|
|
else:
|
||
|
|
cid = f"c-{i}-{rng.randint(0, 999)}"
|
||
|
|
seen_ids.add(cid)
|
||
|
|
# wrong trade ids sometimes
|
||
|
|
tid = "T-FUZZ" if rng.random() < 0.8 else f"OTHER-{i}"
|
||
|
|
pending = t._pending_entries["T-FUZZ"]
|
||
|
|
cmd = {
|
||
|
|
"command_id": cid,
|
||
|
|
"trade_id": tid,
|
||
|
|
"action": "RETRACT",
|
||
|
|
"fraction": frac_choice,
|
||
|
|
"reason": "HOTKEY_RETRACT",
|
||
|
|
"source": "fuzz",
|
||
|
|
"chain_root_trade_id": pending["chain_root_trade_id"],
|
||
|
|
"chain_head_leg_id": pending["chain_head_leg_id"],
|
||
|
|
"chain_prev_leg_id": pending["chain_prev_leg_id"],
|
||
|
|
"chain_seq": pending["chain_seq"],
|
||
|
|
"chain_token": pending["chain_token"],
|
||
|
|
}
|
||
|
|
t.control_map.put("blue_runtime_commands", json.dumps([cmd]))
|
||
|
|
t._process_runtime_commands({"STXUSDT": px})
|
||
|
|
|
||
|
|
if t.eng.position is not None:
|
||
|
|
n = float(t.eng.position.notional)
|
||
|
|
assert n >= -1e-8
|
||
|
|
# never exceed original notional
|
||
|
|
assert n <= 10_000.0 + 1e-8
|
||
|
|
p = t._pending_entries["T-FUZZ"]
|
||
|
|
assert int(p.get("retraction_legs", 0) or 0) >= 0
|
||
|
|
|
||
|
|
# Capital must stay finite and deterministic.
|
||
|
|
assert t.eng.capital == pytest.approx(float(t.eng.capital))
|
||
|
|
assert abs(t.eng.capital - baseline_cap) < 1e7
|
||
|
|
|
||
|
|
|
||
|
|
def test_fuzz_concurrent_queue_submission_and_drain(monkeypatch):
|
||
|
|
monkeypatch.setattr(mod, "ch_put", lambda *_args, **_kwargs: None)
|
||
|
|
monkeypatch.setattr(mod, "_ch_ts_us", lambda: 123)
|
||
|
|
rng = random.Random(777)
|
||
|
|
t = _mk_trader()
|
||
|
|
_install_open_position(t, trade_id="T-RACE", asset="DASHUSDT", entry_price=10.0, notional=5000.0)
|
||
|
|
|
||
|
|
def producer(start: int, count: int):
|
||
|
|
for i in range(start, start + count):
|
||
|
|
with t.control_map._lock:
|
||
|
|
raw = t.control_map._d.get("blue_runtime_commands", "[]")
|
||
|
|
q = json.loads(raw) if raw else []
|
||
|
|
q.append({
|
||
|
|
"command_id": f"p-{i}",
|
||
|
|
"trade_id": "T-RACE" if rng.random() < 0.9 else "OTHER",
|
||
|
|
"action": "RETRACT",
|
||
|
|
"fraction": rng.uniform(0.01, 1.0),
|
||
|
|
"reason": "HOTKEY_RETRACT",
|
||
|
|
"source": "race",
|
||
|
|
"chain_root_trade_id": t._pending_entries["T-RACE"]["chain_root_trade_id"],
|
||
|
|
"chain_head_leg_id": t._pending_entries["T-RACE"]["chain_head_leg_id"],
|
||
|
|
"chain_prev_leg_id": t._pending_entries["T-RACE"]["chain_prev_leg_id"],
|
||
|
|
"chain_seq": t._pending_entries["T-RACE"]["chain_seq"],
|
||
|
|
"chain_token": t._pending_entries["T-RACE"]["chain_token"],
|
||
|
|
})
|
||
|
|
t.control_map._d["blue_runtime_commands"] = json.dumps(q[-200:])
|
||
|
|
|
||
|
|
threads = [threading.Thread(target=producer, args=(k * 120, 120)) for k in range(4)]
|
||
|
|
for th in threads:
|
||
|
|
th.start()
|
||
|
|
for th in threads:
|
||
|
|
th.join()
|
||
|
|
|
||
|
|
# Drain repeatedly; must not throw and must preserve invariants.
|
||
|
|
for _ in range(50):
|
||
|
|
if t.eng.position is None:
|
||
|
|
break
|
||
|
|
t._process_runtime_commands({"DASHUSDT": rng.uniform(8.0, 12.0)})
|
||
|
|
|
||
|
|
if t.eng.position is not None:
|
||
|
|
assert t.eng.position.notional >= -1e-8
|
||
|
|
assert t.eng.position.notional <= 5000.0 + 1e-8
|