Repository Public
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