Repository Public
1"""Strategy contract v2 (META['kind'] = 'perp'): META, weights, identity, purity, describe and signal CLIs.23The spot contract must not move: a META without 'kind' behaves exactly as before, and the spot outputs of the4two CLIs are byte-for-byte what they were."""5from __future__ import annotations67import json8import subprocess9import sys1011import numpy as np12import pandas as pd13import pytest1415from conftest import ROOT, make_data, write_strategy16from perp_helpers import perp_data, perp_meta17from spq import describe as describe_cli18from spq import signal as signal_cli19from spq.report import load_schema20from spq.strategy import (PurityError, StrategyError, WeightsError, from_function, load_strategy, sha256_file,21 validate_meta, validate_perp_weights, validate_weights)2223SPOT_META = {"name": "s", "version": "1", "universe": ["AAA", "BBB"], "interval": "1h", "lookback": 10, "params": {}}24PERP_META = perp_meta(("BTC", "ETH"), lookback=10, max_leverage=3.0, max_gross=4.0, name="p")25BODY = "return {'BTC': -1.5, 'ETH': 0.5}\n"262728def test_perp_meta_validation():29 assert validate_meta(PERP_META)["kind"] == "perp"30 assert validate_meta({**SPOT_META, "kind": "spot"}) and validate_meta(SPOT_META) # absent = spot31 for change in ({"kind": "future"}, {"kind": None}, {"max_leverage": 0}, {"max_leverage": -1}, {"max_leverage": "2"},32 {"max_leverage": True}, {"max_leverage": float("inf")}, {"max_leverage": 51.0},33 {"max_gross_exposure": 0}, {"max_gross_exposure": 6.5}, # > max_leverage x 2 perpetuals34 {"universe": ["BTC.SPOT"]}, {"universe": ["BTC", "ETH.SPOT"]}, # a spot leg needs its perpetual35 {"extra": 1}):36 with pytest.raises(StrategyError):37 validate_meta({**PERP_META, **change})38 for key in ("max_leverage", "max_gross_exposure"):39 with pytest.raises(StrategyError, match="missing"):40 validate_meta({k: v for k, v in PERP_META.items() if k != key})41 # the perp keys are refused on a spot META, and so are spot legs: the spot contract has not grown42 for change in ({"max_leverage": 2.0}, {"max_gross_exposure": 1.0}, {"kind": "spot", "max_leverage": 2.0},43 {"universe": ["AAA", "AAA.SPOT"]}):44 with pytest.raises(StrategyError):45 validate_meta({**SPOT_META, **change})46 ok = perp_meta(("BTC", "BTC.SPOT"), max_leverage=1.0, max_gross=2.0)47 assert validate_meta(ok)["universe"] == ["BTC", "BTC.SPOT"]484950def test_perp_weights_validation():51 sym = ["BTC", "ETH", "BTC.SPOT"]52 v = lambda w, lev=2.0, gross=3.0: validate_perp_weights(w, sym, lev, gross) # noqa: E73153 assert v({"BTC": -2.0, "ETH": 0.5}) == {"BTC": -2.0, "ETH": 0.5, "BTC.SPOT": 0.0} # missing symbols are 054 assert v({"BTC": np.float64(-1.0), "ETH": 1, "BTC.SPOT": 1.0})["BTC.SPOT"] == 1.055 assert v({"BTC": 2.0 + 5e-10})["BTC"] == 2.0 and v({"BTC.SPOT": -5e-10})["BTC.SPOT"] == 0.0 # snapped56 for bad in ({"BTC": 2.1}, {"BTC": -2.1}, {"BTC.SPOT": -0.1}, {"BTC.SPOT": 1.1}, # per-symbol bounds57 {"BTC": 2.0, "ETH": -1.5}, # gross 3.5 > 358 {"BTC": float("nan")}, {"BTC": float("inf")}, {"BTC": "1"}, {"BTC": True}, {"SOL": 0.1}, None, [1.0]):59 with pytest.raises(WeightsError):60 v(bad)61 # the spot validator is untouched: a negative weight is still refused there62 with pytest.raises(WeightsError):63 validate_weights({"BTC": -0.5}, ["BTC"])646566def test_loaded_perp_strategy_identity_purity_and_validation(strategy_dir):67 d = strategy_dir(BODY, meta=PERP_META)68 s = load_strategy(d)69 assert s.kind == "perp" and s.sha256 == sha256_file(d / "strategy.py") and s.static_problems == []70 data = perp_data(20, ("BTC", "ETH"))71 assert s(data) == {"BTC": -1.5, "ETH": 0.5}72 # the hash covers META: changing the leverage cap is another strategy73 other = load_strategy(strategy_dir(BODY, meta={**PERP_META, "max_leverage": 2.0}))74 assert other.sha256 != s.sha256 and other(data) == {"BTC": -1.5, "ETH": 0.5} # still inside 2x757677def test_loaded_perp_strategy_caps(strategy_dir):78 data = perp_data(20, ("BTC", "ETH"))79 tight = load_strategy(strategy_dir(BODY, meta={**PERP_META, "max_leverage": 1.0, "max_gross_exposure": 2.0}))80 with pytest.raises(WeightsError, match="max_leverage is 1"):81 tight(data)82 gross = load_strategy(strategy_dir(BODY, meta={**PERP_META, "max_gross_exposure": 1.9}))83 with pytest.raises(WeightsError, match="gross exposure"):84 gross(data)85 # the purity guard and the static check apply to perp strategies as to spot ones86 with pytest.raises(StrategyError, match="purity"):87 load_strategy(strategy_dir("import os\nreturn {}\n", meta=PERP_META))88 sneaky = load_strategy(strategy_dir("import time\ntime.time()\nreturn {}\n", meta=PERP_META), strict=False)89 with pytest.raises(PurityError):90 sneaky(data)91 # inputs are copy-on-write: writing into the funding column cannot reach the caller's data92 writer = from_function(lambda d, p: (d["BTC"].__setitem__("funding", 9.0), {"BTC": 0.1})[1],93 perp_meta(("BTC", "ETH")))94 before = data["BTC"]["funding"].copy()95 writer({s_: df.iloc[:] for s_, df in data.items()})96 pd.testing.assert_series_equal(data["BTC"]["funding"], before)979899# ------------------------------------------------------------------------------------------------- describe CLI100101def describe(args, capsys):102 code = describe_cli.main(args)103 out = capsys.readouterr()104 return code, out.out, out.err105106107def test_describe_spot_output_is_unchanged_and_perp_needs_contract_2(strategy_dir, capsys):108 spot = strategy_dir("return {s: 0.5 for s in data}\n")109 code, out, err = describe(["--strategy", str(spot)], capsys)110 assert code == 0 and set(json.loads(out)) == {"sha256", "meta", "generic"} # the version-1 output (plus v5 "generic")111 perp = strategy_dir(BODY, meta=PERP_META)112 code, out, err = describe(["--strategy", str(perp)], capsys)113 assert code == 4 and out == "" and "contract 2" in err # not offered to a v1 caller114 code, out, err = describe(["--strategy", str(perp), "--contract", "2"], capsys)115 got = json.loads(out)116 assert code == 0 and got["contract"] == 2 and got["kind"] == "perp" and got["sha256"] == sha256_file(perp / "strategy.py")117 assert got["meta"]["max_leverage"] == 3.0 and got["meta"]["max_gross_exposure"] == 4.0118 code, out, err = describe(["--strategy", str(spot), "--contract", "2"], capsys)119 assert code == 0 and json.loads(out)["contract"] == 1 and json.loads(out)["kind"] == "spot"120121122# --------------------------------------------------------------------------------------------------- signal CLI123124def rows(df):125 return [[ts.strftime("%Y-%m-%dT%H:%M:%SZ"), *map(float, r)] for ts, r in zip(df.index, df[["open", "high", "low", "close", "volume"]].to_numpy())]126127128def payload(data, perps=("BTC", "ETH")):129 return {"candles": {s: rows(df) for s, df in data.items()},130 "funding": {s: [[ts.strftime("%Y-%m-%dT%H:%M:%SZ"), float(f), float(p)] for ts, f, p in131 zip(data[s].index, data[s]["funding"], data[s]["premium"])] for s in perps}}132133134def run_signal(tmp_path, directory, sha, body, *extra, capsys=None):135 f = tmp_path / "in.json"136 f.write_text(body if isinstance(body, str) else json.dumps(body))137 code = signal_cli.main(["--strategy", str(directory), "--sha256", sha, "--input", str(f), *extra])138 out = capsys.readouterr()139 return code, out.out, out.err140141142FUNDING_BODY = """f = data['BTC']['funding'].to_numpy()143return {'BTC': -1.5 if f[-1] > f.mean() else 1.5, 'ETH': float(len(f)) / 100.0}144"""145146147def test_signal_v2_output_and_schema(strategy_dir, tmp_path, capsys):148 import jsonschema149150 d = strategy_dir(FUNDING_BODY, meta=PERP_META)151 sha = sha256_file(d / "strategy.py")152 data = perp_data(40, ("BTC", "ETH"), seed=3)153 code, out, err = run_signal(tmp_path, d, sha, payload(data), "--contract", "2", capsys=capsys)154 assert code == 0, err155 got = json.loads(out)156 f = data["BTC"]["funding"].to_numpy()[-10:]157 assert got == {"schema_version": 2, "kind": "perp", "strategy_sha256": sha,158 "as_of": data["BTC"].index[-1].strftime("%Y-%m-%dT%H:%M:%SZ"),159 "weights": {"BTC": -1.5 if f[-1] > f.mean() else 1.5, "ETH": 0.1}, # the last `lookback` rows160 "max_leverage": 3.0, "max_gross_exposure": 4.0}161 out_schema = json.loads((ROOT / "schema" / "signal-output-v2.schema.json").read_text())162 in_schema = json.loads((ROOT / "schema" / "signal-input-v2.schema.json").read_text())163 jsonschema.Draft202012Validator(out_schema).validate(got)164 jsonschema.Draft202012Validator(in_schema).validate(payload(data))165 # the version-1 output schema refuses this object: a v1 consumer cannot mistake it for spot weights166 v1 = json.loads((ROOT / "schema" / "signal-output.schema.json").read_text())167 assert not jsonschema.Draft202012Validator(v1).is_valid(got)168 # settlement instants a few ms after the hour (as the venue stamps them) and epoch seconds are accepted169 p = payload(data)170 p["funding"]["BTC"] = [[r[0][:-1] + ".139Z", r[1]] for r in p["funding"]["BTC"]]171 p["funding"]["ETH"] = [[int(pd.Timestamp(r[0]).timestamp()), r[1]] for r in p["funding"]["ETH"]]172 code, out2, err = run_signal(tmp_path, d, sha, p, "--contract", "2", capsys=capsys)173 assert code == 0 and json.loads(out2)["weights"] == got["weights"], err174 # optional mark and index series175 p = payload(data)176 p["mark"] = {"BTC": [[r[0], r[4]] for r in p["candles"]["BTC"]]}177 p["index"] = {"BTC": [[r[0], r[4] * 0.999] for r in p["candles"]["BTC"]]}178 assert run_signal(tmp_path, d, sha, p, "--contract", "2", capsys=capsys)[0] == 0179180181def test_signal_v2_refusals(strategy_dir, tmp_path, capsys):182 d = strategy_dir(FUNDING_BODY, meta=PERP_META)183 sha = sha256_file(d / "strategy.py")184 data = perp_data(40, ("BTC", "ETH"), seed=3)185 good = payload(data)186 last = good["candles"]["BTC"][-1][0]187 nxt = (pd.Timestamp(last) + pd.Timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ")188189 def refused(body, *extra, code=2, match=""):190 c, out, err = run_signal(tmp_path, d, sha, body, *(extra or ("--contract", "2")), capsys=capsys)191 assert c == code and out == "" and match in err, (c, err)192193 def mod(**changes):194 p = json.loads(json.dumps(good))195 for path, value in changes.items():196 node, keys = p, path.split("__")197 for k in keys[:-1]:198 node = node[k]199 if value is None:200 node.pop(keys[-1])201 else:202 node[keys[-1]] = value203 return p204205 # a perp strategy is never run for a caller that did not ask for contract 2206 refused(good, "--max-gap-bars", "6", match="contract 2")207 refused(good, "--contract", "1", match="contract 2")208 # THE look-ahead refusal: a settlement, a mark or an index value dated after the decision bar209 refused(mod(funding__BTC=good["funding"]["BTC"] + [[nxt, 0.0001]]), match="after the decision bar")210 refused(mod(mark={"BTC": [[r[0], r[4]] for r in good["candles"]["BTC"]] + [[nxt, 100.0]]}), match="after the decision bar")211 refused(mod(index={"ETH": [[r[0], r[4]] for r in good["candles"]["ETH"]] + [[nxt, 100.0]]}), match="after the decision bar")212 # funding is mandatory, for exactly the perpetuals213 refused(mod(funding=None), match="funding")214 refused(mod(funding__ETH=None), match="exactly the perpetuals")215 refused(mod(funding__SOL=[[last, 0.0]]), match="exactly the perpetuals")216 refused(mod(funding__BTC=[]), match="non-empty")217 refused(mod(funding__BTC=[[last, "0.0001"]]), match="finite numbers")218 refused(mod(funding__BTC=[[last, float("nan")]]), match="") # NaN is not JSON219 refused(mod(funding__BTC=[[last]]), match="2 or 3 values")220 refused(mod(funding__BTC=list(reversed(good["funding"]["BTC"]))), match="strictly increasing")221 refused(mod(funding__BTC=[[last[:14] + "30:00Z", 0.0001]]), match="hourly settlement")222 refused(mod(funding__BTC=good["funding"]["BTC"][:-3]), match="no funding settlement") # hole at the end223 refused(mod(funding__BTC=good["funding"]["BTC"][:33] + good["funding"]["BTC"][34:]), match="no funding settlement")224 refused(mod(mark={"SOL": [[last, 1.0]]}), match="perpetuals of the universe")225 refused(mod(mark={"BTC": [[last, -1.0]]}), match="positive")226 refused(mod(params={"x": 1}), match="unexpected input keys")227 refused(mod(candles__BTC=good["candles"]["BTC"][:5]), match="bars supplied")228 # exit codes of the version-1 runner are kept: hash 3, strategy 4, runtime 5, weights 6229 c, out, err = run_signal(tmp_path, d, "0" * 64, good, "--contract", "2", capsys=capsys)230 assert c == 3 and out == ""231 over = strategy_dir("return {'BTC': -3.5}\n", meta=PERP_META) # beyond max_leverage 3232 c, out, err = run_signal(tmp_path, over, sha256_file(over / "strategy.py"), good, "--contract", "2", capsys=capsys)233 assert c == 6 and out == "" and "max_leverage" in err234 gross = strategy_dir("return {'BTC': -3.0, 'ETH': 1.5}\n", meta=PERP_META) # gross 4.5 > 4235 c, out, err = run_signal(tmp_path, gross, sha256_file(gross / "strategy.py"), good, "--contract", "2", capsys=capsys)236 assert c == 6 and "gross exposure" in err237 boom = strategy_dir("raise RuntimeError('x')\n", meta=PERP_META)238 assert run_signal(tmp_path, boom, sha256_file(boom / "strategy.py"), good, "--contract", "2", capsys=capsys)[0] == 5239 net = strategy_dir("import socket\nsocket.socket()\nreturn {}\n", meta=PERP_META)240 assert run_signal(tmp_path, net, sha256_file(net / "strategy.py"), good, "--contract", "2", capsys=capsys)[0] == 4241242243def test_signal_spot_contract_is_unchanged(strategy_dir, tmp_path, capsys):244 import jsonschema245246 d = strategy_dir("return {s: 0.25 for s in data}\n")247 sha = sha256_file(d / "strategy.py")248 data = make_data(12)249 body = {"candles": {s: rows(df) for s, df in data.items()}}250 code, out, err = run_signal(tmp_path, d, sha, body, capsys=capsys)251 got = json.loads(out)252 assert code == 0 and set(got) == {"strategy_sha256", "as_of", "weights"} and got["weights"] == {"AAA": 0.25, "BBB": 0.25}253 jsonschema.Draft202012Validator(json.loads((ROOT / "schema" / "signal-output.schema.json").read_text())).validate(got)254 # a spot strategy asked through contract 2, or given perp inputs, is refused255 assert run_signal(tmp_path, d, sha, body, "--contract", "2", capsys=capsys)[0] == 2256 assert run_signal(tmp_path, d, sha, {**body, "funding": {}}, capsys=capsys)[0] == 2257258259def test_signal_v2_as_a_subprocess(strategy_dir, tmp_path):260 d = strategy_dir(FUNDING_BODY, meta=PERP_META)261 data = perp_data(15, ("BTC", "ETH"), seed=8)262 f = tmp_path / "in.json"263 f.write_text(json.dumps(payload(data)))264 cmd = [sys.executable, "-m", "spq.signal", "--strategy", str(d), "--sha256", sha256_file(d / "strategy.py"),265 "--input", str(f)]266 p = subprocess.run(cmd + ["--contract", "2"], capture_output=True, text=True, cwd=ROOT, timeout=120)267 assert p.returncode == 0 and json.loads(p.stdout)["kind"] == "perp" and p.stdout.count("\n") == 1268 p = subprocess.run(cmd, capture_output=True, text=True, cwd=ROOT, timeout=120)269 assert p.returncode == 2 and p.stdout == "" and "contract 2" in p.stderr270271272def test_report_schema_v1_file_is_untouched():273 """The version-1 schema is what existing consumers validate against: it still says schema_version 1."""274 assert load_schema()["properties"]["schema_version"] == {"const": 1}275