118 lines
5.0 KiB
Python
118 lines
5.0 KiB
Python
"""`fxhnt compare-measured-precompute` — the OWN-MEMORY writer of the measured-cost COMPARE artifact.
|
|
|
|
The per-coin measured-cost compare used to run LIVE in the cockpit web handler and OOM-killed the 4Gi
|
|
`fxhnt-dashboard` pod (exit 137). The compute now runs only here (own-memory Job), persisting
|
|
`compare_measured_cache`; the cockpit only reads it. This asserts the CLI computes + upserts the artifact,
|
|
is idempotent, and that the web handler no longer runs the heavy builder.
|
|
|
|
NO network: the operational DSN points at a temp sqlite db; the `features` warehouse + cockpit tables are
|
|
seeded on it directly.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import inspect
|
|
|
|
from typer.testing import CliRunner
|
|
|
|
from fxhnt.cli import app
|
|
|
|
_DAY = 86_400
|
|
_AT = dt.datetime(2026, 6, 24, tzinfo=dt.UTC)
|
|
|
|
|
|
def _seed(dsn: str) -> None:
|
|
"""Seed a wide-spread Binance shadow book + sleeve returns on the `features` store, plus a Bybit
|
|
sleeve-ret series, on the temp operational DB — the inputs the precompute reads."""
|
|
from fxhnt.adapters.persistence.paper_repo import PaperRepo
|
|
from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
|
|
from fxhnt.domain.paper import Position
|
|
|
|
repo = PaperRepo(dsn)
|
|
repo.migrate()
|
|
store = TimescaleFeatureStore(dsn, table="features")
|
|
start = dt.date(2021, 1, 1)
|
|
days = 60
|
|
coins = ["AAAUSDT", "BBBUSDT"]
|
|
closes: dict[int, dict[str, float]] = {}
|
|
for ci, coin in enumerate(coins):
|
|
sign = 1.0 if ci % 2 == 0 else -1.0
|
|
px = 100.0
|
|
rows = []
|
|
for i in range(days):
|
|
px *= (1.0 + sign * 0.001)
|
|
half = 0.01 # 200bp wide spread
|
|
ts = (start + dt.timedelta(days=i)).toordinal() * _DAY
|
|
rows.append((ts, {"close": px, "high": px * (1 + half), "low": px * (1 - half)}))
|
|
closes.setdefault((start + dt.timedelta(days=i)).toordinal(), {})[coin] = px
|
|
store.write_features(coin, rows)
|
|
|
|
prior: list[Position] = []
|
|
from fxhnt.application.paper_book import sleeve_daily_return
|
|
for i in range(days):
|
|
d = (start + dt.timedelta(days=i)).isoformat()
|
|
close_d = closes[(start + dt.timedelta(days=i)).toordinal()]
|
|
if prior:
|
|
r = sleeve_daily_return(prior, close_d)
|
|
if r is not None:
|
|
repo.upsert_sleeve_ret(d, "crypto_tstrend", r, at=_AT)
|
|
shadow = [Position("crypto_tstrend", c, (0.5 / close_d[c]), close_d[c], d) for c in coins]
|
|
repo.replace_shadow_positions(d, shadow, at=_AT)
|
|
prior = shadow
|
|
|
|
bstart = dt.date(2021, 1, 20)
|
|
for i in range(30):
|
|
d = (bstart + dt.timedelta(days=i)).isoformat()
|
|
repo.upsert_bybit_sleeve_ret(d, "crypto_tstrend", 0.004 * (1.0 if i % 3 else -0.8), at=_AT)
|
|
store.close()
|
|
|
|
|
|
def _patch(monkeypatch, tmp_path) -> str:
|
|
dsn = f"sqlite:///{tmp_path / 'op.db'}"
|
|
monkeypatch.setenv("FXHNT_OPERATIONAL_DSN", dsn)
|
|
from fxhnt import cli as climod
|
|
climod.get_settings.cache_clear()
|
|
_seed(dsn)
|
|
return dsn
|
|
|
|
|
|
def test_cli_persists_compare_cache(monkeypatch, tmp_path) -> None:
|
|
dsn = _patch(monkeypatch, tmp_path)
|
|
res = CliRunner().invoke(app, ["compare-measured-precompute", "--capital", "100000"])
|
|
assert res.exit_code == 0, res.output
|
|
assert "persisted" in res.output.lower()
|
|
|
|
from fxhnt.adapters.persistence.paper_repo import PaperRepo
|
|
from fxhnt.application.compare_measured_cost import read_cached_measured_compare
|
|
payload = read_cached_measured_compare(PaperRepo(dsn))
|
|
assert payload is not None
|
|
assert payload["rows"] and {r["book"] for r in payload["rows"]} == {"binance_combined", "bybit_4edge"}
|
|
|
|
|
|
def test_cli_is_idempotent(monkeypatch, tmp_path) -> None:
|
|
dsn = _patch(monkeypatch, tmp_path)
|
|
runner = CliRunner()
|
|
r1 = runner.invoke(app, ["compare-measured-precompute", "--capital", "100000"])
|
|
assert r1.exit_code == 0, r1.output
|
|
from fxhnt.adapters.persistence.paper_repo import PaperRepo
|
|
from fxhnt.application.compare_measured_cost import read_cached_measured_compare
|
|
first = read_cached_measured_compare(PaperRepo(dsn))
|
|
|
|
r2 = runner.invoke(app, ["compare-measured-precompute", "--capital", "100000"])
|
|
assert r2.exit_code == 0, r2.output
|
|
second = read_cached_measured_compare(PaperRepo(dsn))
|
|
assert first is not None and second is not None
|
|
assert first["rows"] == second["rows"]
|
|
assert first["equity_a"] == second["equity_a"]
|
|
|
|
|
|
def test_web_compare_handler_has_no_live_measured_compute() -> None:
|
|
"""TRIPWIRE: the cockpit's _compare_context must NOT call the heavy `compare_measured_cost` builder —
|
|
it reads the cache / falls back. Read the create_app source and assert the live builder is absent from
|
|
the measured-read path (only `read_cached_measured_compare` is imported there)."""
|
|
from fxhnt.adapters.web import app as web_app
|
|
src = inspect.getsource(web_app.create_app)
|
|
# the measured-read path imports the READER, never the heavy builder.
|
|
assert "read_cached_measured_compare" in src
|
|
assert "import compare_measured_cost" not in src and "compare_measured_cost(" not in src
|