SPB Git

Repository Public

test_signal.py 11.1 KB 220 lines python

1"""Contract of `python -m spq.signal`: one JSON object on stdout and exit 0, or nothing on stdout and exit != 0."""2from __future__ import annotations34import json5import subprocess6import sys7from pathlib import Path89import jsonschema10import pandas as pd11import pytest1213from conftest import ROOT, make_data, write_strategy14from spq import signal as signal_cli15from spq.strategy import sha256_file1617META = {"name": "t", "version": "1", "universe": ["AAA", "BBB"], "interval": "1h", "lookback": 10, "params": {"w": 0.4}}18OUT_SCHEMA = json.loads((ROOT / "schema" / "signal-output.schema.json").read_text())19IN_SCHEMA = json.loads((ROOT / "schema" / "signal-input.schema.json").read_text())202122def payload(n=12, **kw):23    data = make_data(n, **kw)24    return {"candles": {s: [[ts.strftime("%Y-%m-%dT%H:%M:%SZ"), *map(float, row)] for ts, row in25                            zip(df.index, df.to_numpy())] for s, df in data.items()}}262728@pytest.fixture29def env(tmp_path):30    """A valid strategy, its hash, and a runner returning (exit code, stdout, stderr)."""31    d = write_strategy(tmp_path / "strat", "return {s: params['w'] for s in data}\n", meta=META)3233    class Env:34        dir = d35        sha = sha256_file(d / "strategy.py")3637        def run(self, pl=None, *, strategy=None, sha=None, raw=None, args=()):38            f = tmp_path / "input.json"39            f.write_text(raw if raw is not None else json.dumps(pl if pl is not None else payload()))40            return self.cli(["--strategy", str(strategy or d), "--sha256", sha or41                             (sha256_file(Path(strategy) / "strategy.py") if strategy else self.sha),42                             "--input", str(f), *args])4344        def cli(self, argv):45            p = subprocess.run([sys.executable, "-m", "spq.signal", *argv], capture_output=True, text=True,46                               cwd=tmp_path, timeout=120)47            return p.returncode, p.stdout, p.stderr4849        def variant(self, body, meta=META, raw=None):50            return write_strategy(tmp_path / f"v{len(list(tmp_path.iterdir()))}", body, meta=meta, raw=raw)5152    return Env()535455def refused(result, code):56    rc, out, err = result57    assert rc == code, (rc, out, err)58    assert out == "", "nothing may be written on stdout when the signal is refused"59    assert err.startswith("spq.signal:") and len(err.strip().splitlines()) == 160    return err616263def test_success_prints_exactly_one_json_object(env):64    pl = payload()65    jsonschema.Draft202012Validator(IN_SCHEMA).validate(pl)66    rc, out, err = env.run(pl)67    assert rc == 0 and err == ""68    assert out.endswith("\n") and len(out.splitlines()) == 169    obj = json.loads(out)70    jsonschema.Draft202012Validator(OUT_SCHEMA).validate(obj)71    assert set(obj) == {"strategy_sha256", "as_of", "weights"}72    assert obj["strategy_sha256"] == env.sha73    assert obj["as_of"] == pl["candles"]["AAA"][-1][0] == "2024-01-01T11:00:00Z"   # bar-open time of the last bar74    assert obj["weights"] == {"AAA": 0.4, "BBB": 0.4}757677def test_optional_fields_stdin_epoch_timestamps_and_extra_history(env, tmp_path):78    pl = payload(40)79    pl["as_of"], pl["interval"] = pl["candles"]["AAA"][-1][0], "1h"80    for rows in pl["candles"].values():81        for r in rows:82            r[0] = int(pd.Timestamp(r[0]).timestamp())           # epoch seconds are accepted too83    p = subprocess.run([sys.executable, "-m", "spq.signal", "--strategy", str(env.dir), "--sha256", env.sha,84                        "--input", "-"], input=json.dumps(pl), capture_output=True, text=True, cwd=tmp_path)85    assert p.returncode == 0 and json.loads(p.stdout)["as_of"] == "2024-01-02T15:00:00Z"868788def test_only_the_last_lookback_bars_reach_the_strategy(env):89    d = env.variant("return {'AAA': min(1.0, len(data['AAA']) / 100), 'BBB': 0.0}\n")90    rc, out, _ = env.run(payload(50), strategy=d)91    assert rc == 0 and json.loads(out)["weights"] == {"AAA": 0.1, "BBB": 0.0}929394def test_strategy_prints_do_not_pollute_stdout(env):95    d = env.variant("print('debug noise')\nreturn {s: 0.25 for s in data}\n")96    rc, out, err = env.run(strategy=d)97    assert rc == 0 and json.loads(out)["weights"] == {"AAA": 0.25, "BBB": 0.25} and "debug noise" in err9899100# ----------------------------------------------------------------------------------------------- refusals: hash101102def test_refuses_a_hash_mismatch_without_running_the_strategy(env, tmp_path):103    assert "expected" in refused(env.run(sha="0" * 64), 3)104    (env.dir / "strategy.py").write_text((env.dir / "strategy.py").read_text() + "\n# tampered\n")105    refused(env.run(), 3)                                       # the approved hash no longer matches the file106    bomb = env.variant("", raw="raise SystemExit(0)\n")       # would exit 0 if it were executed107    refused(env.run(strategy=bomb, sha="f" * 64), 3)108    refused(env.run(sha="abc"), 2)                              # not a SHA-256 at all109    refused(env.run(sha="z" * 64), 2)110111112# ------------------------------------------------------------------------------------------- refusals: strategy113114def test_refuses_a_strategy_that_cannot_be_loaded(env, tmp_path):115    refused(env.cli(["--strategy", str(tmp_path / "missing"), "--sha256", "0" * 64, "--input", "x.json"]), 2)116    (tmp_path / "x.json").write_text(json.dumps(payload()))117    refused(env.cli(["--strategy", str(tmp_path / "missing"), "--sha256", "0" * 64, "--input", "x.json"]), 4)118    refused(env.run(strategy=env.variant("", meta={**META, "lookback": 0})), 4)119    refused(env.run(strategy=env.variant("", raw="META = 3\ndef target_weights(d, p):\n    return {}\n")), 4)120    refused(env.run(strategy=env.variant("", raw=f"META = {META!r}\n")), 4)121    refused(env.run(strategy=env.variant("", raw="import os\n" + f"META = {META!r}\n"122                                         "def target_weights(d, p):\n    return {}\n")), 4)123    refused(env.run(strategy=env.variant("return {s: open('/etc/hosts').read() for s in data}\n")), 4)124    refused(env.run(strategy=env.variant("", raw="def broken(:\n")), 4)125    refused(env.run(strategy=env.variant("", raw="raise RuntimeError('x')\n")), 4)126127128def test_refuses_when_the_strategy_raises_or_is_impure(env):129    assert "ZeroDivisionError" in refused(env.run(strategy=env.variant("return {s: 1 / 0 for s in data}\n")), 5)130    assert "KeyError" in refused(env.run(strategy=env.variant("return {'AAA': data['ZZZ']}\n")), 5)131    err = refused(env.run(strategy=env.variant(132        "import numpy as np\nreturn {s: float(np.random.rand()) / 2 for s in data}\n")), 5)133    assert "PurityError" in err134    refused(env.run(strategy=env.variant("raise SystemExit(0)\n")), 5)   # cannot fake a success by exiting135    refused(env.run(strategy=env.variant("", raw="raise SystemExit(0)\n")), 5)   # nor at import time136137138# -------------------------------------------------------------------------------------------- refusals: weights139140@pytest.mark.parametrize("body", [141    "return {'AAA': 1.2, 'BBB': 0.0}\n",                 # above 1142    "return {'AAA': -0.1, 'BBB': 0.5}\n",                # short143    "return {'AAA': 0.6, 'BBB': 0.6}\n",                 # leverage144    "return {'AAA': float('nan'), 'BBB': 0.1}\n",145    "return {'AAA': float('inf')}\n",146    "return {'AAA': '0.5'}\n",147    "return {'AAA': None}\n",148    "return {'AAA': 0.5, 'ETH': 0.5}\n",                 # symbol outside the universe149    "return [0.5, 0.5]\n",150    "return None\n",151    "return 0.5\n",152])153def test_refuses_invalid_weights(env, body):154    refused(env.run(strategy=env.variant(body)), 6)155156157# ---------------------------------------------------------------------------------------------- refusals: input158159def test_refuses_bad_input(env, tmp_path):160    def edit(fn, n=12):161        pl = payload(n)162        fn(pl)163        return pl164165    def drop(pl, sym, rows):166        pl["candles"][sym] = [r for i, r in enumerate(pl["candles"][sym]) if i not in rows]167168    cases = {169        "not json": dict(raw="{not json"),170        "json array": dict(raw="[1, 2]"),171        "NaN literal": dict(raw=json.dumps(payload()).replace("100.0", "NaN", 1)),172        "no candles": dict(pl={"as_of": "2024-01-01T11:00:00Z"}),173        "missing symbol": dict(pl=edit(lambda p: p["candles"].pop("BBB"))),174        "extra symbol": dict(pl=edit(lambda p: p["candles"].update(CCC=p["candles"]["AAA"]))),175        "parameter override": dict(pl=edit(lambda p: p.update(params={"w": 1.0}))),176        "wrong interval": dict(pl=edit(lambda p: p.update(interval="5m"))),177        "too few bars": dict(pl=payload(9)),178        "empty series": dict(pl=edit(lambda p: p["candles"].update(AAA=[]))),179        "row of 5": dict(pl=edit(lambda p: p["candles"]["AAA"][3].pop())),180        "string price": dict(pl=edit(lambda p: p["candles"]["AAA"][3].__setitem__(4, "101.5"))),181        "null price": dict(pl=edit(lambda p: p["candles"]["AAA"][3].__setitem__(4, None))),182        "negative price": dict(pl=edit(lambda p: p["candles"]["AAA"][11].__setitem__(4, -1.0))),183        "high below low": dict(pl=edit(lambda p: p["candles"]["AAA"][11].__setitem__(2, 0.01))),184        "bad timestamp": dict(pl=edit(lambda p: p["candles"]["AAA"][3].__setitem__(0, "yesterday"))),185        "newest first": dict(pl=edit(lambda p: p["candles"]["AAA"].reverse())),186        "duplicate bar": dict(pl=edit(lambda p: p["candles"]["AAA"].insert(4, p["candles"]["AAA"][4]))),187        "off grid": dict(pl=edit(lambda p: p["candles"]["AAA"][11].__setitem__(0, "2024-01-01T11:30:00Z"))),188        "gap": dict(pl=edit(lambda p: [drop(p, s, range(50, 57)) for s in ("AAA", "BBB")], n=60)),189        "misaligned": dict(pl=edit(lambda p: drop(p, "BBB", [11]), n=13)),190        "stale as_of": dict(pl=edit(lambda p: p.update(as_of="2024-01-01T12:00:00Z"))),191        "unreadable as_of": dict(pl=edit(lambda p: p.update(as_of="soon"))),192    }193    for name, kw in cases.items():194        rc, out, err = env.run(**kw)195        assert (rc, out) == (2, ""), f"{name}: rc={rc} out={out!r} err={err!r}"196        assert err.startswith("spq.signal: input refused"), name197    refused(env.cli(["--strategy", str(env.dir), "--sha256", env.sha, "--input", str(tmp_path / "absent.json")]), 2)198    refused(env.cli(["--strategy", str(env.dir), "--sha256", env.sha]), 2)            # --input missing199    refused(env.cli(["--strategy", str(env.dir), "--input", "x"]), 2)                   # --sha256 missing200201202def test_gap_tolerance_is_explicit(env):203    pl = payload(60)204    for s in pl["candles"]:205        del pl["candles"][s][55]                      # one missing bar inside the lookback window of 10206    refused(env.run(pl), 2)                           # 1 of 10 bars missing: above the default 2 %207    rc, out, _ = env.run(pl, args=["--max-missing-fraction", "0.2"])208    assert rc == 0 and json.loads(out)["weights"] == {"AAA": 0.4, "BBB": 0.4}209    refused(env.run(pl, args=["--max-missing-fraction", "0.2", "--max-gap-bars", "0"]), 2)210211212def test_in_process_entry_point_matches(env, tmp_path, capsys):213    f = tmp_path / "in.json"214    f.write_text(json.dumps(payload()))215    assert signal_cli.main(["--strategy", str(env.dir), "--sha256", env.sha, "--input", str(f)]) == 0216    out = capsys.readouterr().out217    assert json.loads(out)["weights"] == {"AAA": 0.4, "BBB": 0.4}218    assert signal_cli.main(["--strategy", str(env.dir), "--sha256", "1" * 64, "--input", str(f)]) == 3219    assert capsys.readouterr().out == ""220
Files main
Menu

Theme