Repository Public
1# spq — research and backtesting laboratory (SP Wallet V3)23Python lab of the **research zone** of `docs/v3/ARCHITECTURE.md`: it reads market data, runs long-only spot4strategies, and writes reports. It proposes; it never executes. Nothing here places an order, holds a key, talks to5the signer or changes a mandate.67```8research/9 pyproject.toml, uv.lock Python 3.12, pinned dependencies (uv)10 src/spq/11 data/ load_candles (PostgreSQL `candles` table or Parquet/CSV), quality checks, fetch (Coinbase, Kraken)12 strategy.py strategy contract: loader, SHA-256 identity, META validation, purity guard, weight validation13 engine.py event-driven backtester (decision on closed bar, fill at next open, fee + slippage) + benchmarks14 metrics.py per-split metrics dsr.py probabilistic / deflated Sharpe ratio15 research.py chronological splits, Experiment (test split enforcement, trial ledger), grids16 robustness.py sensitivity, walk-forward, block bootstrap, Monte Carlo, stress windows, look-ahead check17 report.py JSON report + schema validation18 backtest.py CLI: one strategy -> report signal.py CLI used by the engine pipeline.py whole library19 schema/ backtest-report.schema.json, signal-input.schema.json, signal-output.schema.json20 strategies/<name>/<version>/strategy.py + README.md (hypothesis)21 results/2026-11-10/ reports of the library on real data (see "Results" below)22 tests/ uv run pytest23```2425## Quick start2627```bash28cd research29uv sync30uv run pytest # 136 tests (+1 live-network test: SPQ_NETWORK_TESTS=1)3132# public, keyless data into ./data (ignored by git)33uv run python -m spq.data.fetch --symbols ETH,BTC,LINK,SOL --interval 1h --start 2024-11-10 --end 2026-11-10 --out data34uv run python -m spq.data.fetch --symbols ETH,BTC,LINK,SOL --interval 1h --start 2026-10-01 --source kraken --out data3536# one strategy -> one report37uv run python -m spq.backtest --strategy strategies/ma_trend/1 --symbols ETH,LINK --interval 1h \38 --start 2024-11-10 --end 2026-11-10 --fee-bps 10 --slippage-bps 10 --data-dir data --out report.json3940# every strategy of the library, with a shared trial count41uv run python -m spq.pipeline --symbols ETH,BTC,LINK,SOL --interval 1h --start 2024-11-10 --end 2026-11-10 \42 --data-dir data --out results/2026-11-10 --min-order 5 --rebalance-threshold 0.05 --decision-every garch_regime=2443```4445## Data4647`spq.data.load_candles(symbol, interval, start, end, ...)` returns `(DataFrame, Quality)`; `load_universe` does it48for several symbols and keeps their common timestamps.4950- **PostgreSQL (production path)**: `database_url=` or `DATABASE_URL`. Reads the engine's table51 `candles(source, symbol, interval, ts, open, high, low, close, volume)`, primary key52 `(source, symbol, interval, ts)`, `ts` = bar-open time UTC, exactly as in the architecture document. The session is53 opened read-only. `source=` (or `SPQ_CANDLE_SOURCE`) chooses the source; if the table holds several sources for a54 series and none is chosen, the loader refuses instead of mixing them. Written against the documented schema and55 tested against a throw-away PostgreSQL 17 instance; the ingester did not exist yet, so it has not been run56 against the real table.57- **Files (tests, offline)**: `root=` or `SPQ_DATA_DIR`, layout `<root>/<source>/<SYMBOL>_<interval>.parquet|csv`58 with a `ts` column.59- **Downloader** `python -m spq.data.fetch`: Coinbase Exchange (`/products/{SYM}-USD/candles`, full history, 30060 bars per request) and Kraken (`/0/public/OHLC`, **last 720 bars only**: a cross-check of recent bars, not a61 history). Both endpoints were queried live on 2026-11-10.6263Every loader returns **closed bars only** (a bar is closed when `ts + interval <= now`), UTC, oldest first.64Duplicated timestamps raise (`on_duplicate="drop_identical"` merges exact copies; conflicting copies always raise).65Gaps are counted and listed in `Quality` (`on_gap="raise"` to refuse them) and are **never filled**; the backtester66simply moves to the next existing bar. `Quality.ok()` is the hard yes/no (no OHLC violation, no off-grid timestamp,67at most 0.5 % missing bars, no gap longer than 24 bars).6869## Writing a strategy7071A strategy version is a directory `strategies/<name>/<version>/` with `strategy.py` (and a `README.md` stating the72hypothesis and what would refute it). Its identity is the SHA-256 of `strategy.py`.7374```python75META = {"name": "eth_trend", "version": "1", "universe": ["ETH", "LINK"], "interval": "1h", "lookback": 720,76 "params": {"fast": 24, "slow": 168}}7778def target_weights(data, params):79 """data[symbol]: OHLCV DataFrame indexed by UTC bar-open time, closed bars only, oldest first, at most80 `lookback` rows. Return {symbol: weight}, each in [0, 1], sum <= 1; the rest stays in USDC."""81 out = {}82 for symbol, df in data.items():83 close = df["close"].to_numpy()84 up = len(close) >= params["slow"] and close[-params["fast"]:].mean() > close[-params["slow"]:].mean()85 out[symbol] = 1.0 / len(data) if up else 0.086 return out87```8889Rules enforced by `spq.strategy`:9091- `META` has exactly the six keys above; `lookback` is the number of closed bars the function needs (the engine92 passes exactly that many and makes no decision before they exist). Choose it larger than the longest window so93 that the sensitivity grid (which doubles each numeric parameter) still has enough history.94- Weights: a dict, finite numbers in [0, 1], known symbols only, sum ≤ 1 (tolerance 1e-9). Missing symbols mean 0.95 Anything else stops the backtest and makes the signal runner exit non-zero.96- **Pure function.** No memory between calls: if a rule needs a state ("in position since the breakout"), rebuild97 it from the window (see `donchian_breakout`). Parameters live in `META["params"]`: they are part of the hashed98 file, so approving a hash approves the parameters. To try other parameters for real, write a new version.99- **v8 — what the purity guard is.** A lint for honest mistakes and research hygiene, NOT a security boundary. The100 security boundary is the OS sandbox of every process that loads a strategy (`docs/v8/SANDBOX.md`; its tests run with101 this lint disabled). The static check (`spq.purity`) was a short deny-list and was trivially evaded — 17 of the 19102 patterns of `tests/evasions.py` passed it, nine of them shown to execute (`from typing import sys`,103 `pd.io.common.os`, `np.ctypeslib`, `(x for x in ()).gi_frame.f_builtins`, `locals()["__builtins__"]`, a `match` class104 pattern reading `__globals__`, `pd.eval`, …). It is now an allow-list policy: listed modules used through a reviewed105 API, a module object never used as a value, no dunder or `_private` access anywhere, no `class` / decorator / global106 state, no string evaluation (`.format`, `.eval`, `.query`, string annotations), `numpy.random` refused statically107 (exit 4 at load instead of 5 at run time). The 26 library strategies pass unchanged (same files, same hashes).108- Purity is enforced as far as an in-process check can:109 1. *static* (AST): imports limited to `math`, `numpy`, `pandas`, `scipy`, `statsmodels`, `arch`, `sklearn` and a110 few stdlib helpers; `open`, `eval`, `exec`, `__import__`, `getattr`, `.now()`, `.today()`, `read_csv`, `load`,111 `global`… are refused;112 2. *runtime guard* around each call: sockets, DNS, `open`/`io.open`/`os.open`, subprocess, `time.time` & co,113 `os.urandom` and the global numpy random functions raise `PurityError`;114 3. inputs are copy-on-write views: writing into them cannot reach the engine's data.115116 **Limits.** This is a guard against mistakes, not a security sandbox. C-level entry points cannot be patched117 (`datetime.now()`, `pandas.Timestamp.now()` are only caught by the static check), module-level code runs before118 the guard, and determined code can reach the originals through introspection, `ctypes` or a C extension. The guard119 patches module attributes process-wide, so it is not thread-safe. The real protection is the architecture: the120 research zone has no keys, the engine must run `spq.signal` in an OS-level sandbox (no network, read-only121 filesystem) with a timeout, and the signer enforces the mandate whatever a strategy returns.122123## Backtesting124125```126close of bar t strategy sees bars <= t (at most `lookback`) -> target weights127open of bar t+1 engine trades towards the targets at open[t+1]: buys pay open*(1+slippage) + fee,128 sells receive open*(1-slippage) - fee (both in bps of traded notional); sells first129close of bar t+1 portfolio marked to market130```131132The strategy is given `df.iloc[t+1-lookback : t+1]` and nothing else: the bar it is filled on is not in its inputs.133Tests (`tests/test_lookahead.py`) check that inputs end at the decision bar, that a rule which would be a money134machine with one leaked bar earns nothing, that shifted data give shifted results, that changing future bars does135not change the past, and that the truncation check used in every report detects a deliberately leaky engine.136137Options: `--min-order` (USD; smaller orders are skipped), `--rebalance-threshold` (a symbol whose weight is within138this distance of its target is left alone; default 0.05 in the CLI — with 0 the portfolio is rebalanced at every139bar; a full exit is never blocked), `--decision-every N` (ask the strategy every N bars, anchored on UTC), `--split`140(default 0.6,0.2,0.2), `--grid` (explicit sensitivity grid), `--ledger` (persistent trial count).141142Research hygiene is in the API, not in a convention:143144- `Experiment` hands selection code (`trial`, `run`, `parameter_sensitivity`, `walk_forward`) data truncated before145 the test split; asking for the test split raises `TestSplitError`.146- `Experiment.final_test()` is the only way to the test split. It can be evaluated once per strategy name; after147 that, any further trial for that strategy and any second evaluation with other parameters raise.148- `spq.backtest` always evaluates the test split with `META["params"]`. The grid is reported as a robustness149 analysis and never selects anything.150- Every configuration run on train + validation is a **trial**; every look at a test split is counted. With151 `--ledger file.jsonl` both survive across runs (nothing stops you from deleting the file: the ledger makes152 repeated looks visible, it cannot make them impossible).153154## Reading a report155156`schema/backtest-report.schema.json` is the reference (the report is validated before it is written).157158| Key | Meaning |159|---|---|160| `strategy_sha256`, `config` | what was run: hash, symbols, interval, data range, costs, execution options, parameters |161| `splits` | `[first bar, last bar]` of train / validation / test (chronological, bar-open times) |162| `metrics.<split>` | each split backtested from cash. `sharpe`, `sortino`, `volatility` annualised with the bar interval (8 760 bars/year at 1h, 24/7), risk-free 0. `max_drawdown` is a positive fraction. `turnover` = traded notional / average equity over the split. `trades` = fills. `win_rate` / `profit_factor` on closed lots (each sell against average cost, open positions marked at the end). `fees_paid`, `slippage_paid` in USD. `exposure` = average invested fraction. `returns` = per-bar distribution. Undefined ratios are `null`. |163| `benchmarks` | same metrics on the **test** split: `buy_and_hold` (equal weights bought once), `equal_weight` (rebalanced with the same threshold), `buy_and_hold_<SYM>` |164| `robustness.parameter_sensitivity` | train and validation Sharpe/return of each configuration of the grid (default: every numeric parameter halved and doubled, one at a time). A result that only exists for one cell is fragile. |165| `robustness.walk_forward` | 4 rolling folds inside train + validation: best configuration in-sample, then run out-of-sample |166| `robustness.bootstrap_sharpe_ci` | 95 % stationary-block-bootstrap interval of the test Sharpe ratio. If it contains 0, the sign of the Sharpe ratio is not established. |167| `robustness.monte_carlo` | spread of total return / drawdown when bars are resampled, and of P&L when closed lots are resampled (dependence on a few trades) |168| `robustness.stress_windows` | strategy vs benchmark during the benchmark's three largest drawdowns of the test split |169| `robustness.trials_tested`, `deflated_sharpe` | number of configurations tried and the Deflated Sharpe Ratio (Bailey & López de Prado 2014): probability that the true Sharpe ratio is positive given that many trials, the variance of their validation Sharpe ratios, and the skewness, kurtosis and length of the test returns. Below 0.95, "no skill" is not rejected. Inputs are in `deflated_sharpe_detail`. |170| `robustness.test_evaluations` | how many times this strategy name looked at a test split (ledger) |171| `checks.lookahead` | truncation test on the last 1 500 test bars |172| `checks.data_quality` | `pass` only if every series passes `Quality.ok()`; `notes` lists gaps and cross-source deviation |173| `equity_curve` | test split, `[bar-open time, equity at that bar's close]` |174175A report never says a strategy is profitable. It says what was measured, on which data, with which costs.176177## Signal runner (engine side)178179```180uv run python -m spq.signal --strategy <dir> --sha256 <hash> --input <file.json | ->181```182183Prints one line `{"strategy_sha256", "as_of", "weights": {symbol: number}}` and exits 0, or prints nothing on184stdout, one line on stderr, and exits non-zero: 2 bad input, 3 hash mismatch (no strategy code is executed), 4185strategy cannot be loaded, 5 the strategy raised or broke purity, 6 invalid weights, 1 anything else.186187The architecture document does not define the input; this is what the runner expects188(`schema/signal-input.schema.json`), **to be agreed with the engine**:189190```json191{"candles": {"ETH": [["2026-11-10T11:00:00Z", open, high, low, close, volume], ...], "LINK": [...]},192 "as_of": "2026-11-10T11:00:00Z", "interval": "1h"}193```194195Exactly the symbols of `META["universe"]`, closed bars, oldest first, strictly increasing, identical timestamps196across symbols, at least `lookback` bars (the last `lookback` are used). `as_of` and `interval` are optional197cross-checks. `as_of` in the output is the bar-open time of the last bar used. The runner has no clock: it cannot198know whether the last bar is really closed — the engine must guarantee it. Parameters cannot be passed in the199input. Missing bars inside the lookback window are refused beyond 2 % of the window or a gap of more than 6 bars200(`--max-missing-fraction`, `--max-gap-bars`).201202## Strategy library203204All on ETH, BTC, LINK, SOL at 1h, parameters fixed before any backtest. Each directory has a README with the205hypothesis and what would refute it.206207| Strategy | Idea |208|---|---|209| `ts_momentum/1` | hold an asset while its 30-day return is positive |210| `ma_trend/1` | hold while the 3-day mean is above the 20-day mean |211| `mean_reversion/1` | scale in as the price falls below its 48-hour mean (z-score), out at the mean |212| `donchian_breakout/1` | in on a 20-day high, out on a 10-day low |213| `vol_target_trend/1` | `ma_trend`, position scaled by 40 % / realised volatility (never above 1/N) |214| `xs_momentum/1` | hold the 2 assets with the best 30-day return (always invested) |215| `pairs_rotation/1` | ETH/BTC and SOL/LINK: rotate into the cheap leg when the spread looks stationary (always invested; not market-neutral) |216| `garch_regime/1` | daily trend, switched off when the GARCH(1,1) one-day forecast is above 1.25 × its median (one decision per day) |217218## Results on real data (2026-11-10)219220Coinbase 1h candles, 2024-11-10 → 2026-11-09 (17 510 common closed bars; 10 bars missing per symbol, longest gap2215, not filled). Train 2024-11-10 → 2026-01-21, validation → 2026-06-17, **test 2026-06-17 → 2026-11-09** (3 502222bars, about 4.8 months). Fee 10 bps + slippage 10 bps, initial cash 1 000 USD, minimum order 5 USD, rebalance223threshold 0.05. Full reports, trial ledger and table: `results/2026-11-10/`.224225| Strategy | Test return | Sharpe (95 % CI) | Max DD | Trades | Costs (USD) | Exposure | Train / validation Sharpe | Deflated Sharpe (46 trials) |226|---|---:|---|---:|---:|---:|---:|---|---:|227| ts_momentum/1 | +24.3 % | 1.65 [-1.55, 4.71] | -15.7 % | 144 | 72.69 | 0.67 | -0.38 / -2.85 | 0.154 |228| garch_regime/1 | +20.4 % | 1.69 [-1.87, 4.80] | -13.2 % | 78 | 45.35 | 0.51 | -0.04 / -0.94 | 0.157 |229| ma_trend/1 | +18.8 % | 1.42 [-1.77, 4.43] | -10.6 % | 42 | 21.44 | 0.55 | -0.65 / -0.68 | 0.122 |230| donchian_breakout/1 | +17.0 % | 1.53 [-1.73, 4.53] | -13.8 % | 25 | 13.17 | 0.41 | +0.17 / -0.66 | 0.136 |231| vol_target_trend/1 | +16.8 % | 1.63 [-1.90, 4.85] | -8.1 % | 59 | 20.29 | 0.43 | -0.53 / -0.57 | 0.149 |232| pairs_rotation/1 | +13.8 % | 0.91 [-1.97, 3.94] | -26.6 % | 110 | 61.21 | 1.00 | -0.30 / -1.93 | 0.069 |233| xs_momentum/1 | +2.1 % | 0.36 [-2.62, 3.25] | -28.7 % | 191 | 196.56 | 1.00 | -0.55 / -3.14 | 0.034 |234| mean_reversion/1 | -18.3 % | -2.21 [-4.27, -0.05] | -20.8 % | 2 150 | 333.87 | 0.26 | -2.41 / -4.49 | 0.000 |235| **buy and hold** (equal weights) | **+19.2 %** | 1.15 [-1.77, 4.19] | -25.5 % | 4 | 2.00 | 1.00 | | |236237What these numbers say, and do not say:238239- **No strategy shows a credible edge over buy-and-hold after costs.** On the test split two strategies end above240 buy-and-hold in return (ts_momentum +24.3 %, garch_regime +20.4 % vs +19.2 %) and the five trend-type strategies241 have a higher Sharpe ratio and a smaller drawdown, with 41–67 % average exposure. But every bootstrap interval242 contains zero and largely overlaps the benchmark's, the deflated Sharpe ratios are between 0.00 and 0.16 (0.95243 would be needed), and the test split is 4.8 months of one rising market.244- **The same strategies lost money before the test split.** All eight have a negative Sharpe ratio on the245 validation split and seven of eight on the train split. The test split is the only period where they look fine;246 that is what one favourable regime looks like, not evidence of skill.247- `mean_reversion/1` loses in every split; its costs alone (334 USD on 1 000 USD in 4.8 months) rule it out at248 20 bps per trade. `xs_momentum/1` and `pairs_rotation/1` are always invested and do worse than holding.249- The rebalanced equal-weight benchmark equals buy-and-hold on the test split: no weight drifted by 5 points, so250 it never rebalanced.251252## Known limits253254- **Spot, long-only, no leverage, no shorting.** Market-neutral ideas (pairs) can only be expressed as tilts and255 keep the full market risk.256- **Bar data only.** One fill per decision at the next bar's open plus a fixed slippage. No order book, no spread,257 no depth, no partial fill, no latency, no intrabar path; nothing below the bar interval can be evaluated.258- **Costs are assumptions.** 10 bps + 10 bps is a guess, constant in time and independent of order size. No price259 impact, no minimum fee.260- **Exchange prices are not on-chain execution.** The candles are Coinbase prices. The wallet will trade through261 on-chain routers: pool fees, gas (fixed per swap, so heavy for small orders — a 5 USD rebalance can cost more in262 gas than its fee), price impact against pool depth, MEV, and DEX prices that differ from Coinbase. Idle cash is263 USDC, assumed worth exactly 1 USD and earning nothing. Paper trading with live V2 quotes is the step that264 measures this; a backtest cannot.265- **Short history, one venue.** Two years, four correlated assets, one source (Kraken only cross-checks the last266 ~700 bars: median close difference 1–3 bps, maximum 51 bps). The test split is under five months. Survivorship:267 the universe was chosen today among assets that still exist.268- **Gaps** are not filled; a decision taken before a gap is filled at the first bar after it.269- **Statistics.** Sharpe ratios on hourly returns are annualised with √8760, which assumes uncorrelated returns.270 The deflated Sharpe ratio assumes independent trials (a parameter grid is not), uses the validation Sharpe271 ratios of the trials for their variance and the test split for the tested Sharpe ratio. Train and validation272 metrics start from cash without earlier history, so their first `lookback` bars are idle.273- **Purity guard** is not a sandbox (see above). `decision_every` is a backtest option, not part of `META`: a274 strategy backtested at one decision per day must be called by the engine at the same cadence.275- **Not implemented**: machine-learning models (`scikit-learn` is pinned for them but unused), regime-switching276 models (Markov switching, HMM), microstructure / order-book / funding / on-chain signals, portfolio optimisation277 (risk parity, mean-variance), stop-loss or trailing-stop execution logic, intrabar fills, parameter optimisers278 beyond a grid, combinatorial purged cross-validation and the probability of backtest overfitting.279280## Perpetual futures (V4)281282Everything above is the spot laboratory and is unchanged. This section is the extension to perpetual futures — long and283short, leverage, funding, margin, liquidation. The model, its formulas with their sources, every simplification and the284reading guide of a version-2 report are in **`docs/v4/quant-perps.md`**; this is the short version.285286```287src/spq/288 data/hyperliquid.py candles + funding history from Hyperliquid's public info endpoint (read-only), funding store,289 alignment check290 margin.py margin arithmetic in decimal.Decimal: a port of packages/derivatives/src/margin.ts291 perp_engine.py cross-margin USDC account: fills, funding, initial-margin check, intrabar liquidation, exact ledger292 perp_metrics.py v1 metrics + funding PnL, leverage, liquidations, worst intrabar drawdown, ...293 perp_research.py PerpExperiment (same split / sealing / trial ledger), benchmarks, funding stress windows294 perp_report.py report, schema version 2 perp_backtest.py, perp_pipeline.py CLIs295schema/ backtest-report-v2.schema.json, signal-input-v2.schema.json, signal-output-v2.schema.json296strategies/<name>_perp/1/ results/2026-11-10-perps/297```298299```bash300# public, keyless data: Hyperliquid perpetual candles + funding, and Coinbase spot for the benchmark / spot legs301uv run python -m spq.data.hyperliquid --symbols BTC,ETH,SOL,HYPE --interval 1h --out data --depth302uv run python -m spq.data.fetch --symbols BTC,ETH,SOL,HYPE --interval 1h --start 2026-04-10 --out data303304uv run python -m spq.perp_backtest --strategy strategies/ts_momentum_perp/1 --data-dir data --out report.json305uv run python -m spq.perp_pipeline --strategies strategies --data-dir data --out results/2026-11-10-perps \306 --rebalance-threshold 0.05 --decision-every har_rv_perp=24307```308309**Data.** `candleSnapshot`, `fundingHistory`, `meta` and nothing else. The venue serves **only its most recent 5 000310candles per interval** (1h: about 208 days) and the full hourly funding history (BTC, ETH, SOL: since 2023-05-12). Closed311bars, UTC, gaps counted and never filled. A funding settlement belongs to the bar that opens at its hour. Candles are trade312prices: the API has no historical mark or oracle price.313314**Strategy contract v2.** `META["kind"] = "perp"` plus `max_leverage` (bound of one weight) and `max_gross_exposure`315(bound of the sum of absolute weights). `target_weights` returns signed fractions of account equity (negative = short);316`data[symbol]` also carries `funding` and `premium` (and `mark`, `index` when available), all truncated at the decision bar.317A symbol `X.SPOT` is a spot leg (long only, paid in full). A META without `kind` is the spot contract, exactly as before.318`spq.describe` and `spq.signal` refuse a perp strategy unless the caller passes `--contract 2`, so a caller that only knows319the spot contract can neither list nor run one; with it, `spq.signal` requires the funding history, refuses any value dated320after the decision bar and prints `{"schema_version": 2, "kind": "perp", ..., "weights": {"BTC": -0.5, ...}}`.321322**Backtester.** Funding at each hourly settlement on the position held, fill at the next open (taker 4.5 bps + 2 bps323slippage by default), initial margin `notional / leverage` checked at order time (orders that do not fit are cut pro rata or324refused), maintenance margin `1 / (2 × venue max leverage)`, liquidation inside the bar when the low (long) or high (short)325crosses the level where equity equals the maintenance margin — closed at that level plus a 100 bps penalty, flagged in the326trade log —, wipe-out at zero equity. The account ledger is exact decimal arithmetic: `final equity = initial + Σ realised327PnL + Σ funding − Σ fees − Σ slippage (+ unrealised PnL)` to the last digit, checked on every run (`checks.ledger`).328329**Strategies** (`strategies/<name>_perp/1/`, each with hypothesis, mechanism and what would refute it; parameters written330before any backtest):331332| Strategy | Idea |333|---|---|334| `ts_momentum_perp/1` | long an asset whose 7-day return is positive, short otherwise |335| `ma_trend_perp/1` | long/short on two moving averages, size inversely proportional to realised volatility |336| `zscore_reversion_perp/1` | fade a 2-sigma deviation from the 48-hour mean, exit at the mean, stop at 4 sigma |337| `pairs_coint_perp/1` | BTC–ETH spread, market-neutral, hedge ratio on a rolling window ending at the decision bar |338| `funding_carry_perp/1` | short a perpetual whose funding is persistently high, sized by a funding z-score — **directional** |339| `funding_carry_neutral_perp/1` | the same signal hedged: spot long + perpetual short |340| `vol_breakout_perp/1` | follow a 24-hour move larger than twice its usual size for 48 hours |341| `har_rv_perp/1` | trend position sized by a HAR-RV volatility forecast, flat when the forecast is far above normal |342343**Not implemented, on purpose: microstructure strategies.** Order-book-imbalance and other order-book signals cannot be344backtested here: they need historical book (or tick) data, the public endpoint only serves the current book and none was345recorded. A candle backtest of such a strategy would be a fake; it belongs to the paper engine once books have been346recorded.347348### Results on real data (2026-11-10, perps)349350Hyperliquid 1h candles and funding, BTC, ETH, SOL, HYPE, 2026-04-16 → 2026-11-11 (4 995 common closed bars after the3515 bars missing in the Coinbase spot series are removed). Train → 2026-08-19, validation → 2026-09-30, **test 2026-09-30 →3522026-11-11** (999 bars, 41.6 days). Taker 4.5 bps + slippage 2 bps, 1 000 USDC, minimum order 10 USD, rebalance threshold3530.05. Full tables and reports: `results/2026-11-10-perps/`.354355| Strategy | Test return | Sharpe (95 % CI) | Max DD | Fees + slippage / funding (USD) | Gross lev. mean | Liquidations | Train / validation return | Deflated Sharpe (94 trials) |356|---|---:|---|---:|---:|---:|---:|---|---:|357| zscore_reversion_perp/1 | +6.0 % | 2.08 [−1.77, 5.82] | −5.9 % | 29.63 / −0.05 | 0.37 | 0 | −11.9 % / +2.3 % | 0.059 |358| funding_carry_perp/1 | +1.2 % | 1.89 [−2.52, 5.58] | −1.2 % | 0.32 / +0.79 | 0.03 | 0 | +4.3 % / −4.4 % | 0.053 |359| ts_momentum_perp/1 | +1.1 % | 0.45 [−5.81, 5.75] | −12.6 % | 40.82 / +3.45 | 0.97 | 0 | +23.6 % / +1.0 % | 0.018 |360| funding_carry_neutral_perp/1 | −0.0 % | −0.56 [−3.25, 2.17] | −0.0 % | 0.58 / +0.36 | 0.03 | 0 | +0.0 % / −0.2 % | 0.007 |361| pairs_coint_perp/1 | −1.7 % | −2.93 [−7.04, 2.02] | −2.1 % | 6.57 / −0.06 | 0.24 | 0 | −4.5 % / +0.8 % | 0.000 |362| vol_breakout_perp/1 | −2.3 % | −1.07 [−6.08, 3.40] | −5.6 % | 7.09 / +1.24 | 0.31 | 0 | +13.8 % / +8.7 % | 0.004 |363| ma_trend_perp/1 | −6.4 % | −1.02 [−7.06, 4.08] | −17.0 % | 24.92 / +2.41 | 1.28 | 0 | +4.0 % / −5.9 % | 0.005 |364| har_rv_perp/1 | −9.9 % | −1.61 [−7.03, 3.11] | −20.9 % | 17.92 / +1.77 | 1.30 | 0 | −10.1 % / −13.7 % | 0.003 |365| **perp long 1× incl. funding** | **−22.1 %** | −4.47 [−8.92, 0.17] | −25.4 % | 0.65 / −9.05 | 1.00 | 0 | +10.4 % / +26.1 % | |366| **buy and hold spot** | **−21.4 %** | −4.33 [−8.84, 0.42] | −24.7 % | 2.00 / — | | | +12.2 % / +27.4 % | |367368**No strategy shows an edge that survives the statistics.** Every bootstrap interval contains zero and the deflated Sharpe369ratios are at most 0.06 (0.95 needed). The test split is 42 days of a market that fell 21–22 %: all eight strategies ended370above the two long benchmarks there, which is what being net short or mostly flat does in a falling market; on the371validation split, when the market rose 26–27 %, all eight were far below them. No strategy is above `perp_long_1x` on all372three splits, and the sign of most results flips from one split to the next. No liquidation occurred (gross leverage never373above 2.13×). Details and the limits of the run: `results/2026-11-10-perps/summary.md`.374375### Known limits (perps)376377- **About 208 days of hourly history** on the venue's own candles, four correlated assets, one venue; the test split is six378 weeks.379- **Funding is charged on the perpetual's price, not on the oracle price**, and liquidation uses trade highs and lows, not380 the mark price: neither historical series is served. Flat maintenance margin (no tiers), whole-account liquidation with a381 fixed penalty (no partial liquidation, no backstop, no ADL), a pessimistic worst case when several positions are open.382- **Fills** as in the spot engine: next open, constant slippage, no book, no impact, every fill a taker.383- **Spot legs** come from another venue's candles and share one cash pool with the perpetual account, with no transfer384 delay or cost; the USD / USDT basis is unhedged.385- **The look-ahead check compares decisions at two cut points**: a leak that happens not to change those decisions is not386 detected by it (the unit tests use rules that always react).387- The version-2 shapes (`--contract 2`, report, signal) have no consumer yet on the TypeScript side: they are proposals.388389## Research jobs (V7)390391An AI coding agent can develop a strategy in this laboratory as a sandboxed *research job*392(`docs/v7/RESEARCH-RUNTIME.md`). Additions, all additive:393394- `spq.job` — the job's commands: `info`, `trial` (one configuration on train + validation, counted),395 `evaluate` (the standard report + the claims file; THE look at the inner test split, sealed across processes by the396 job ledger: another `strategy.py` hash afterwards exits 7), `split` (server side: where the test split starts).397- `SPQ_JOB_LEDGER` — when set, every `Experiment` appends its trials and test evaluations there, whatever `ledger=`398 the caller passed.399- `--job-ledger` (`spq.backtest`, `spq.perp_backtest`) — the trials a job consumed are added to the trial count of the400 deflated Sharpe ratio; the variance of the trial Sharpe ratios can only grow (`spq.dsr.deflate_for_job`).401- `spq.lint` — the static purity check of a `strategy.py` without importing it.402- Directories `strategies/rj_<job>/` are strategies installed by the wallet API from research jobs (git-ignored, not403 part of the library and its tests).404