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.
303 lines
15 KiB
Python
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 == []
|