Files
fxhnt/tests/unit/test_bybit_testnet_run.py
jgrusewski 7c05189aa2 test(exec): restore behavioral coverage lost porting the bybit testnet leg off Dagster (Task 9 review)
Task 7 deleted tests/integration/test_bybit_testnet_job_wiring.py wholesale, taking with it the
regression tests for 4 named past bugs the ported `run_bybit_testnet` code still contains but was
left untested: unlock-tier misclassification on the bare-vs-USDT-suffixed symbol (FIX 1),
mean_timing_drift dividing by the wrong denominator when a fill has no book_mark (FIX 2), the
recorded fill ts using the record-step wall clock instead of the exchange fill time (FIX 3), and
the open-orders resting-guard not aborting placement (FIX 4) — plus the per-day NAV-marker
idempotency no-op and the normal-path fills+NAV happy path. Ports all of them onto
`run_bybit_testnet` directly (not the deleted Dagster assets), patching the module's own
`fxhnt.application.bybit_testnet_run` seams instead of `adapters.orchestration.assets`. Also adds
a new run-level test for the paper-envelope refuse (non-paper account + paper_envelope>0 raises
ValueError before any order is placed) — the case the CLI-level Minor fixes in the next commit
need to stay distinct from a connectivity error.

Deliberately does NOT port `test_job_exists_with_two_assets_and_not_in_combined` or
`test_schedule_registered_on_its_own_job_stopped` — those tested the deleted Dagster job/schedule
and their absence is already asserted in test_orchestration_definitions.py.
2026-07-11 02:15:02 +02:00

303 lines
15 KiB
Python

"""Unit tests for `run_bybit_testnet` — the Bybit testnet exec leg ported off Dagster into a plain
application function (Task 5). These prove the guards AND the named past-bug fixes SURVIVED the port:
(a) kill-switch -> places ZERO orders, no exception (returns killed=True);
(b) dry-run (execute=False) -> places ZERO orders and writes NO NAV row (returns dry_run=True);
(c) a per-day NAV marker makes a re-run a no-op;
(d) a normal reconcile records fills (decomposed slippage) + a NAV row;
(e) FIX 1 — an unlock fill is classified at the 35bp unlock tier, not the 8bp liquid tier;
(f) FIX 2 — mean_timing_drift excludes None-drift fills from the denominator;
(g) FIX 3 — the recorded fill ts is the EXCHANGE fill time, not the record-step wall clock;
(h) FIX 4 — an open order resting on a run symbol aborts the reconcile, placing nothing;
(i) the paper-envelope refuse (account not testnet/paper) propagates as a ValueError at the run level,
with the adapter never reaching `place_market`.
The warehouse/ccxt inputs are swapped via the module-level monkeypatchable seams + a fake adapter, so the
tests never touch Timescale or the network.
"""
from __future__ import annotations
import datetime as dt
import pytest
import fxhnt.application.bybit_testnet_run as run_mod
from fxhnt.application.bybit_testnet_run import run_bybit_testnet
class _FakeStore:
def close(self): # noqa: ANN202
pass
class _FakeAdapter:
"""Mimics `BybitExecution`'s surface for `run_bybit_testnet`. By default `place_market` RAISES — a guard
that fires means the port leaked a placement into the kill/dry-run path. Pass `allow_place=True` (the
behavioral tests) to make it record the order and return a deterministic `Fill` instead, so slippage
decomposition + persistence can be exercised end-to-end."""
def __init__(self, *, equity=8000.0, positions=None, limits=None, mids=None, fill_price=None,
open_orders=None, fill_ts=0, allow_place=False): # noqa: ANN001, ANN204
from fxhnt.application.bybit_testnet_execution import InstrumentLimit
self._equity = equity
self._positions = positions or {}
self._limits = limits or {
"BTCUSDT": InstrumentLimit(min_order_qty=0.001, qty_step=0.001, min_notional=5.0),
"ETHUSDT": InstrumentLimit(min_order_qty=0.01, qty_step=0.01, min_notional=5.0),
"ONDOUSDT": InstrumentLimit(min_order_qty=0.1, qty_step=0.1, min_notional=5.0),
}
self._mids = mids or {}
self._fill_price = fill_price or {}
self._open_orders = open_orders or []
self._fill_ts = fill_ts
self._allow_place = allow_place
self.placed: list[tuple] = []
def equity(self): # noqa: ANN201
return self._equity
def positions(self): # noqa: ANN201
return dict(self._positions)
def instrument_limits(self): # noqa: ANN201
return dict(self._limits)
def open_orders(self, symbol=None): # noqa: ANN001, ANN201
return list(self._open_orders)
def mid(self, sym): # noqa: ANN001, ANN201
return self._mids.get(sym)
def funding_since(self, cursor): # noqa: ANN001, ANN201
return 0.0
def place_market(self, sym, qty, reduce_only=False): # noqa: ANN001, ANN201
self.placed.append((sym, qty, reduce_only))
if not self._allow_place:
raise AssertionError("guard breached: kill-switch / dry-run must not place an order")
from fxhnt.adapters.broker.bybit import Fill
px = self._fill_price.get(sym, self._mids.get(sym) or 100.0)
side = "BUY" if qty > 0 else "SELL"
return Fill(symbol=sym, side=side, qty=abs(qty), avg_price=px, fee=0.0,
ts=self._fill_ts, order_id=f"o-{sym}")
def _settings(tmp_path, *, kill_switch=False, testnet=True): # noqa: ANN001, ANN202
"""Real sqlite operational_dsn (the run function builds its OWN repos from it — not monkeypatchable) with a
seeded `bybit_4edge` B2a allocation so the envelope is non-zero and `plan_orders` yields a REAL intent —
otherwise the guard-under-test would have nothing to place and the assertion would be vacuous."""
from fxhnt.adapters.persistence.cockpit_models import StrategyAllocationRow
from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
from fxhnt.config import BybitExecutionConfig, Settings
dsn = f"sqlite:///{tmp_path / 'op.db'}"
s = Settings(operational_dsn=dsn,
bybit_exec=BybitExecutionConfig(api_key="k", api_secret="s", testnet=testnet,
kill_switch=kill_switch))
at = dt.datetime.now(dt.UTC).replace(tzinfo=None)
repo = ForwardNavRepo(dsn)
repo.migrate()
repo.replace_allocations([StrategyAllocationRow(
strategy_id="bybit_4edge", target_weight=1.0, target_dollars=1_000_000.0,
policy_version=1, policy_hash="h", as_of=at.date().isoformat(), computed_at=at)], at)
return s, dsn
def _patch_seams(monkeypatch, adapter, *, weights=None, marks=None, unlock_syms=None,
as_of_day="2026-07-11"): # noqa: ANN001, ANN202
monkeypatch.setattr(run_mod, "_testnet_adapter", lambda s: adapter)
monkeypatch.setattr(run_mod, "_testnet_store", lambda s: _FakeStore())
monkeypatch.setattr(run_mod, "_testnet_universe", lambda store: None)
monkeypatch.setattr(run_mod, "_testnet_unlock_events", lambda s:
[{"sym": x, "cliff_day": 0, "n": 0, "cat": "c"} for x in (unlock_syms or [])])
monkeypatch.setattr(run_mod, "_testnet_marks", lambda store, universe=None: marks or {"BTCUSDT": 100.0})
# non-empty raw weights so plan_orders emits an intent (envelope is non-zero from the seed).
monkeypatch.setattr(run_mod, "latest_raw_sleeve_weights", lambda *a, **k: dict(weights or {"BTCUSDT": 0.5}))
monkeypatch.setattr(run_mod, "combined_symbol_weights", lambda *a, **k: ({}, as_of_day))
def test_kill_switch_places_nothing(monkeypatch, tmp_path): # noqa: ANN001, ANN201
s, _dsn = _settings(tmp_path, kill_switch=True)
adapter = _FakeAdapter()
_patch_seams(monkeypatch, adapter)
logs: list[str] = []
out = run_bybit_testnet(s, paper_envelope=0.0, execute=True, log=logs.append)
assert out.get("killed") is True
assert out["placed"] == 0
assert adapter.placed == []
def test_dry_run_places_nothing_and_writes_no_nav(monkeypatch, tmp_path): # noqa: ANN001, ANN201
from fxhnt.adapters.persistence.bybit_testnet_repo import BybitTestnetRepo
s, dsn = _settings(tmp_path, kill_switch=False)
adapter = _FakeAdapter()
_patch_seams(monkeypatch, adapter)
logs: list[str] = []
out = run_bybit_testnet(s, paper_envelope=0.0, execute=False, log=logs.append)
assert out["placed"] == 0
assert out.get("dry_run") is True
assert adapter.placed == []
# no NAV row written -> a subsequent run would NOT see today as executed.
repo = BybitTestnetRepo(dsn)
repo.migrate()
run_date = dt.datetime.now(dt.UTC).date().isoformat()
assert repo.testnet_executed_on(run_date) is False
def test_executed_marker_makes_reconcile_a_noop(monkeypatch, tmp_path): # noqa: ANN001, ANN201
from fxhnt.adapters.persistence.bybit_testnet_repo import BybitTestnetRepo
s, dsn = _settings(tmp_path)
adapter = _FakeAdapter(allow_place=True)
_patch_seams(monkeypatch, adapter)
# Pre-mark today as executed → the run must short-circuit before placing anything.
run_date = dt.datetime.now(dt.UTC).date().isoformat()
repo = BybitTestnetRepo(dsn)
repo.migrate()
repo.upsert_testnet_nav(run_date, equity=10_000.0, realized_pnl=0.0, gross=0.0, n_orders=0,
mean_exec_slippage_bps=0.0, mean_timing_drift_bps=0.0, ts=dt.datetime.now(dt.UTC))
out = run_bybit_testnet(s, paper_envelope=0.0, execute=True, log=lambda *a: None)
assert out.get("noop") is True
assert adapter.placed == []
def test_normal_reconcile_records_fills_and_nav(monkeypatch, tmp_path): # noqa: ANN001, ANN201
from fxhnt.adapters.persistence.bybit_testnet_repo import BybitTestnetRepo
s, dsn = _settings(tmp_path)
# Target a long BTC; book_mark=100, live mid=101, fill=102 → exec_slippage = +bps, timing_drift = +bps.
adapter = _FakeAdapter(equity=10_000.0, mids={"BTCUSDT": 101.0}, fill_price={"BTCUSDT": 102.0},
allow_place=True)
_patch_seams(monkeypatch, adapter, weights={"BTCUSDT": 0.5}, marks={"BTCUSDT": 100.0})
out = run_bybit_testnet(s, paper_envelope=0.0, execute=True, log=lambda *a: None)
assert out["placed"] == 1
assert adapter.placed and adapter.placed[0][0] == "BTCUSDT"
fill0 = out["fills"][0]
assert fill0["symbol"] == "BTCUSDT"
assert fill0["exec_slippage_bps"] is not None and fill0["exec_slippage_bps"] > 0
assert fill0["timing_drift_bps"] is not None and fill0["timing_drift_bps"] > 0
assert fill0["low_confidence"] is False
assert out["nav_written"] is True
run_date = dt.datetime.now(dt.UTC).date().isoformat()
repo = BybitTestnetRepo(dsn)
nav = repo.read_testnet_nav()
assert len(nav) == 1 and nav[0].run_date == run_date and nav[0].n_orders == 1
fills = repo.read_testnet_fills(run_date)
assert len(fills) == 1 and fills[0].symbol == "BTCUSDT"
assert fills[0].exec_slippage_bps is not None
def test_unlock_fill_classified_at_unlock_tier(monkeypatch, tmp_path): # noqa: ANN001, ANN201
"""FIX 1: an unlock event carries the BARE base coin ("ONDO"); the order is on the USDT-suffixed
"ONDOUSDT". The run must normalize the unlock set to the USDT suffix so the fill is charged the 35bp
unlock tier — not the 8bp liquid tier (the misclassification this test would have caught)."""
from fxhnt.application.paper_book import assumed_tier_bps
s, _dsn = _settings(tmp_path)
adapter = _FakeAdapter(mids={"ONDOUSDT": 1.0}, fill_price={"ONDOUSDT": 1.0}, allow_place=True)
_patch_seams(monkeypatch, adapter, weights={"ONDOUSDT": 0.5}, marks={"ONDOUSDT": 1.0},
unlock_syms=["ONDO"])
out = run_bybit_testnet(s, paper_envelope=0.0, execute=True, log=lambda *a: None)
assert out["placed"] == 1
fill0 = out["fills"][0]
assert fill0["symbol"] == "ONDOUSDT"
assert fill0["assumed_tier_bps"] == assumed_tier_bps("ONDOUSDT", True) # 35bp unlock tier
assert fill0["assumed_tier_bps"] != assumed_tier_bps("ONDOUSDT", False) # NOT the 8bp liquid tier
class _MarksDropBookMark(dict):
"""Returns the real mark to `plan_orders` (1st `.get`) but None to the later `book_mark` read (2nd
`.get`) for `drop` symbols — so a PLACED fill carries timing_drift_bps=None, the case FIX 2 guards
(the mean must divide by the count of non-None drifts, not len(real))."""
def __init__(self, data, drop): # noqa: ANN001, ANN204
super().__init__(data)
self._drop = set(drop)
self._seen: dict = {}
def get(self, key, default=None): # noqa: ANN001, ANN201
val = super().get(key, default)
if key in self._drop:
n = self._seen.get(key, 0)
self._seen[key] = n + 1
if n >= 1:
return None
return val
def test_mean_drift_excludes_none_drift_from_denominator(monkeypatch, tmp_path): # noqa: ANN001, ANN201
"""FIX 2: BTC has a real book_mark → drift present. ETH's book_mark read returns None on the SECOND
lookup (the fill-time read) even though the fill itself is real (mid not None ⇒ not low_confidence). The
mean must be over the single non-None drift (BTC), not BTC/2 — the buggy len(real) denominator gave half."""
s, _dsn = _settings(tmp_path)
marks = _MarksDropBookMark({"BTCUSDT": 100.0, "ETHUSDT": 200.0}, drop=["ETHUSDT"])
adapter = _FakeAdapter(mids={"BTCUSDT": 101.0, "ETHUSDT": 202.0},
fill_price={"BTCUSDT": 102.0, "ETHUSDT": 202.0}, allow_place=True)
_patch_seams(monkeypatch, adapter, weights={"BTCUSDT": 0.3, "ETHUSDT": 0.3}, marks=marks)
out = run_bybit_testnet(s, paper_envelope=0.0, execute=True, log=lambda *a: None)
assert out["placed"] == 2
drifts = {f["symbol"]: f["timing_drift_bps"] for f in out["fills"]}
assert drifts["ETHUSDT"] is None and drifts["BTCUSDT"] is not None
assert out["mean_timing_drift_bps"] == drifts["BTCUSDT"]
def test_recorded_fill_ts_is_exchange_fill_time(monkeypatch, tmp_path): # noqa: ANN001, ANN201
"""FIX 3: the recorded fill ts is the EXCHANGE fill time, not the record-step wall clock."""
from fxhnt.adapters.persistence.bybit_testnet_repo import BybitTestnetRepo
s, dsn = _settings(tmp_path)
fill_ms = 1_700_000_000_000 # 2023-11-14T22:13:20Z — far from today's wall clock
adapter = _FakeAdapter(mids={"BTCUSDT": 101.0}, fill_price={"BTCUSDT": 102.0}, fill_ts=fill_ms,
allow_place=True)
_patch_seams(monkeypatch, adapter, weights={"BTCUSDT": 0.5}, marks={"BTCUSDT": 100.0})
out = run_bybit_testnet(s, paper_envelope=0.0, execute=True, log=lambda *a: None)
assert out["fills"][0]["ts"] == fill_ms
run_date = dt.datetime.now(dt.UTC).date().isoformat()
fills = BybitTestnetRepo(dsn).read_testnet_fills(run_date)
expected = dt.datetime.fromtimestamp(fill_ms / 1000.0, dt.UTC)
got = fills[0].ts
assert (got.year, got.month, got.day) == (expected.year, expected.month, expected.day)
assert got.year == 2023 # the exchange fill time, NOT now()
def test_open_orders_present_aborts_without_placing(monkeypatch, tmp_path): # noqa: ANN001, ANN201
"""FIX 4: a resting order on a run symbol means a prior run placed but the record step failed; re-placing
would double-execute. The run must ABORT placing nothing (and write no NAV row → visible gap)."""
from fxhnt.adapters.persistence.bybit_testnet_repo import BybitTestnetRepo
s, dsn = _settings(tmp_path)
adapter = _FakeAdapter(mids={"BTCUSDT": 100.0}, allow_place=True,
open_orders=[{"symbol": "BTC/USDT:USDT", "info": {"symbol": "BTCUSDT"}}])
_patch_seams(monkeypatch, adapter, weights={"BTCUSDT": 0.5}, marks={"BTCUSDT": 100.0})
out = run_bybit_testnet(s, paper_envelope=0.0, execute=True, log=lambda *a: None)
assert out.get("aborted") is True
assert out["error"] == "open_orders_present"
assert out["placed"] == 0
assert adapter.placed == []
run_date = dt.datetime.now(dt.UTC).date().isoformat()
assert not BybitTestnetRepo(dsn).testnet_executed_on(run_date) # no NAV row → visible gap
def test_paper_envelope_refused_when_account_not_paper(monkeypatch, tmp_path): # noqa: ANN001, ANN201
"""A live (non-testnet) account with a non-zero --paper-envelope must be refused AT THE run level — this
is a real-capital exposure guard, not a connectivity error. The adapter must never reach `place_market`."""
s, _dsn = _settings(tmp_path, testnet=False)
adapter = _FakeAdapter(mids={"BTCUSDT": 100.0}, allow_place=True)
_patch_seams(monkeypatch, adapter, weights={"BTCUSDT": 0.5}, marks={"BTCUSDT": 100.0})
with pytest.raises(ValueError, match="paper-envelope refused"):
run_bybit_testnet(s, paper_envelope=1_000_000.0, execute=True, log=lambda *a: None)
assert adapter.placed == []