SPB Git

Repository Public

test_perp_strategy.py 15.5 KB 275 lines python

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
Files main
Menu

Theme