Repository Public
1"""Signal runner used by the engine (docs/v3/ARCHITECTURE.md, "Strategy contract").23 uv run python -m spq.signal --strategy <dir> --sha256 <hash> --input <json file, or - for stdin>45On success: exactly one JSON object on stdout, exit code 0:67 {"strategy_sha256": "<hex>", "as_of": "<bar-open time of the last closed bar, UTC>", "weights": {"ETH": 0.5, ...}}89On ANY problem: nothing on stdout, a one-line reason on stderr, non-zero exit code. The engine must treat every10non-zero exit (and a timeout, and unparsable output) as "no signal: hold".1112 exit 2 bad command line or bad input (JSON, schema, symbols, timestamps, gaps, not enough bars)13 exit 3 strategy.py does not have the requested SHA-256 (no strategy code is executed)14 exit 4 strategy cannot be loaded (missing file, bad META, fails the static purity check, error at import)15 exit 5 target_weights raised (including an attempt to use the network, a file, the clock or randomness)16 exit 6 target_weights returned invalid weights (not a dict, NaN, outside [0, 1], sum > 1, unknown symbol)17 exit 1 anything else1819Input (see schema/signal-input.schema.json):2021 {"candles": {"ETH": [[ts, open, high, low, close, volume], ...], "LINK": [...]},22 "as_of": "2026-11-10T12:00:00Z", # optional: must be the bar-open time of the last bar supplied23 "interval": "1h"} # optional: must equal META['interval']2425`ts` is the bar-open time (ISO 8601 UTC string, or epoch seconds). Rows must be CLOSED bars, oldest first, strictly26increasing, for exactly the symbols of META['universe'] (for a universe-generic strategy, META['universe'] ==27["*"]: for the symbols the engine chose, 1 to 12 of them, within META['min_symbols'] / META['max_symbols'], and28the weights are validated against that list), with identical timestamps across symbols and at least29META['lookback'] bars; the last `lookback` bars are passed to the strategy. The runner has no clock: it cannot30tell whether the last bar is really closed, that is the engine's duty. Parameters always come from META (they are31part of the hashed file): the input cannot override them, otherwise an approved hash would not pin the behaviour.3233Contract 2 — perpetual futures (META["kind"] = "perp"; docs/v4/quant-perps.md). Nothing above changes for spot34strategies. A perp strategy is only run when the caller passes `--contract 2` (without it: exit 2, so a caller35that only knows the spot contract can never receive signed, leveraged weights by accident); `--contract 2` on a36spot strategy is refused the same way. Input (schema/signal-input-v2.schema.json):3738 {"candles": {"BTC": [[ts, o, h, l, c, v], ...], "BTC.SPOT": [...]}, # every symbol of the universe39 "funding": {"BTC": [[ts, rate], ...] or [[ts, rate, premium], ...]}, # every PERPETUAL of the universe40 "mark": {"BTC": [[ts, price], ...]}, "index": {...}, # optional, perpetuals only41 "as_of": ..., "interval": ...}4243Funding rows are hourly settlements: `ts` = the settlement hour (an instant within 60 s of the hour is attached to44it), `rate` = the fraction of notional longs paid at that instant, oldest first, strictly increasing. Each row is45attached to the bar that OPENS at its hour, exactly as in the backtester. A settlement — or a mark / index value —46dated AFTER the last bar's open time is refused (exit 2): it does not exist yet at the decision. Bars of the47lookback window without a settlement are refused beyond the same limits as missing bars. Output48(schema/signal-output-v2.schema.json), one line:4950 {"schema_version": 2, "kind": "perp", "strategy_sha256": "<hex>", "as_of": "...",51 "weights": {"BTC": -0.5, "ETH": 0.25}, "max_leverage": 2.0, "max_gross_exposure": 2.0}5253Weights are signed fractions of account equity (negative = short), each within +/- max_leverage (a spot leg within54[0, 1]) and their absolute values sum to at most max_gross_exposure; anything else is exit 6. These are TARGETS,55not orders: sizing, margin, liquidation distance and every risk limit are the engine's and the signer's duty.56"""57from __future__ import annotations5859import argparse60import contextlib61import json62import sys6364import pandas as pd6566import numpy as np6768from .data.core import COLUMNS, DataError, align, normalise, to_utc69from .strategy import (HashMismatch, StrategyError, WeightsError, is_generic, is_spot_leg, load_strategy, resolve_symbols,70 validate_perp_weights, validate_weights)7172EXIT_INPUT, EXIT_HASH, EXIT_STRATEGY, EXIT_RUNTIME, EXIT_WEIGHTS = 2, 3, 4, 5, 673FAR_FUTURE = "2262-01-01T00:00:00Z"747576class InputError(ValueError):77 pass787980class _Parser(argparse.ArgumentParser):81 def error(self, message):82 raise InputError(f"command line: {message}")838485def iso_z(ts: pd.Timestamp) -> str:86 return ts.tz_convert("UTC").strftime("%Y-%m-%dT%H:%M:%SZ")878889def parse_input(payload, meta: dict, *, max_gap_bars: int = 6, max_missing_fraction: float = 0.02):90 """Validate the engine's payload against META; return ({symbol: DataFrame of the last `lookback` bars}, as_of)."""91 if not isinstance(payload, dict) or not isinstance(payload.get("candles"), dict):92 raise InputError('input must be an object with a "candles" object')93 unknown_keys = set(payload) - {"candles", "as_of", "interval"}94 if unknown_keys:95 raise InputError(f"unexpected input keys {sorted(unknown_keys)} (parameters cannot be overridden)")96 if "interval" in payload and payload["interval"] != meta["interval"]:97 raise InputError(f"input interval {payload['interval']!r} differs from META interval {meta['interval']!r}")98 candles = payload["candles"]99 if is_generic(meta): # v5: a generic strategy takes its symbols from the input, in the order given100 try:101 universe = resolve_symbols(meta, list(candles))102 except StrategyError as e:103 raise InputError(str(e)) from e104 else:105 universe = meta["universe"]106 if sorted(candles) != sorted(universe):107 raise InputError(f"input symbols {sorted(candles)} differ from META universe {sorted(universe)}")108 data = {}109 for sym in universe:110 rows = candles[sym]111 if not isinstance(rows, list) or not rows:112 raise InputError(f"{sym}: candles must be a non-empty array")113 if not all(isinstance(r, list) and len(r) == 6 for r in rows):114 raise InputError(f"{sym}: every candle must be [ts, open, high, low, close, volume]")115 for r in rows:116 if any(isinstance(x, bool) or not isinstance(x, (int, float)) for x in r[1:]):117 raise InputError(f"{sym}: OHLCV values must be numbers")118 try:119 ts = [to_utc(pd.Timestamp(r[0], unit="s") if isinstance(r[0], (int, float)) else r[0]) for r in rows]120 except Exception as e: # noqa: BLE001121 raise InputError(f"{sym}: unreadable timestamp ({e})") from e122 if any(b <= a for a, b in zip(ts, ts[1:])):123 raise InputError(f"{sym}: timestamps must be strictly increasing (oldest first, no duplicates)")124 raw = pd.DataFrame([r[1:] for r in rows], columns=COLUMNS, index=pd.DatetimeIndex(ts, name="ts"))125 try:126 df, q = normalise(raw, symbol=sym, interval=meta["interval"], source="engine", as_of=FAR_FUTURE)127 except DataError as e:128 raise InputError(str(e)) from e129 if q.off_grid or q.ohlc_violations:130 raise InputError(f"{sym}: {q.off_grid} timestamps off the {meta['interval']} grid, "131 f"{q.ohlc_violations} rows with impossible OHLCV values")132 df = df.iloc[-meta["lookback"]:]133 if len(df) < meta["lookback"]:134 raise InputError(f"{sym}: {len(df)} bars supplied, the strategy needs {meta['lookback']}")135 _, q = normalise(df, symbol=sym, interval=meta["interval"], source="engine", as_of=FAR_FUTURE)136 if q.max_gap_bars > max_gap_bars or q.missing_fraction > max_missing_fraction:137 raise InputError(f"{sym}: {q.missing_bars} missing bars in the lookback window "138 f"(longest gap {q.max_gap_bars}); refusing to compute a signal on a broken series")139 data[sym] = df140 first = data[universe[0]].index141 for sym, df in data.items():142 if not df.index.equals(first):143 raise InputError(f"{sym}: timestamps differ from {universe[0]} (series must be aligned)")144 as_of = first[-1]145 if payload.get("as_of") is not None:146 try:147 wanted = to_utc(payload["as_of"])148 except Exception as e: # noqa: BLE001149 raise InputError(f"unreadable as_of ({e})") from e150 if wanted != as_of:151 raise InputError(f"as_of {payload['as_of']} is not the last bar supplied ({iso_z(as_of)})")152 return align(data), as_of153154155def _rows(obj, sym: str, what: str, widths: tuple[int, ...]) -> list:156 rows = obj.get(sym)157 if not isinstance(rows, list) or not rows:158 raise InputError(f"{sym}: {what} must be a non-empty array")159 if not all(isinstance(r, list) and len(r) in widths for r in rows):160 raise InputError(f"{sym}: every {what} row must have {' or '.join(map(str, widths))} values")161 for r in rows:162 if any(isinstance(x, bool) or not isinstance(x, (int, float)) or x != x or x in (float("inf"), float("-inf"))163 for x in r[1:]):164 raise InputError(f"{sym}: {what} values must be finite numbers")165 try:166 ts = [to_utc(pd.Timestamp(r[0], unit="s") if isinstance(r[0], (int, float)) else r[0]) for r in rows]167 except Exception as e: # noqa: BLE001168 raise InputError(f"{sym}: unreadable {what} timestamp ({e})") from e169 if any(b <= a for a, b in zip(ts, ts[1:])):170 raise InputError(f"{sym}: {what} timestamps must be strictly increasing (oldest first, no duplicates)")171 return [(t, *r[1:]) for t, r in zip(ts, rows)]172173174def _nan_runs(values) -> tuple[int, int]:175 """(number of NaN, longest run of NaN)."""176 bad = np.isnan(np.asarray(values, dtype="float64"))177 longest = run = 0178 for b in bad:179 run = run + 1 if b else 0180 longest = max(longest, run)181 return int(bad.sum()), longest182183184def parse_perp_input(payload, meta: dict, *, max_gap_bars: int = 6, max_missing_fraction: float = 0.02):185 """Contract 2: candles as in parse_input, plus funding (mandatory) and mark / index (optional) for perpetuals.186187 Nothing dated after the decision bar's open time may be in the input."""188 from .data.hyperliquid import FUNDING_TOLERANCE_MS, attach_funding189190 if not isinstance(payload, dict):191 raise InputError('input must be an object with "candles" and "funding" objects')192 unknown_keys = set(payload) - {"candles", "funding", "mark", "index", "as_of", "interval"}193 if unknown_keys:194 raise InputError(f"unexpected input keys {sorted(unknown_keys)} (parameters cannot be overridden)")195 data, as_of = parse_input({k: payload[k] for k in ("candles", "as_of", "interval") if k in payload}, meta,196 max_gap_bars=max_gap_bars, max_missing_fraction=max_missing_fraction)197 perps = [s for s in meta["universe"] if not is_spot_leg(s)]198 funding = payload.get("funding")199 if not isinstance(funding, dict) or sorted(funding) != sorted(perps):200 raise InputError(f'"funding" must be an object with exactly the perpetuals {sorted(perps)}')201 limit = int(len(next(iter(data.values()))) * max_missing_fraction)202 for sym in perps:203 rows = _rows(funding, sym, "funding", (2, 3))204 hour = [t.round("h") for t, *_ in rows]205 if any(abs((t - h).total_seconds()) * 1000 > FUNDING_TOLERANCE_MS for (t, *_), h in zip(rows, hour)):206 raise InputError(f"{sym}: funding timestamps must be hourly settlement times")207 if len(set(hour)) != len(hour):208 raise InputError(f"{sym}: two funding rows for the same settlement hour")209 if hour[-1] > as_of:210 raise InputError(f"{sym}: funding settlement {iso_z(hour[-1])} is dated after the decision bar "211 f"({iso_z(as_of)}): a rate that is not settled yet cannot be an input")212 f = pd.DataFrame({"rate": [r[1] for r in rows], "premium": [r[2] if len(r) > 2 else float("nan") for r in rows]},213 index=pd.DatetimeIndex(hour, name="ts").as_unit("ns"))214 data[sym] = attach_funding(data[sym], f)215 missing, gap = _nan_runs(data[sym]["funding"])216 if missing > limit or gap > max_gap_bars:217 raise InputError(f"{sym}: {missing} bars of the lookback window have no funding settlement "218 f"(longest run {gap}); refusing to compute a signal on a broken funding series")219 for name in ("mark", "index"):220 if name not in payload:221 continue222 series = payload[name]223 if not isinstance(series, dict) or not set(series) <= set(perps) or not series:224 raise InputError(f'"{name}" must be an object keyed by perpetuals of the universe')225 for sym in series:226 rows = _rows(series, sym, name, (2,))227 if rows[-1][0] > as_of:228 raise InputError(f"{sym}: {name} value dated {iso_z(rows[-1][0])} is after the decision bar "229 f"({iso_z(as_of)})")230 if any(v <= 0 for _, v in rows):231 raise InputError(f"{sym}: {name} prices must be positive")232 s = pd.Series([v for _, v in rows], index=pd.DatetimeIndex([t for t, _ in rows]).as_unit("ns"))233 if not s.index[s.index >= data[sym].index[0]].isin(data[sym].index).all():234 raise InputError(f"{sym}: {name} timestamps must be bar-open times of the candles")235 data[sym] = data[sym].assign(**{name: s.reindex(data[sym].index).to_numpy()})236 missing, gap = _nan_runs(data[sym][name])237 if missing > limit or gap > max_gap_bars:238 raise InputError(f"{sym}: {missing} bars of the lookback window have no {name} value")239 return data, as_of240241242def compute_signal(strategy_dir, sha256: str, payload, contract: int = 1, **limits) -> dict:243 strategy = load_strategy(strategy_dir, sha256)244 if strategy.kind == "perp":245 if contract != 2:246 raise InputError("this is a perpetual-futures strategy (contract 2: signed, leveraged weights); "247 "it is only run for a caller that passes --contract 2")248 data, as_of = parse_perp_input(payload, strategy.meta, **limits)249 with contextlib.redirect_stdout(sys.stderr):250 raw = strategy.raw(data)251 weights = validate_perp_weights(raw, strategy.universe, strategy.meta["max_leverage"],252 strategy.meta["max_gross_exposure"])253 return {"schema_version": 2, "kind": "perp", "strategy_sha256": strategy.sha256, "as_of": iso_z(as_of),254 "weights": weights, "max_leverage": float(strategy.meta["max_leverage"]),255 "max_gross_exposure": float(strategy.meta["max_gross_exposure"])}256 if contract != 1:257 raise InputError("--contract 2 was requested but this is a spot strategy (contract 1)")258 data, as_of = parse_input(payload, strategy.meta, **limits)259 with contextlib.redirect_stdout(sys.stderr):260 raw = strategy.raw(data)261 weights = validate_weights(raw, list(data)) # v5: the symbols of the input (== META['universe'] when it is fixed)262 return {"strategy_sha256": strategy.sha256, "as_of": iso_z(as_of), "weights": weights}263264265def main(argv=None) -> int:266 try:267 p = _Parser(prog="python -m spq.signal", description=__doc__.split("\n")[0])268 p.add_argument("--strategy", required=True)269 p.add_argument("--sha256", required=True)270 p.add_argument("--input", required=True, help="JSON file, or - for stdin")271 p.add_argument("--max-gap-bars", type=int, default=6)272 p.add_argument("--max-missing-fraction", type=float, default=0.02)273 p.add_argument("--contract", type=int, choices=(1, 2), default=1,274 help="1 = spot contract (default); 2 = perpetual futures (required to run a perp strategy)")275 try:276 a = p.parse_args(argv)277 except SystemExit as e: # --help278 return int(e.code or 0)279 if len(a.sha256.strip()) != 64 or any(c not in "0123456789abcdefABCDEF" for c in a.sha256.strip()):280 raise InputError("--sha256 must be 64 hexadecimal characters")281 try:282 text = sys.stdin.read() if a.input == "-" else open(a.input, encoding="utf-8").read()283 except OSError as e:284 raise InputError(f"cannot read input: {e}") from e285 try:286 payload = json.loads(text, parse_constant=lambda c: (_ for _ in ()).throw(ValueError(f"{c} not allowed")))287 except ValueError as e:288 raise InputError(f"input is not valid JSON: {e}") from e289 try:290 out = compute_signal(a.strategy, a.sha256, payload, contract=a.contract, max_gap_bars=a.max_gap_bars,291 max_missing_fraction=a.max_missing_fraction)292 except (HashMismatch, StrategyError, InputError, WeightsError):293 raise294 except BaseException as e: # noqa: BLE001 - whatever the strategy raised, SystemExit(0) included295 print(f"spq.signal: strategy failed: {type(e).__name__}: {e}", file=sys.stderr)296 return EXIT_RUNTIME297 line = json.dumps(out, allow_nan=False)298 except InputError as e:299 print(f"spq.signal: input refused: {e}", file=sys.stderr)300 return EXIT_INPUT301 except HashMismatch as e:302 print(f"spq.signal: hash refused: {e}", file=sys.stderr)303 return EXIT_HASH304 except StrategyError as e:305 print(f"spq.signal: strategy refused: {e}", file=sys.stderr)306 return EXIT_STRATEGY307 except WeightsError as e:308 print(f"spq.signal: weights refused: {e}", file=sys.stderr)309 return EXIT_WEIGHTS310 except BaseException as e: # noqa: BLE001311 print(f"spq.signal: {type(e).__name__}: {e}", file=sys.stderr)312 return 1313 sys.stdout.write(line + "\n")314 return 0315316317if __name__ == "__main__":318 sys.exit(main())319