Compare commits
93 Commits
ml-alpha-r
...
surfer-uni
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0181431b3 | ||
|
|
feb0ae2541 | ||
|
|
4afaa5e248 | ||
|
|
e21a9a5da4 | ||
|
|
b2efc58940 | ||
|
|
fbb7b429e3 | ||
|
|
f4bd1e2432 | ||
|
|
6878aac950 | ||
|
|
bccd814716 | ||
|
|
cfa28368d5 | ||
|
|
f9b62d108c | ||
|
|
54e3d50db8 | ||
|
|
d82439219d | ||
|
|
3f321bb4bf | ||
|
|
02b851ca8b | ||
|
|
a3f309dd7e | ||
|
|
db8f5434a0 | ||
|
|
1cdbc81390 | ||
|
|
7662d82232 | ||
|
|
2a468e4b5e | ||
|
|
8ebb65f27e | ||
|
|
f320c5f09f | ||
|
|
dc2b7214f7 | ||
|
|
4bbf07bddf | ||
|
|
66e37955ce | ||
|
|
63d4f7f61d | ||
|
|
ba543ea85e | ||
|
|
e35b79a531 | ||
|
|
40c6e2f2ab | ||
|
|
4817bd4e7c | ||
|
|
a9a1c3c511 | ||
|
|
247e469a31 | ||
|
|
ac01db39fa | ||
|
|
9a90720500 | ||
|
|
ce8f8cbd46 | ||
|
|
ba7e7dca79 | ||
|
|
60e900e43a | ||
|
|
be084b5154 | ||
|
|
764fd99480 | ||
|
|
e731c2fc2f | ||
|
|
2b36929b5f | ||
|
|
ee2215f4d3 | ||
|
|
027d73a504 | ||
|
|
4f73e99225 | ||
|
|
9bf67e731d | ||
|
|
f203998613 | ||
|
|
107bcc6648 | ||
|
|
24ac921cd3 | ||
|
|
42e9621c47 | ||
|
|
04e7a61320 | ||
|
|
902eb1c85f | ||
|
|
df5c591441 | ||
|
|
9c2c38eb5d | ||
|
|
c10ebe0257 | ||
|
|
0fc3eac2ab | ||
|
|
244ccaaf0b | ||
|
|
7457ef679c | ||
|
|
6c113b0df2 | ||
|
|
d43fb5e61f | ||
|
|
0c5af2d9a0 | ||
|
|
30f944f015 | ||
|
|
05a81c5e5f | ||
|
|
2065c98c25 | ||
|
|
5ed94ddc97 | ||
|
|
a7d6671dea | ||
|
|
b664673df3 | ||
|
|
a9d1af9ec8 | ||
|
|
bc4cc46775 | ||
|
|
74b9d092ad | ||
|
|
f986510099 | ||
|
|
ad7feb61ff | ||
|
|
9642ad3156 | ||
|
|
dbbe8f7b22 | ||
|
|
ec308346b2 | ||
|
|
d6eb5b2ada | ||
|
|
4336a71e26 | ||
|
|
99b747ad2c | ||
|
|
a2cbed4a83 | ||
|
|
7edeec9106 | ||
|
|
ca3877c328 | ||
|
|
7495eaa286 | ||
|
|
0d6d58428d | ||
|
|
0f1a04d16d | ||
|
|
af5e6d523b | ||
|
|
cb402ab81f | ||
|
|
1283812c54 | ||
|
|
1cf38232e8 | ||
|
|
fa700c7b5f | ||
|
|
2bb2b50fd1 | ||
|
|
8df1c7eea2 | ||
|
|
e07c409950 | ||
|
|
565511c5f5 | ||
|
|
ce13a72ba1 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -204,3 +204,4 @@ crates/ml/ml/
|
||||
# Foxhunt audit hook dedup state (cleared at SessionStart)
|
||||
.claude/.foxhunt-audit-state
|
||||
/config/ml/alpha_logits_cache.bin
|
||||
data/surfer/
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
# Surfer Phase 0 — Diversified-Trend Floor + Validation (CUDA/Rust) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans (or subagent-driven-development).
|
||||
> Steps use checkbox (`- [ ]`). TDD throughout — **GPU-oracle tests only, NO CPU reference oracle**
|
||||
> (`feedback_no_cpu_test_fallbacks`). **CUDA-only, CPU read-only** (`feedback_cpu_is_read_only`,
|
||||
> `pearl_cold_path_no_exception_to_gpu_drives`): every formula is a CUDA kernel; CPU reads only final gate scalars
|
||||
> from mapped-pinned buffers and only enumerates split index-sets as control flow.
|
||||
|
||||
**Goal:** Build the deterministic diversified-trend FLOOR and the CPCV/PBO/Deflated-Sharpe validation, **entirely as
|
||||
CUDA kernels + Rust orchestration in `crates/ml-alpha`**, then measure whether the floor has a real OOS edge after
|
||||
costs. The cheap, falsifiable gate before any ML (Phases 1-3). Runs on the local GPU (data is tiny: ~30 instruments
|
||||
× ~5000 days = ~150k floats).
|
||||
|
||||
**Architecture:** Reuse ml-alpha's CUDA pipeline — `build.rs` cubin precompile (`KERNELS` list), `pinned_mem.rs`
|
||||
mapped-pinned buffers, cudarc `load_cubin`/`launch_builder`, the determinism foundation (`FOXHUNT_DETERMINISTIC`,
|
||||
single-thread-per-series sequential sweeps, no `atomicAdd`, no nvrtc). GPU-drives / CPU-reads, uniformly.
|
||||
|
||||
**Tech Stack:** Rust 1.85 (`crates/ml-alpha`), CUDA 12.4 (pre-compiled cubins via build.rs, NO nvrtc), cudarc,
|
||||
mapped-pinned host buffers. Data via `crates/data` dbn decode (the L0-fixed `dbn_parser`). NO Python in the built
|
||||
system. NO GPU↔CPU roundtrips except the final gate-scalar readback.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-05-surfer-uncorrelated-universe-design.md`
|
||||
|
||||
---
|
||||
|
||||
## GPU-contract compliance (apply to EVERY task)
|
||||
- **No `atomicAdd`** (`feedback_no_atomicadd`) → reductions are tree-reduce or single-thread-per-series sequential.
|
||||
- **Mapped-pinned only** for any host visibility (`feedback_no_htod_htoh_only_mapped_pinned`); no raw HtoD/DtoH for
|
||||
compute results. Data UPLOAD uses the existing mapped-pinned dataset path.
|
||||
- **Determinism**: per-series sequential sweeps (EWMA vol, portfolio carry) are single-thread-per-instrument with a
|
||||
sequential time loop — mirror `gae_backward_sweep.cu` (deterministic, no inter-thread race). Verify with
|
||||
`determinism-check`-style same-seed bit-equality.
|
||||
- **No CPU compute**: signals, vol, sizing, returns, Sharpe, PBO, Deflated-Sharpe are kernels. CPU may only:
|
||||
(a) enumerate combinatorial split index-sets (control flow, like `epoch_idx`), uploading them as int arrays;
|
||||
(b) read the FINAL gate scalars from mapped-pinned at the end.
|
||||
- **No CPU test oracle**: tests assert kernel outputs against HAND-DERIVED analytic constants read from mapped-pinned
|
||||
(e.g. "rising series → TSMOM signal == +1.0"), never a CPU re-implementation of the algorithm.
|
||||
|
||||
## File structure
|
||||
| File | Change | Responsibility |
|
||||
|---|---|---|
|
||||
| `crates/ml-alpha/cuda/surfer_continuous_adjust.cu` | new | element-wise backward ratio-adjust of stitched closes |
|
||||
| `crates/ml-alpha/cuda/surfer_tsmom_signal.cu` | new | per (instrument,day) 1/3/12-mo sign-ensemble signal |
|
||||
| `crates/ml-alpha/cuda/surfer_ewma_vol.cu` | new | per-instrument EWMA vol (single-thread-per-instrument seq) |
|
||||
| `crates/ml-alpha/cuda/surfer_floor_portfolio.cu` | new | inverse-vol size + vol-target + no-trade band → daily port. returns |
|
||||
| `crates/ml-alpha/cuda/surfer_cpcv_sharpe.cu` | new | per (path,config) Sharpe over returns matrix (parallel) |
|
||||
| `crates/ml-alpha/cuda/surfer_overfit_stats.cu` | new | PBO + Deflated-Sharpe scalars on GPU (erff cdf) |
|
||||
| `crates/ml-alpha/build.rs` | edit `KERNELS` | register the 6 cubins |
|
||||
| `crates/ml-alpha/src/surfer/mod.rs` | new | Rust orchestration (load→upload→launch→read gates) |
|
||||
| `crates/ml-alpha/src/surfer/continuous.rs` | new | roll detection + stitch (data-assembly; decode via `data` crate) |
|
||||
| `crates/ml-alpha/examples/surfer_phase0.rs` | new | the verdict-run binary |
|
||||
| `crates/ml-alpha/tests/surfer_floor_invariants.rs` | new | GPU-oracle invariant tests |
|
||||
|
||||
---
|
||||
|
||||
## Task A: Continuous-contract series (data-assembly + ratio-adjust kernel)
|
||||
|
||||
**Files:** `crates/ml-alpha/src/surfer/continuous.rs`; `crates/ml-alpha/cuda/surfer_continuous_adjust.cu`; build.rs.
|
||||
|
||||
- [ ] **Step 1:** Add `"surfer_continuous_adjust"` to `KERNELS` in `crates/ml-alpha/build.rs`.
|
||||
- [ ] **Step 2: kernel** `surfer_continuous_adjust.cu` — given stitched raw closes `[N_days]` and a roll-ratio array
|
||||
`ratio[N_days]` (cumulative back-adjust factor per day, =1.0 after the last roll), output `adj[i]=raw[i]*ratio[i]`.
|
||||
Element-wise, one thread per day. (Roll DETECTION — which contract is active per day by max volume, and the per-roll
|
||||
ratio — is data-assembly control flow in `continuous.rs`: it decides WHAT was loaded, not a compute-over-results.
|
||||
It reads per-expiry volume from the decoded dbn, picks argmax-volume contract per day, and builds the cumulative
|
||||
`ratio` array, which is then UPLOADED mapped-pinned; the multiply is the kernel.)
|
||||
- [ ] **Step 3: `continuous.rs`** — `pub fn build_continuous(root, decoder) -> ContinuousSeries { ts, raw_close_d,
|
||||
adj_close_d, volume… }` decoding per-expiry OHLCV via the `data` crate, picking active contract per day by volume,
|
||||
computing cumulative ratio, uploading raw+ratio mapped-pinned, launching the adjust kernel → `adj_close_d` on GPU.
|
||||
Keep BOTH `raw_close_d` (fills/reward USD) and `adj_close_d` (signals/returns). Per spec §4: NEVER Panama; reward on raw.
|
||||
- [ ] **Step 4: GPU-oracle test** (`surfer_floor_invariants.rs`): upload raw=[100,101,102,202,204], roll at day3,
|
||||
ratio=[r,r,r,1,1] with r=202/102; launch; read `adj` mapped-pinned; assert `adj[3]==202`, `adj[2]==102*r`, and the
|
||||
seam log-return `ln(adj[3]/adj[2])≈0`. (Analytic constants, no CPU re-impl.)
|
||||
- [ ] **Step 5:** `SQLX_OFFLINE=true cargo test -p ml-alpha --test surfer_floor_invariants continuous` → PASS.
|
||||
- [ ] **Step 6: Commit** — `feat(surfer): continuous-contract assembly + ratio-adjust kernel`.
|
||||
|
||||
---
|
||||
|
||||
## Task B: Floor kernels (TSMOM signal, EWMA vol, portfolio backtest)
|
||||
|
||||
**Files:** `surfer_tsmom_signal.cu`, `surfer_ewma_vol.cu`, `surfer_floor_portfolio.cu`; `src/surfer/mod.rs`; build.rs.
|
||||
|
||||
- [ ] **Step 1:** Register the 3 kernels in build.rs `KERNELS`.
|
||||
- [ ] **Step 2: `surfer_tsmom_signal.cu`** — input `logc[N_inst × N_days]`; per (inst, day) output
|
||||
`sig = clip( mean_{L∈{21,63,252}} sign(logc[end]-logc[end-L]), -1, 1)`, where for L=252 `end = day-21` (skip recent),
|
||||
else `end=day`; 0 before warmup. Grid over (inst×day), branch-free. (Sign-inversion for yield-based rates handled
|
||||
by a per-instrument `sign_flip[inst]` multiplier uploaded as config.)
|
||||
- [ ] **Step 3: `surfer_ewma_vol.cu`** — per instrument, single thread, sequential over days:
|
||||
`v[t] = a·r[t]^2 + (1-a)·v[t-1]`, `a=1-exp(ln0.5/halflife)`, `sigma[t]=sqrt(v[t]·252)`. One thread per instrument
|
||||
(deterministic sequential carry — mirror `gae_backward_sweep.cu`'s single-thread-per-series pattern). No atomics.
|
||||
- [ ] **Step 4: `surfer_floor_portfolio.cu`** — per day (sequential single-thread for the portfolio vol-target carry;
|
||||
per-instrument inner loop): target weight `w_i = sig_i · (risk_budget / sigma_i)`; apply no-trade band (skip change
|
||||
if |w_i - w_i_prev| < band); portfolio gross scaled to `vol_target/realized_port_vol` (realized from a trailing
|
||||
window kernel-side); daily portfolio return `R[t] = Σ_i w_i[t-1]·r_i[t]`. Output `port_ret_d[N_days]`,
|
||||
`turnover_d[N_days]` on GPU.
|
||||
- [ ] **Step 5: `src/surfer/mod.rs`** — `pub struct Floor` loads the 3 cubins; `pub fn run_floor(&mut self,
|
||||
series: &[ContinuousSeries], cfg: FloorCfg) -> FloorOutput` launches signal→vol→portfolio, returns GPU handles
|
||||
(`port_ret_d`, `turnover_d`). No host math.
|
||||
- [ ] **Step 6: GPU-oracle tests** (analytic): (a) monotonic-rising logc → `tsmom_signal` last value == +1.0;
|
||||
monotonic-falling → −1.0; (b) constant-vol synthetic → `ewma_vol` converges to the known σ (hand value); (c) a
|
||||
2-instrument hand-built case → assert `port_ret_d[t]` equals the hand-derived `Σ w·r` for one step. Read all via
|
||||
mapped-pinned; no CPU re-impl of the formulas.
|
||||
- [ ] **Step 7:** Run tests → PASS. **Smoke**: `run_floor` on the local 4 instruments (6E/ES/NQ/ZN), read
|
||||
`port_ret_d` mapped-pinned, print annualized Sharpe (expect noisy/underpowered on 2y — proves the machinery).
|
||||
- [ ] **Step 8: Commit** — `feat(surfer): floor kernels (TSMOM signal + EWMA vol + portfolio backtest)`.
|
||||
|
||||
---
|
||||
|
||||
## Task C: Validation kernels (CPCV Sharpe, PBO, Deflated Sharpe) + cost
|
||||
|
||||
**Files:** `surfer_cpcv_sharpe.cu`, `surfer_overfit_stats.cu`; `src/surfer/validation.rs`; build.rs.
|
||||
|
||||
- [ ] **Step 1:** Register both kernels in build.rs `KERNELS`.
|
||||
- [ ] **Step 2: cost in the portfolio kernel** — extend `surfer_floor_portfolio.cu` to subtract per-rebalance cost:
|
||||
`cost[t] = turnover[t]·(per_contract_usd/notional + half_spread_bps/1e4)` (+ roll cost on roll days), producing a
|
||||
NET `port_ret_d`. Costs are config scalars uploaded mapped-pinned. (No host arithmetic.)
|
||||
- [ ] **Step 3: `surfer_cpcv_sharpe.cu`** — inputs: net `port_ret_d[N_days]` (or `[N_cfg × N_days]` if sweeping
|
||||
configs), and CPU-enumerated `test_mask[N_path × N_days]` (uint8, uploaded). Per (path[, cfg]) compute mean/std →
|
||||
Sharpe over the path's OOS days via tree-reduce (no atomics). Output `oos_sharpe[N_path(× N_cfg)]` on GPU.
|
||||
- [ ] **Step 4: `surfer_overfit_stats.cu`** — on GPU: (a) Deflated Sharpe `DSR = Φ((SR−SR0)√(T−1)/√(1−γ3·SR+
|
||||
(γ4−1)/4·SR²))` with `Φ` via `0.5·erfcf(-x/√2)`, `SR0` from `n_trials` (Euler-γ order-statistic formula), `n_trials`
|
||||
uploaded; (b) PBO from the per-split IS-argmax / OOS-rank arrays (the CSCV split sets enumerated CPU-side, the
|
||||
per-split argmax+rank reduced GPU-side). Output `{dsr, pbo, oos_sharpe_5pct, rank_consistency}` to a mapped-pinned
|
||||
gate-scalar struct.
|
||||
- [ ] **Step 5: `src/surfer/validation.rs`** — `pub fn validate(net_ret_d, cfg_grid) -> Gates`: CPU ENUMERATES the
|
||||
CPCV combinations and CSCV splits (control flow), uploads index masks mapped-pinned, launches the two kernels,
|
||||
reads the final `Gates` struct from mapped-pinned. The ONLY host readback.
|
||||
- [ ] **Step 6: GPU-oracle tests**: (a) feed a returns series with known mean/std → assert `oos_sharpe` equals the
|
||||
hand-derived value; (b) Deflated-Sharpe: same SR, n_trials=1 vs 50 → assert `dsr` decreases (monotonic, hand-checked
|
||||
direction); (c) PBO: construct IS-best=OOS-worst returns → assert `pbo > 0.5`. All via mapped-pinned; no CPU oracle.
|
||||
- [ ] **Step 7:** Run tests → PASS. **Determinism**: run `validate` twice same inputs → gate scalars bit-equal.
|
||||
- [ ] **Step 8: Commit** — `feat(surfer): CUDA validation (CPCV Sharpe + PBO + Deflated Sharpe + cost)`.
|
||||
|
||||
---
|
||||
|
||||
## Task D: Acquire broad-universe deep-history data
|
||||
|
||||
**Files:** reuse `services/data_acquisition` / the existing Databento pipeline; cache decoded series.
|
||||
|
||||
- [ ] **Step 1:** Target ≥15 FULL-SIZE roots across classes (equity ES/NQ/YM/RTY; rates ZN/ZB/ZF/ZT; FX 6E/6J/6B/6A/6C;
|
||||
metals GC/SI/HG; energy CL/NG/RB; ags ZC/ZS/ZW), max history via Databento `GLBX.MDP3 ohlcv-1d`. (Edge is identical to
|
||||
micros; micros are a deployment-sizing concern only.)
|
||||
- [ ] **Step 2:** Fetch per-expiry daily OHLCV (existing data pipeline), land on the PVC/local; the surfer's
|
||||
`continuous.rs` builds continuous series at load.
|
||||
- [ ] **Step 3:** Validate each root: ≥10y, decode clean via the L0-fixed `dbn_parser`, ratio seam-returns sane.
|
||||
- [ ] **STOP-if:** Databento history/cost prohibitive → document and proceed with the largest free continuous set,
|
||||
noting the data-quality caveat in the verdict.
|
||||
|
||||
---
|
||||
|
||||
## Task E: Phase-0 verdict run (the decisive gate)
|
||||
|
||||
**Files:** `crates/ml-alpha/examples/surfer_phase0.rs`.
|
||||
|
||||
- [ ] **Step 1:** Wire: load all continuous series → upload → `run_floor` (net of cost) → `validate(net_ret_d, grid)`
|
||||
over the lookback/vol-target config grid → read the `Gates` struct (mapped-pinned, the only readback) → print a
|
||||
verdict block (per `tier1_5_verdict.py` STYLE, but the binary is Rust reading GPU scalars):
|
||||
- SV-G1: CPCV (≥50 paths) 5th-pct OOS Sharpe after costs > 0.
|
||||
- SV-G3: Deflated Sharpe > 0.95, `n_trials` = ALL trials ever (record the count: 64 commits + session harnesses +
|
||||
every floor lookback/vol-target variant in the grid).
|
||||
- SV-G4: IS↔OOS config rank-consistency > 0.
|
||||
- SV-G5: edge survives 2× cost + roll (re-run with doubled cost scalars).
|
||||
- [ ] **Step 2:** Determinism: same-inputs run twice → identical verdict.
|
||||
- [ ] **Step 3:** Record verdict in spec §9 + a memory pearl.
|
||||
- [ ] **Step 4: Commit.**
|
||||
|
||||
**VERDICT BRANCH:**
|
||||
- **PASS** (floor clears SV-G1/G3/G5 OOS net of costs) → diversified-trend premium is real & capturable on this
|
||||
universe → proceed to Phase 1 (regime overlay). The floor becomes the live benchmark.
|
||||
- **FAIL** → even the floor has no powered OOS edge → do NOT build ML. Reassess universe/history/thesis. Cost: a few
|
||||
days of local-GPU dev, $0 cluster.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
- **CUDA-only / CPU-read-only**: every formula is a kernel (Tasks A-C); CPU only enumerates split masks + reads the
|
||||
final `Gates` struct (Task C Step 5, Task E Step 1). Complies with `feedback_cpu_is_read_only`,
|
||||
`pearl_cold_path_no_exception_to_gpu_drives`. ✓
|
||||
- **No CPU test oracle**: all tests assert kernel outputs vs hand-derived analytic constants read from mapped-pinned
|
||||
(`feedback_no_cpu_test_fallbacks`). ✓
|
||||
- **GPU-contract**: no atomicAdd (tree-reduce / single-thread-per-series), mapped-pinned only, cubins via build.rs
|
||||
(no nvrtc), determinism via sequential-per-series sweeps + same-input bit-equality. ✓
|
||||
- **Spec coverage**: floor §3.1 → Task B ✓ | validation gates §5 → Task C+E ✓ | universe+pipeline §4 → Task A+D ✓ |
|
||||
Phase-0 STOP §6 → Task E verdict ✓ | edge-validation on full-size (not micros) → Task D ✓ | reuse ml-alpha infra §3.5 → file structure ✓.
|
||||
- **Placeholders**: kernel bodies are given as signature + exact formula + determinism pattern + the existing kernel to
|
||||
mirror (gae_backward_sweep / ema_update_per_step / the reduce kernels) — a competent engineer following ml-alpha's
|
||||
`cuda/` conventions writes them directly. Not silent gaps.
|
||||
|
||||
## Execution note
|
||||
Tasks A-C run on the local GPU against the 4 local instruments to prove the kernels + machinery; D-E need the broad
|
||||
universe. No cluster, no Python in the built system. The floor + CUDA validation are the reusable core the ML surfer
|
||||
(Phases 1-3) sits on.
|
||||
263
docs/superpowers/plans/2026-06-21-decommission-rust-infra.md
Normal file
263
docs/superpowers/plans/2026-06-21-decommission-rust-infra.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# Decommission Dead Rust Infra — Implementation Plan (Phase 1)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (INLINE, with checkpoints).
|
||||
> **Do NOT run this subagent-driven** — every task performs irreversible production deletes that need
|
||||
> human confirmation at each gate. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Remove the now-unused Rust-specific infra (GPU pools, GPU CI runners, build-cache PVCs, Rust
|
||||
artifact buckets, training/GPU manifests) — preserving ALL `.dbn` market data and every fxhnt/platform resource.
|
||||
|
||||
**Architecture:** Verify-then-delete, gated. Terraform destroys the GPU/precompute pools (plan reviewed
|
||||
for exactly-3); helm uninstalls the GPU runners; kubectl deletes the unmounted build-cache PVCs; mc deletes
|
||||
the no-`.dbn` Rust buckets; repo + cluster Argo templates cleaned up. Cockpit/dagster/fund verified healthy.
|
||||
|
||||
**Tech Stack:** terragrunt + OpenTofu (Scaleway provider), helm, kubectl, mc (MinIO), Scaleway Kapsule.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-21-decommission-rust-infra-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Shared env (export at the start of each shell session that runs terragrunt/scw)
|
||||
```bash
|
||||
cd /home/jgrusewski/Work/foxhunt
|
||||
export TG_TF_PATH=tofu TERRAGRUNT_TFPATH=tofu
|
||||
export SCW_ACCESS_KEY=$(kubectl get secret scaleway-credentials -n foxhunt -o jsonpath='{.data.access-key}'|base64 -d)
|
||||
export SCW_SECRET_KEY=$(kubectl get secret scaleway-credentials -n foxhunt -o jsonpath='{.data.secret-key}'|base64 -d)
|
||||
export SCW_DEFAULT_PROJECT_ID=$(kubectl get secret scaleway-credentials -n foxhunt -o jsonpath='{.data.project-id}'|base64 -d)
|
||||
export SCW_DEFAULT_REGION=fr-par SCW_DEFAULT_ZONE=fr-par-2
|
||||
export TF_HTTP_USERNAME=root TF_HTTP_PASSWORD=$(kubectl get secret gitlab-pat -n foxhunt -o jsonpath='{.data.token}'|base64 -d)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 0: Branch
|
||||
- [ ] **Step 1**
|
||||
```bash
|
||||
cd /home/jgrusewski/Work/foxhunt && git checkout -b chore/decommission-rust-infra
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Destroy the GPU + precompute pools (Terraform)
|
||||
|
||||
**Files:** Modify `infra/live/production/kapsule/terragrunt.hcl`
|
||||
|
||||
- [ ] **Step 1: Set the three enable flags to false**
|
||||
|
||||
In `infra/live/production/kapsule/terragrunt.hcl`, change:
|
||||
```
|
||||
enable_ci_compile_cpu_hm_pool = true
|
||||
```
|
||||
to `false`; and
|
||||
```
|
||||
enable_ci_training_l40s_pool = true
|
||||
```
|
||||
to `false`; and
|
||||
```
|
||||
enable_ci_training_h100_pool = true
|
||||
```
|
||||
to `false`. (Leave `enable_ci_compile_cpu_pool = true` — Python cockpit builds use it.)
|
||||
|
||||
- [ ] **Step 2: Plan and GATE on exactly-3-destroys**
|
||||
|
||||
Run (with shared env, in `infra/live/production/kapsule`):
|
||||
```bash
|
||||
cd /home/jgrusewski/Work/foxhunt/infra/live/production/kapsule
|
||||
terragrunt plan -input=false -no-color 2>&1 | sed 's/.*tofu: //' | grep -iE "^Plan:|will be destroyed|# scaleway"
|
||||
```
|
||||
Expected: `Plan: 0 to add, 0 to change, 3 to destroy.` and the 3 destroyed are EXACTLY
|
||||
`scaleway_k8s_pool.ci_compile_cpu_hm[0]`, `scaleway_k8s_pool.ci_training_l40s[0]`,
|
||||
`scaleway_k8s_pool.ci_training_h100[0]`.
|
||||
**STOP if the count ≠ 3 or any other resource is destroyed.**
|
||||
|
||||
- [ ] **Step 3: Apply**
|
||||
```bash
|
||||
terragrunt apply -input=false -no-color -auto-approve 2>&1 | sed 's/.*tofu: //' | grep -iE "Apply complete|Destroy complete|Error"
|
||||
```
|
||||
Expected: `Apply complete! Resources: 0 added, 0 changed, 3 destroyed.`
|
||||
|
||||
- [ ] **Step 4: Verify pools gone**
|
||||
```bash
|
||||
scw k8s pool list cluster-id=34a1e3c4-ac35-48c8-ab49-5f6ec4df32c1 -o json 2>/dev/null | python3 -c "import sys,json;print([p['name'] for p in json.load(sys.stdin)])"
|
||||
```
|
||||
Expected: `['ci-compile-cpu', 'platform']` (no l40s/h100/hm).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
cd /home/jgrusewski/Work/foxhunt
|
||||
git add infra/live/production/kapsule/terragrunt.hcl
|
||||
git commit -m "chore(infra): destroy unused Rust GPU + precompute pools (L40S/H100/hm)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Uninstall the GPU CI runners (helm)
|
||||
|
||||
- [ ] **Step 1: Uninstall the 3 GPU runners**
|
||||
```bash
|
||||
for r in gitlab-runner-h100 gitlab-runner-h100-sxm gitlab-runner-h100x2; do
|
||||
helm uninstall "$r" -n foxhunt 2>&1 | tail -1
|
||||
done
|
||||
```
|
||||
Expected: `release "<r>" uninstalled` for each.
|
||||
|
||||
- [ ] **Step 2: Decide the main `gitlab-runner`**
|
||||
|
||||
Check whether anything still uses it (fxhnt has no `.gitlab-ci.yml`; cockpit deploys via Argo):
|
||||
```bash
|
||||
kubectl get pods -n foxhunt | grep gitlab-runner # expect: no running runner pods
|
||||
# Inspect what the main runner is registered for (manual judgement):
|
||||
helm get values gitlab-runner -n foxhunt 2>/dev/null | grep -iE "tags|runUntagged|description" | head
|
||||
```
|
||||
- [ ] **Step 3: If confirmed dead, uninstall it; else keep (document the decision):**
|
||||
```bash
|
||||
helm uninstall gitlab-runner -n foxhunt 2>&1 | tail -1 # ONLY if Step 2 confirms no consumer
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
```bash
|
||||
helm list -n foxhunt | grep -i runner # expect: none (or only a deliberately-kept one)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Delete the build-cache PVCs (re-verify unmounted first)
|
||||
|
||||
- [ ] **Step 1: Re-confirm each PVC is unmounted (safety)**
|
||||
```bash
|
||||
for p in cargo-target-cpu cargo-target-cuda cargo-target-cuda-test sccache-cpu sccache-cuda; do
|
||||
echo -n "$p: "; kubectl describe pvc $p -n foxhunt 2>/dev/null | grep -A1 "Used By" | tr '\n' ' '; echo
|
||||
done
|
||||
```
|
||||
Expected: every line shows `Used By: <none>`. **STOP on any PVC that lists a pod.**
|
||||
|
||||
- [ ] **Step 2: Delete them**
|
||||
```bash
|
||||
kubectl delete pvc cargo-target-cpu cargo-target-cuda cargo-target-cuda-test sccache-cpu sccache-cuda -n foxhunt 2>&1 | tail
|
||||
```
|
||||
Expected: `persistentvolumeclaim "<name>" deleted` ×5.
|
||||
|
||||
- [ ] **Step 3: Verify reclaim**
|
||||
```bash
|
||||
kubectl get pvc -n foxhunt | grep -E "cargo-target|sccache" || echo "all build-cache PVCs gone (~175Gi reclaimed)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Delete the Rust artifact buckets (re-verify 0 `.dbn` first)
|
||||
|
||||
- [ ] **Step 1: Start a MinIO port-forward + mc alias**
|
||||
```bash
|
||||
pkill -f "port-forward svc/minio" 2>/dev/null; sleep 1
|
||||
kubectl port-forward svc/minio -n foxhunt 19000:9000 >/tmp/minio-pf.log 2>&1 &
|
||||
sleep 4
|
||||
ak=$(kubectl get secret minio-credentials -n foxhunt -o jsonpath='{.data.access-key}'|base64 -d)
|
||||
sk=$(kubectl get secret minio-credentials -n foxhunt -o jsonpath='{.data.secret-key}'|base64 -d)
|
||||
mc alias set fxl http://localhost:19000 "$ak" "$sk"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Re-verify the 3 target buckets contain ZERO `.dbn`/market data, and the KEPT one does**
|
||||
```bash
|
||||
for b in foxhunt-binaries foxhunt-training-results foxhunt-models; do
|
||||
echo -n "$b dbn-count="; mc ls --recursive fxl/$b 2>/dev/null | grep -icE "\.dbn|\.zst|databento|mbp"
|
||||
done
|
||||
echo -n "KEEP foxhunt-training-data dbn-count="; mc ls --recursive fxl/foxhunt-training-data 2>/dev/null | grep -icE "\.dbn|\.zst|databento|mbp"
|
||||
```
|
||||
Expected: the 3 targets show `0`; `foxhunt-training-data` shows `>0`. **STOP if any target shows >0.**
|
||||
|
||||
- [ ] **Step 3: Delete the 3 Rust buckets**
|
||||
```bash
|
||||
for b in foxhunt-binaries foxhunt-training-results foxhunt-models; do
|
||||
mc rb --force fxl/$b 2>&1 | tail -1
|
||||
done
|
||||
```
|
||||
Expected: `Removed '<bucket>' successfully.` ×3.
|
||||
|
||||
- [ ] **Step 4: Verify market data intact**
|
||||
```bash
|
||||
mc du fxl/foxhunt-training-data # expect: still ~38GiB, 54+ objects
|
||||
kubectl get pvc training-data-pvc test-data-pvc feature-cache-pvc -n foxhunt # expect: all 3 still Bound
|
||||
pkill -f "port-forward svc/minio" 2>/dev/null
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Remove dead manifests + Argo templates (repo + cluster)
|
||||
|
||||
**Files:** Remove `infra/k8s/training/`, `infra/k8s/gpu-overlays/`, Rust Argo templates, the databento job, GPU-runner helm values.
|
||||
|
||||
- [ ] **Step 1: Identify the Rust Argo WorkflowTemplates in the cluster (keep fxhnt-cockpit!)**
|
||||
```bash
|
||||
kubectl get wftmpl -n foxhunt -o name 2>/dev/null
|
||||
```
|
||||
Note the Rust ones (e.g. train-multi-seed, lob-backtest-sweep, ci-pipeline, alpha-rl-*). **Do NOT touch `fxhnt-cockpit`.**
|
||||
|
||||
- [ ] **Step 2: Delete the dead WorkflowTemplates from the cluster** (substitute the exact names from Step 1):
|
||||
```bash
|
||||
# example — replace with the actual Rust template names from Step 1:
|
||||
kubectl delete wftmpl -n foxhunt <rust-template-1> <rust-template-2> ... 2>&1 | tail
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove dead manifests + the now-unused Terraform pool blocks from the repo**
|
||||
```bash
|
||||
cd /home/jgrusewski/Work/foxhunt
|
||||
git rm -r infra/k8s/training infra/k8s/gpu-overlays 2>/dev/null
|
||||
git rm infra/k8s/jobs/download-trades-job.yaml infra/k8s/argo/train-multi-seed-template.yaml infra/k8s/argo/lob-backtest-sweep-template.yaml infra/k8s/argo/ci-pipeline-template.yaml 2>/dev/null
|
||||
# (also git rm any GPU-runner helm value files + alpha-rl argo templates found under infra/)
|
||||
ls infra/k8s/argo infra/k8s/jobs # eyeball what remains; keep cockpit/fxhnt/fund + cert-manager etc.
|
||||
```
|
||||
- [ ] **Step 4: Remove the dead pool resource blocks + variables from the kapsule module** (optional tidy):
|
||||
in `infra/modules/kapsule/main.tf` remove the `scaleway_k8s_pool.ci_training_l40s`,
|
||||
`...ci_training_h100`, `...ci_compile_cpu_hm` resource blocks (now count=0); in `variables.tf` remove
|
||||
their `enable_*`/`*_type`/`*_max_size` vars; in `terragrunt.hcl` remove the 3 dead `enable_*`/`*_type`
|
||||
lines. Then re-plan to confirm still clean:
|
||||
```bash
|
||||
cd infra/live/production/kapsule && terragrunt plan -input=false -no-color 2>&1 | sed 's/.*tofu: //' | grep -iE "^Plan:|No changes"
|
||||
```
|
||||
Expected: `No changes` (removing count=0 blocks is a no-op against the cluster).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
cd /home/jgrusewski/Work/foxhunt
|
||||
git add -A infra
|
||||
git commit -m "chore(infra): remove dead Rust training/GPU manifests, Argo templates, pool config
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Final verification (cluster + fund still healthy)
|
||||
|
||||
- [ ] **Step 1: Platform/fund health**
|
||||
```bash
|
||||
curl -sS -m 12 -o /dev/null -w "dashboard HTTP %{http_code}\n" https://dashboard.fxhnt.ai/
|
||||
kubectl get pods -n foxhunt | grep -E "dagster|fxhnt-dashboard|gitlab-webservice" | grep -vE "Completed"
|
||||
kubectl get pvc training-data-pvc test-data-pvc feature-cache-pvc fxhnt-surfer-data fxhnt-backtest-data -n foxhunt
|
||||
```
|
||||
Expected: dashboard 200; dagster/dashboard/gitlab Running; all kept PVCs Bound.
|
||||
|
||||
- [ ] **Step 2: Terraform clean**
|
||||
```bash
|
||||
cd /home/jgrusewski/Work/foxhunt/infra/live/production/kapsule && terragrunt plan -input=false -no-color 2>&1 | sed 's/.*tofu: //' | grep -iE "^Plan:|No changes"
|
||||
```
|
||||
Expected: `No changes`.
|
||||
|
||||
- [ ] **Step 3: Merge to main**
|
||||
```bash
|
||||
cd /home/jgrusewski/Work/foxhunt
|
||||
git checkout main && git merge --ff-only chore/decommission-rust-infra && git branch -d chore/decommission-rust-infra
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes for the executor
|
||||
- **Data safety is paramount:** never delete `training-data-pvc`, `test-data-pvc`, `feature-cache-pvc`, the
|
||||
`foxhunt-training-data` bucket, or any fxhnt/platform resource. Re-verify (`Used By`, `.dbn` count) at each
|
||||
delete gate; STOP on any surprise.
|
||||
- **Terraform gate:** Task 1 Step 2 must show exactly the 3 GPU/precompute pools destroyed — stop otherwise.
|
||||
- `pkill -f "port-forward svc/minio"` after Task 4 to clean up the forward.
|
||||
- If the main `gitlab-runner` is ambiguous, KEEP it (a kept idle runner is harmless; a wrongly-removed one breaks CI).
|
||||
@@ -0,0 +1,144 @@
|
||||
# The Surfer over an Uncorrelated Universe — Design Spec
|
||||
|
||||
> **For agentic workers:** This spec defines WHY and WHAT for foxhunt's strategic pivot from
|
||||
> single-instrument seconds-horizon order-book RL to a diversified, days-to-weeks, multi-asset
|
||||
> ML trend system. It does NOT implement; an implementation plan follows (Phase 0 first).
|
||||
|
||||
**Status:** Draft 1 (2026-06-05)
|
||||
**Supersedes (direction):** the seconds-horizon ES order-book RL thesis (measured structurally unprofitable).
|
||||
**Branch target:** new branch `surfer-universe` off the current branch (keeps the session's validation harnesses).
|
||||
**Capital stance:** $35k is a PILOT to prove the pipeline + edge (paper / tiny live); real book targets ~$100k.
|
||||
|
||||
**Linked pearls (load-bearing):**
|
||||
- `pearl_surfer_thesis_is_uncorrelated_trend_recognition` — the thesis
|
||||
- `pearl_surfer_universe_build_synthesis` — the 8-agent research synthesis behind every choice here
|
||||
- `pearl_ofi_edge_uncapturable_by_crossing` / `pearl_passive_mm_knife_edge_ofi_conditioning_promising` — why seconds-horizon ES is dead
|
||||
- `pearl_lowfreq_no_robust_edge_on_2y_es` — why breadth/universe is the bottleneck; validation discipline
|
||||
- `pearl_omnisearch_synthesis_signal_first_and_mtm_reward` — dense-MtM → portfolio Differential Sharpe reward
|
||||
|
||||
---
|
||||
|
||||
## §1. Why this, why now
|
||||
|
||||
Measured this session (zero cluster runs, ~5 cheap harnesses): on ES, seconds-horizon crossing edge is ~100× smaller
|
||||
than the spread; passive MM is adversely-selected negative (winner's curse); intraday has no IC; daily/cross-asset
|
||||
edges sign-flip IS↔OOS (overfit). **The bottleneck was the configuration — single instrument × seconds-horizon ×
|
||||
order-book microstructure — not the model.** That is the worst possible config for a non-colocated ML participant:
|
||||
most-efficient market, most speed-dependent horizon, zero diversification.
|
||||
|
||||
The fix is to invert all three: **many uncorrelated instruments × days-to-weeks horizon × trend/cross-sectional
|
||||
recognition.** This is also foxhunt's actual "surfer" thesis ("no wave is the same") finally pointed at an ocean
|
||||
with enough waves to ride. Breadth = opportunity sourcing: across many uncorrelated markets, some subset always
|
||||
has a rideable trend, and uncorrelated rides don't sink together.
|
||||
|
||||
## §2. The thesis
|
||||
|
||||
A fixed trend rule applies identical logic to every wave. The ML "surfer" reads each unique wave — is it a real
|
||||
trend or chop? building or exhausting? what's the sea-state (regime)? — and adjusts. That recognition is the
|
||||
legitimate ML edge ON TOP of the trend risk premium. **Breadth gives the floor; ML earns its keep above it.**
|
||||
|
||||
## §3. Architecture
|
||||
|
||||
**Core design principle (the eval-collapse cure):** the ML surfer outputs **bounded DEVIATIONS from a deterministic
|
||||
diversified-trend floor**: `w_i = floor_i + clamp(ML_delta_i, ±δ)`. The model can only add value above a proven
|
||||
baseline and degrades gracefully to the floor when it has no edge. It structurally cannot run off a cliff.
|
||||
|
||||
### §3.1 The floor (deterministic)
|
||||
Diversified time-series momentum: `signal_i = mean over h∈{1,3,12}mo of sign(return_{i,h})` (12mo skips last ~21d);
|
||||
inverse-vol size with 63-day EWMA vol; portfolio vol-target 10% annual (rescale weekly on realized portfolio vol);
|
||||
weekly rebalance + ≥10%-change no-trade band. Realistic net Sharpe 0.4–0.5 (small universe), DD 15–25%.
|
||||
|
||||
### §3.2 The ML surfer (bounded deltas), in confidence order
|
||||
1. **Regime / change-point overlay (build FIRST)** — recognizes trend-vs-chop, throttles gross, cuts drawdowns.
|
||||
ML's most defensible edge (DD reduction, NOT return prediction). Lowest overfit surface.
|
||||
2. **Cross-sectional tilt (panel)** — ONE shared encoder over all instruments (N×T samples) + per-instrument
|
||||
embedding → continuous dollar-neutral tilt on the floor; gated on rank-IC / ICIR. NOT discrete ranks (a <20-name
|
||||
universe is statistically starved; breadth is recovered through time + pooling).
|
||||
3. **Direct return prediction → DEFERRED** (the 64-commit trap; cost-cliff at 2–3 bps = turnover noise).
|
||||
|
||||
### §3.3 Reward / objective
|
||||
Portfolio **Differential Sharpe Ratio** (Moody-Saffell) + drawdown penalty + turnover/cost penalty — the
|
||||
portfolio-level, risk-adjusted generalization of the dense per-step mark-to-market reward (Φ=unrealized-PnL).
|
||||
|
||||
### §3.4 Risk / sizing
|
||||
Vol-target 10%; inverse-vol within asset class + ERC across classes; fractional Kelly (half/quarter, reuse the
|
||||
existing Kelly controller clamped [0,0.5]); drawdown throttle θ; reuse the CMDP overlay as the tail kill-switch.
|
||||
Discreteness is binding at micro size: target <0.5 contract → hold 0, carry the residual; $35k sustains ~3–5
|
||||
simultaneous 1-lot positions.
|
||||
|
||||
### §3.5 Reuse of existing foxhunt assets + architecture discipline
|
||||
CfC/Mamba2 encoder (shared across instruments + instrument embedding + cross-sectional attention head); RL stack;
|
||||
Kelly + CMDP controllers; the determinism foundation; ml-alpha's CUDA build pipeline (build.rs cubins, mapped-pinned
|
||||
buffers, cudarc launch). **All compute is CUDA-only, CPU read-only** (`feedback_cpu_is_read_only`,
|
||||
`pearl_cold_path_no_exception_to_gpu_drives`, `feedback_no_cpu_test_fallbacks`): every formula — signals, vol,
|
||||
sizing, backtest returns, and the validation statistics (CPCV per-path Sharpe, PBO, Deflated Sharpe) — is a CUDA
|
||||
kernel; CPU only reads final gate scalars from mapped-pinned buffers and only enumerates split index-sets as control
|
||||
flow. No CPU compute, no cold-path exception, no CPU test oracle. The session's Python `scripts/measure_*.py` were
|
||||
one-off edge AUDITS, not part of the built system; the system is Rust+CUDA. The new surfer lives in `crates/ml-alpha`
|
||||
(reuses its build.rs/cuda/mapped-pinned/determinism infra and, in Phases 1-3, its encoder + RL).
|
||||
|
||||
## §4. Universe + data pipeline
|
||||
|
||||
**Universe (~8 micros, maximal asset-class diversification):** MES, M2K (equity); 10Y micro-yield (rates — note
|
||||
YIELD-based = sign inversion vs price); M6E, M6A (FX); MGC (metals); MCL (energy); MBT (crypto).
|
||||
**Data:** Databento `GLBX.MDP3`, `ohlcv-1d` (+ `ohlcv-1h`). **Continuous contracts built in-house:** volume-roll
|
||||
(validate vs OI) + **backward RATIO adjustment for the encoder's returns**; keep an **UNADJUSTED** series for
|
||||
actual fills + USD reward. NEVER Panama-adjust for a trend learner; NEVER compute reward on adjusted prices.
|
||||
|
||||
## §5. Validation gates (the guardrail — non-negotiable)
|
||||
|
||||
| Gate | Metric | Pass |
|
||||
|---|---|---|
|
||||
| SV-G0 | Leakage audit: purge+embargo covers full hold + feature lookback | verified |
|
||||
| SV-G1 | CPCV (≥50 paths): 5th-percentile OOS pnl after costs | > 0 |
|
||||
| SV-G2 | PBO (CSCV) | < 0.20 |
|
||||
| SV-G3 | Deflated Sharpe, deflated by N = ALL trials ever (64-commit + session + this build) | > 0.95 |
|
||||
| SV-G4 | IS↔OOS config rank-consistency (Spearman) — the sign-flip detector | > 0 |
|
||||
| SV-G5 | Edge survives 2× cost + roll cost | net OOS pnl > 0 |
|
||||
| SV-G6 | Capacity/turnover: cost × turnover < fixed fraction of gross | pass |
|
||||
|
||||
Every config / seed / hyperparameter is a logged trial feeding N. The 64 failed commits prove N is large.
|
||||
|
||||
## §6. Phases (signal-first, cheapest-first, zero cluster until validated)
|
||||
|
||||
- **Phase 0 — Floor + validation harness (CUDA kernels + Rust orchestration, CPU read-only, no ML, no cluster — per foxhunt GPU-drives discipline; `feedback_cpu_is_read_only`, `pearl_cold_path_no_exception_to_gpu_drives`). Runs on the local GPU. FULLY SPECIFIED in the plan.**
|
||||
Build the deterministic floor on real Databento micro data; build the CPCV/PBO/DSR harness; measure whether the
|
||||
floor itself clears the gates OOS after costs. **STOP-if:** floor fails SV-G1/G3/G5 → the universe has no
|
||||
capturable trend premium at this scale; reassess (more instruments / more history) before any ML.
|
||||
- **Phase 1 — Regime overlay.** Must beat the floor OOS (DD reduction first). Gates SV-G1..G6 vs floor.
|
||||
- **Phase 2 — Cross-sectional panel tilt** (shared encoder). Must beat floor+regime OOS via rank-IC/ICIR.
|
||||
- **Phase 3 — Full RL surfer** (floor + bounded delta, DSR reward). Cluster only here, gated by all above.
|
||||
|
||||
## §7. Scope boundary
|
||||
|
||||
**In (this spec):** the floor, the bounded-delta architecture, the 3 ML layers (regime → cross-sectional → RL),
|
||||
the universe + continuous-contract pipeline, the validation gates, the phased build. **Phase 0 is the immediate
|
||||
deliverable.**
|
||||
**Out:** seconds-horizon / order-book microstructure (measured dead); direct return-prediction sequence models
|
||||
(deferred until floor+regime+cross-sectional prove out); live-capital deployment beyond a pilot (until ~$100k and
|
||||
gates pass); broker/execution automation (separate spec once a validated strategy exists).
|
||||
|
||||
## §8. Risks
|
||||
|
||||
1. **Capital ($35k below viable minimum)** — under-diversified → trend's drawdowns without its smoothing; micro-cost
|
||||
drag eats Sharpe. MITIGATION: treat as a pilot to prove pipeline/edge; scale to ~$100k for the real book.
|
||||
2. **ML adds nothing over the floor** — entirely possible; the bounded-delta design makes that a graceful
|
||||
no-op (you keep the floor), and the gates catch it before any spend.
|
||||
3. **Floor itself has no edge at this scale/universe** — Phase 0 STOP catches this cheaply.
|
||||
4. **Re-overfitting (commit #65)** — the SV gates + deflation-by-all-trials are the explicit defense; honor them.
|
||||
5. **Roll / continuous-contract artifacts** — ratio-adjust + volume-roll + reward-on-unadjusted; validated in Phase 0.
|
||||
|
||||
## §9. Decision log
|
||||
|
||||
```
|
||||
Decision: Pivot foxhunt to "the surfer over an uncorrelated universe" — diversified days-to-weeks ML trend across
|
||||
~8 uncorrelated micro futures, ML as bounded deviations from a deterministic trend floor, gated by CPCV/PBO/Deflated-
|
||||
Sharpe. Phase 0 (build+validate the floor, CPU-only) is the immediate, decisive, near-free test.
|
||||
Rejected: continuing seconds-horizon ES order-book RL (measured structurally unprofitable for a non-colocated ML
|
||||
participant — crossing edge 100× < spread; passive MM adversely-selected; daily/cross-asset edges sign-flip).
|
||||
Capital: $35k = pilot to prove the approach; real book ~$100k (Carver retail floor; under-diversification = main risk).
|
||||
ML scope: regime overlay first (DD reduction = ML's defensible edge); cross-sectional tilt second; direct return
|
||||
prediction deferred (the 64-commit trap).
|
||||
Date: 2026-06-05
|
||||
Falsification (Phase 0): floor fails SV-G1/G3/G5 OOS → no capturable trend premium at this scale; reassess before ML.
|
||||
```
|
||||
@@ -0,0 +1,226 @@
|
||||
# Adaptive Multi-Strat Book — Deployable Spec
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Status:** Validated design, pre-deployment (paper-forward running)
|
||||
**Branch:** surfer-universe
|
||||
**Engine:** `scripts/surfer/multistrat_book_v3.py` (validated), `sixtyforty_paper.py` (baseline, live)
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive summary
|
||||
|
||||
A **diversified, edge-decay-adaptive, adaptively-risk-managed, unlevered** multi-asset book — the
|
||||
honest endpoint of an exhaustive search. It is *not* alpha; it is **premium harvesting with superior
|
||||
risk management** (the foxhunt engine's genuine, validated strength). Realistic **~0.5–0.7 Sharpe,
|
||||
~−7% max drawdown**, ~$2–3k/yr on $35k, scales linearly with capital.
|
||||
|
||||
**Why this and not the alternatives** (all tested to OOS exhaustion, all in memory):
|
||||
- Single predictive edges (equities, futures, ML, AI4Finance, PEAD): no OOS alpha for non-colocated retail.
|
||||
- Crypto funding (single-venue): hedgeability-blocked. Cross-venue: marginal/breakeven OOS (the +14.9 Sharpe was a max-min artifact; honest fixed-pair = ~breakeven).
|
||||
- Leverage: **doesn't pay at retail financing** (6–7% margin vs prime-brokerage SOFR+1–2%) — adaptive 1x Sharpe +0.48 vs 2x +0.14. The hedge-fund moat is *cheap financing*, not the strategy.
|
||||
|
||||
**What we keep** (validated, measurable): the **edge-decay-adaptive allocation** (+0.11 Sharpe, lower
|
||||
DD vs static risk-parity) and the **adaptive risk layer** (beat the static one decisively; halves
|
||||
drawdown). The engine's value is *risk management + adaptive allocation*, and that is real.
|
||||
|
||||
---
|
||||
|
||||
## 2. Strategy overview
|
||||
|
||||
Hold a small set of **uncorrelated return streams**, each vol-normalized to equal risk, weighted by
|
||||
a **per-stream edge-health trust** (down-weight decaying streams, resurrect recovered ones), combined
|
||||
and run through an **adaptive risk-management layer**, **unlevered (~1x)**, rebalanced on a schedule.
|
||||
|
||||
Return source = the structural premia (equity, term, gold/inflation, commodity, trend/crisis-alpha,
|
||||
a small crypto sleeve). The *alpha* is the disciplined, adaptive risk allocation — not prediction.
|
||||
|
||||
---
|
||||
|
||||
## 3. Instruments (deployable at $35k via fractional-share ETFs + small crypto)
|
||||
|
||||
| Stream | ETF | Role |
|
||||
|---|---|---|
|
||||
| Equity | **SPY** (or VTI) | equity risk premium |
|
||||
| Bonds | **IEF** (7–10y Treasuries) | term premium, equity diversifier |
|
||||
| Gold | **GLD** (or IAU) | inflation/crisis diversifier |
|
||||
| Commodity | **PDBC** (or DBC) | inflation/commodity premium |
|
||||
| Trend / managed futures | **DBMF** (or KMLM) | crisis-alpha, uncorrelated (use the *real* CTA ETF — our DIY trend was ~0 Sharpe) |
|
||||
| Crypto (small) | **BTC spot** (or IBIT) | high-return uncorrelated sleeve; trust-layer auto-caps it (currently down-weighted in deleverage) |
|
||||
|
||||
All ETFs are marginable, fractional-shareable, ~zero commission. **Unlevered** (cash account is fine;
|
||||
no margin needed). Crypto held separately (spot/IBIT), sized small.
|
||||
|
||||
---
|
||||
|
||||
## 4. Allocation engine — edge-decay-adaptive trust (foxhunt idea, validated)
|
||||
|
||||
For each stream `i`, maintain a **trust** `θᵢ ∈ [0.1, 1]`:
|
||||
- Compute trailing-126d risk-adjusted return (Sharpe) of the stream.
|
||||
- Map: Sharpe ≥ +0.5 → θ=1.0; ≤ −0.5 → θ=0.1 (floored — **resurrection-capable**); linear between.
|
||||
- EMA-smooth (α≈0.06) so it adapts gradually, not jumpily.
|
||||
|
||||
Weight each (vol-normalized) stream by `θᵢ`, normalize to sum 1. This **down-weights decaying premia
|
||||
and re-weights recovered ones** automatically — currently: equity 0.73, commod 1.00, trend 0.79
|
||||
(healthy) vs bond 0.11, crypto 0.10 (correctly de-emphasized). Validated: +0.11 Sharpe, lower DD vs
|
||||
static equal-risk. Source: `pearl_edge_decay_detection_is_a_missing_abstraction_layer`,
|
||||
`pearl_dead_signal_resurrection_discipline`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Risk-management layer — adaptive controllers (the foxhunt port; the load-bearing piece)
|
||||
|
||||
Static thresholds **crush returns** (one-way latch, `pearl_cmdp_consec_loss_counter_is_one_way_latch`).
|
||||
The adaptive layer (validated: beat static +0.03→+0.14 Sharpe, maxDD −18.7%→−14.5%). Daily leverage
|
||||
`L = clip( min(L_vol, L_kelly) × dd_mult × corr_mult , LEV_FLOOR, MAXLEV )`, applied to the combination:
|
||||
|
||||
1. **EMA online vol** (α≈0.03) → `L_vol = TARGET_VOL / realized_vol` (responsive, no window edges).
|
||||
2. **Kelly with floor + bootstrap** → `L_kelly = clip(EMA_mean·252 / EMA_var·252, KELLY_FLOOR=0.5, MAXLEV)` — adapts to edge strength, **never dies** (`pearl_bootstrap_must_respect_clamp_range`).
|
||||
3. **Drawdown de-lever, CONTINUOUS + self-recovering** → `dd_mult = clip(1 − 3·max(0, −dd − 0.05), 0.40, 1.0)` — scales down as drawdown deepens, **recovers immediately as it heals** (NOT a latch — this is the fix).
|
||||
4. **Correlation de-risk, z-scored** → when avg cross-stream correlation is unusually high vs its own trailing distribution (diversification breaking in a crisis), reduce: `corr_mult = clip(1 − 0.2·max(0, z), 0.5, 1.0)`.
|
||||
5. **Leverage floor** `LEV_FLOOR=0.3`, **MAXLEV=1.0** (unlevered — see §8).
|
||||
|
||||
Defaults: `TARGET_VOL=10%`. The whole layer is the engine's true job — *survive and adapt*, not maximize.
|
||||
|
||||
---
|
||||
|
||||
## 6. Rebalance & execution
|
||||
|
||||
- **Weekly rebalance** (fixed-phase): recompute vol-normalization, trust weights, and `L`; trade to target.
|
||||
- **Hysteresis / no-churn:** only trade a stream if its target weight moved > ~2% (cut turnover/cost).
|
||||
- **Cost budget:** ETFs ~0 commission; the only friction is spread/slippage (minimal on SPY/IEF/GLD/DBMF). Crypto sleeve: small, spot.
|
||||
- **Cash account, unlevered** — no financing cost, no liquidation risk.
|
||||
|
||||
---
|
||||
|
||||
## 7. Expected performance (honest)
|
||||
|
||||
**Validated** on the real ETFs (SPY/IEF/GLD/PDBC/DBMF + BTC) over 2019-05..2026-06, exact live
|
||||
pipeline (`multistrat_etf_backtest.py`), multiple regimes (COVID, 2022 bear, bulls):
|
||||
|
||||
| Metric | Backtest 2019–26 | Realistic forward |
|
||||
|---|---|---|
|
||||
| Sharpe (unlevered) | **+1.20** | ~0.8–1.0 (haircut: favorable period + low-vol-flatter) |
|
||||
| Annual return | +6.1% | ~5–7% |
|
||||
| Realized vol | 5.1% | ~5–8% |
|
||||
| Max drawdown | **−5.4%** | ~−8 to −12% |
|
||||
| Per-year | **positive every year** incl 2022 (+0.1) | — |
|
||||
| vs 60/40 (SPY/IEF) | +0.87 Sharpe, −21% DD | book wins on Sharpe AND drawdown |
|
||||
| vs equity buy-hold | +0.85 Sharpe, −34% DD | — |
|
||||
| On $35k | ~$2.1k/yr, very smooth (−5% DD) | — |
|
||||
| On $500k | ~$30k/yr (Sharpe scale-invariant — capital is the lever) | — |
|
||||
|
||||
The robust signals are the **−5.4% max drawdown and every-year-positive** (not just the low-vol
|
||||
Sharpe). The book genuinely beats 60/40 on risk-adjusted terms with ~1/4 the drawdown.
|
||||
|
||||
Honest caveats: it's **long beta** (falls in everything-down 2022-style, though trend + adaptive
|
||||
de-lever cushion it); returns are *modest* in dollars at small capital (the lever is capital, not
|
||||
Sharpe); crypto sleeve validated on one window — keep it small and let the trust layer cap it.
|
||||
|
||||
---
|
||||
|
||||
## 8. Why unlevered (the key finding)
|
||||
|
||||
Leverage scales return but **at retail financing (6–7%) it lowers risk-adjusted return**: adaptive 1x
|
||||
Sharpe +0.48 vs 2x +0.14 — the financing drag eats the leverage benefit, while drawdown grows. Hedge
|
||||
funds lever ~0.7-Sharpe books profitably **only because prime brokerage finances at ~SOFR+1–2%.** That
|
||||
financing access is the institutional moat, and it's structural — not replicable at retail. **So: run
|
||||
it 1x.** Re-evaluate leverage only if you ever access institutional-rate financing.
|
||||
|
||||
---
|
||||
|
||||
## 9. Validation status
|
||||
|
||||
**Validated:** edge-decay-adaptive allocation (>static), adaptive risk layer (>static, halves DD),
|
||||
combination Sharpe ~0.5–0.72 across windows, the unlevered-is-best financing finding. Scripts:
|
||||
`multistrat.py`, `multistrat_book.py`, `multistrat_book_v2.py`, `multistrat_book_v3.py`.
|
||||
**Live baseline:** `sixtyforty_paper.py` paper-forward running (the 60/40 core).
|
||||
**Not yet:** live execution of the full 6-stream adaptive book (paper-forward harness for it = next),
|
||||
real ETF fills/spreads (minor), the crypto sleeve operationally.
|
||||
|
||||
---
|
||||
|
||||
## 10. Phased rollout
|
||||
|
||||
1. **Paper-forward (4–8 wk):** extend the live harness to the full 6-stream adaptive book (ETF closes
|
||||
via Yahoo + BTC), logging target weights + realized — confirm the design forward. (60/40 core
|
||||
already runs via `sixtyforty_paper.py`.)
|
||||
2. **Micro-live (small):** deploy the ETF book unlevered at small size; validate rebalance discipline,
|
||||
spreads, the trust/risk layer operating live.
|
||||
3. **Scale:** full capital, same unlevered book. Returns scale with capital, Sharpe unchanged.
|
||||
|
||||
Gate each phase: forward Sharpe ~consistent, drawdown controlled, trust/risk layer behaving.
|
||||
|
||||
---
|
||||
|
||||
## 11. Go / no-go + risk limits
|
||||
|
||||
- **Go:** you accept a modest (~0.5–0.7 Sharpe, ~$2–3k/yr on $35k) but *robust, well-risk-managed,
|
||||
unlevered* diversified book whose return scales with capital — and that the engine's role is risk
|
||||
management, not alpha.
|
||||
- **No-go / reconsider:** if you need higher absolute return at $35k (then the lever is *more capital*,
|
||||
not more strategy), or if you want market-neutral (this is long-beta — accept the equity-correlated
|
||||
drawdowns).
|
||||
- **Risk limits:** unlevered (MAXLEV 1.0); crypto sleeve ≤ ~15% target (trust-capped); weekly rebalance;
|
||||
the adaptive layer de-risks automatically in vol spikes / correlation crises / drawdowns.
|
||||
|
||||
**The honest bottom line:** after exhausting every alpha avenue (incl. the engine's own advanced
|
||||
ideas), this is what's real and deployable — a simple, diversified, adaptively-risk-managed harvest.
|
||||
The engine earns its keep as the risk/allocation brain, not as an oracle. The path to *meaningful*
|
||||
money is capital, not a better signal.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Micro-live runbook (Phase 2, concrete)
|
||||
|
||||
**Validation so far (historical, exhaustive):** in-sample Sharpe +1.20; holdout-OOS +1.41; 20-year
|
||||
(2006–26, conservative no-DBMF/crypto version) +0.96 surviving 2008/2020/2022 with −10% maxDD;
|
||||
bootstrap CI p5 +0.62 / median +0.99, P(Sharpe>0.5)=99%. The *only* thing history can't give is
|
||||
forward/live confirmation — that's what micro-live provides.
|
||||
|
||||
**Objective:** validate *execution* — fills, spreads, the weekly/monthly rebalance, the live adaptive
|
||||
weighting — and start the real-money clock. **NOT to make money** (on $1k the P&L is pennies). It
|
||||
de-risks the scale step; that's its whole job.
|
||||
|
||||
**Capital:** $500–1,000, fully at-risk, **unlevered**. Worst case ≈ the book's maxDD (~−7%) ≈ −$70.
|
||||
Trivial by design — that's the point of micro.
|
||||
|
||||
**Instruments (all in ONE brokerage, fractional shares):**
|
||||
| Stream | Ticker | Note |
|
||||
|---|---|---|
|
||||
| equity | SPY | |
|
||||
| bonds | IEF | |
|
||||
| gold | GLD | |
|
||||
| commodity | PDBC | |
|
||||
| trend / CTA | DBMF | the real managed-futures ETF (our DIY trend was ~0) |
|
||||
| crypto | **IBIT** | iShares Bitcoin ETF — use instead of BTC spot: in-brokerage, fractional, **NO crypto-exchange counterparty tail** |
|
||||
|
||||
Broker: any fractional-share, ~zero-commission (IBKR / Fidelity / Schwab / Robinhood). IBKR or
|
||||
Fidelity recommended (clean fractional fills).
|
||||
|
||||
**Step 1 — today's target:**
|
||||
```
|
||||
python3 scripts/surfer/multistrat_paper.py weights 1000
|
||||
```
|
||||
→ exact % and $ per instrument (the adaptive trust + risk-layer output). Map the crypto line → IBIT.
|
||||
|
||||
**Step 2 — place orders manually** (micro scale → eyeball the fills). Marketable-limit or market on
|
||||
these liquid ETFs. Record actual fill prices.
|
||||
|
||||
**Step 3 — rebalance MONTHLY (not weekly) at micro scale.** At $1k, weekly deltas are ~$3 —
|
||||
rounding-dominated and operationally silly. Rebalance monthly, and only trade an instrument if its
|
||||
target weight drifted >~5% (hysteresis). The adaptive layer's value shows over months, not days.
|
||||
|
||||
**Step 4 — track actual vs intended.** Each rebalance, log the harness's intended weights vs your
|
||||
actual fills → measure (a) tracking error, (b) realized cost (spread/slippage), (c) live-vs-paper match.
|
||||
|
||||
**Gate to scale (~4–8 weeks):**
|
||||
- Live cumulative ≈ paper-forward cumulative (small tracking error).
|
||||
- Realized cost ≤ ~10–20 bp/rebalance (should be tiny on these ETFs).
|
||||
- Rebalance executes cleanly; adaptive weights behave sensibly; no operational surprises.
|
||||
- → all green: **scale to full capital, same unlevered book** (Sharpe is scale-invariant; capital is the lever).
|
||||
- → tracking error large / cost high: diagnose before scaling.
|
||||
|
||||
**Risk limits:** unlevered (no margin); $500–1k at-risk; adaptive layer auto-de-risks in vol/DD;
|
||||
IBIT removes the counterparty tail. **What it does NOT do:** make meaningful money — it proves the
|
||||
machine runs cleanly with real fills before you commit real capital. The honest bridge from
|
||||
validated backtest to scaled deployment.
|
||||
@@ -0,0 +1,183 @@
|
||||
# Crypto Funding Harvest — Deployable Strategy Spec
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Status:** Validated (4 gates passed), pre-deployment
|
||||
**Branch:** surfer-universe
|
||||
**Author:** research campaign synthesis
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive summary
|
||||
|
||||
A **delta-neutral crypto perpetual-funding harvest**: hold `long spot + short perp` on coins
|
||||
whose funding rate is reliably positive, collecting the funding payment as carry with **no price
|
||||
exposure**. No prediction, no ML — a pure cross-sectional carry harvest gated by a regime filter.
|
||||
|
||||
**Why this and not everything else:** a multi-week, multi-market search (efficient equities/futures
|
||||
= no alpha, leak-free ML IC 0.004; simple 60/40 = ~0.7 Sharpe ceiling; energy = capital not
|
||||
algorithm) found this is the **only edge that breaks the ~0.7 retail Sharpe ceiling**, and it does
|
||||
so because crypto funding is structurally inefficient (retail perp longs over-pay) and the harvest
|
||||
is *latency-insensitive* (no colocation needed — a carry trade, not a race).
|
||||
|
||||
**Validated metrics (this dataset, 135 coins, 2019–2026):**
|
||||
|
||||
| Gate | Result |
|
||||
|---|---|
|
||||
| Structural | funding positive 75.5% of the time; 78.8% of coins pay carry on an avg day |
|
||||
| Delta-neutral | worst monthly bucket −1.5% (price risk genuinely hedged) |
|
||||
| Liveness | 2025–26 weakness is *regime* (deleverage), not decay; positive-funding yield intact (~3bp, = 2019/2023 levels) |
|
||||
| **Clean OOS** | filter chosen on 2019–24, applied blind to 2025–26 → **OOS Sharpe +5.1 raw** (IS-best); 9/12 strong-IS filters also OOS-positive; naive no-filter FAILS OOS (−5.8) |
|
||||
|
||||
**Honest expected performance (realistic, after basis-vol haircut ÷~2.5):**
|
||||
- **Sharpe ~2–3.6** (raw funding-only model shows 6–9; realistic accounts for basis/tracking-error vol we cannot model with single-price data)
|
||||
- **APR ~15–22%** on deployed capital (sensible-hurdle filter), regime-dependent
|
||||
- **Max drawdown ~−7 to −15%** (price-neutral; drawdowns are funding-flip + churn, not crashes)
|
||||
- **The Sharpe does NOT show the real risk: counterparty/exchange failure (−100% tail).**
|
||||
|
||||
---
|
||||
|
||||
## 2. The edge (why it exists, why it persists)
|
||||
|
||||
- **Perpetual futures funding** is the mechanism that tethers perp price to spot. When perps trade
|
||||
at a premium (bullish retail leverage), **longs pay shorts** a periodic funding rate (typically
|
||||
8-hourly on Binance/Bybit/OKX).
|
||||
- **Net structural bias is positive**: retail over-leverages long → funding is positive ~75% of
|
||||
the time. A `long spot + short perp` position is **delta-neutral** (spot and perp price moves
|
||||
cancel) and **collects the funding** the short perp leg receives.
|
||||
- **Why it persists** (not arbitraged to zero): capturing it requires capital *pre-positioned on
|
||||
each venue*, active management, and acceptance of counterparty risk — operational/risk frictions,
|
||||
not informational ones. It is the crypto analogue of an insurance premium.
|
||||
- **Latency-insensitive:** funding accrues over 8h periods; seconds of execution delay are
|
||||
immaterial. This is the single reason it is reachable by a non-colocated retail trader, unlike
|
||||
cross-exchange *latency* arb (which needs colocation and is NOT this strategy).
|
||||
|
||||
---
|
||||
|
||||
## 3. Strategy rules (precise, implementable)
|
||||
|
||||
### 3.1 Universe
|
||||
- Perpetual contracts on a **tier-1 venue** (see §4) that offers both spot and perp for the coin.
|
||||
- **Liquidity floor:** trailing-30d mean quote volume **> $5M/day** (validated threshold).
|
||||
- Exclude stablecoin-pair anomalies, delisting candidates, and coins without a spot leg.
|
||||
|
||||
### 3.2 Signal (the regime filter — the load-bearing piece)
|
||||
- For each eligible coin, compute **trailing-30-day mean funding rate** `tf30`.
|
||||
- **Qualify a coin iff `tf30 > 5 bp/day`** (≈ persistently well-paid carry). This is the validated
|
||||
filter: strong both IS (+7.9) and OOS (+9.1), and it sits the book out of the deleverage regime
|
||||
that whipsaws the naive "harvest anything positive" version (which FAILED OOS).
|
||||
- Causality: position decisions use **yesterday's** funding (`tf30` through t−1) to harvest day t.
|
||||
Funding is persistent, so this is both realistic and leak-free.
|
||||
|
||||
### 3.3 Positioning
|
||||
- For each qualifying coin: **long spot notional X + short perp notional X** (delta-neutral).
|
||||
- **Equal-weight** across qualifying coins (validated; funding-weighting did not improve risk-adj).
|
||||
- **Cash when nothing qualifies** — in a deleverage regime few/no coins clear the 5bp hurdle; the
|
||||
book correctly de-risks to stablecoin (do NOT force-harvest a dead regime).
|
||||
|
||||
### 3.4 Rebalance & cost
|
||||
- **Daily** check: enter coins crossing above the hurdle, exit coins falling below.
|
||||
- Cost budget: strategy validated net of **10 bp round-trip** (perp + spot, both legs); survives to
|
||||
~20 bp. Use **maker/limit orders** on entry/exit where possible to stay inside budget. Avoid
|
||||
rebalancing on marginal hurdle-crossings (add hysteresis: enter >5bp, exit <3bp, to cut churn).
|
||||
|
||||
### 3.5 Sizing
|
||||
- Per-coin notional = (deployed capital) / (number of qualifying coins), capped by §4 per-venue
|
||||
limits.
|
||||
- **Leverage:** the short-perp leg uses exchange margin; keep effective leverage low (≤2–3×) so a
|
||||
funding-flip + basis move cannot trigger liquidation. The spot leg is the hedge — never let the
|
||||
perp get liquidated while holding spot (that converts delta-neutral into naked long).
|
||||
|
||||
---
|
||||
|
||||
## 4. Risk management — counterparty is THE risk (read this twice)
|
||||
|
||||
Delta-neutrality removes *price* risk. It does **not** remove **counterparty/exchange risk**, which
|
||||
is the actual way this strategy produces a −100% (FTX, Mt. Gox, QuadrigaCX). The Sharpe ratio is
|
||||
blind to it. **This section matters more than the backtest.**
|
||||
|
||||
1. **Venue selection:** only tier-1 venues with **proof-of-reserves**, deep liquidity, and a
|
||||
solvency track record. No yield-farming protocols, no obscure CEXs chasing higher funding.
|
||||
2. **Collateral spreading:** spread capital across **≥2–3 venues** so no single failure is fatal.
|
||||
*Caveat at $35k:* small capital makes spreading hard (per-venue minimums + cost). Below ~$50k,
|
||||
counterparty concentration is unavoidable — treat the whole strategy as at-risk capital you can
|
||||
lose entirely, and start tiny (§7).
|
||||
3. **Withdrawal discipline:** keep only working collateral on exchanges; sweep profits to
|
||||
self-custody on a schedule. Never let the on-exchange balance grow unmonitored.
|
||||
4. **Liquidation guard:** low leverage (≤2–3×), automated margin-top-up alerts; the spot hedge must
|
||||
always survive a perp-leg margin call.
|
||||
5. **Deleverage kill-switch:** if fleet-wide funding goes broadly negative (the 2022/2025-26 signal:
|
||||
`frac_pos < 0.5` or `mean_all < 0`), **go fully to cash** — the filter does this automatically,
|
||||
but add a hard override.
|
||||
6. **Stablecoin risk:** the cash leg sits in stablecoins (USDT/USDC) — itself a (smaller)
|
||||
counterparty/depeg risk; diversify stable holdings.
|
||||
7. **Position cap per venue:** no more than (venue risk budget) on any one exchange.
|
||||
|
||||
---
|
||||
|
||||
## 5. Expected performance (honest)
|
||||
|
||||
| Metric | Raw model (funding-only) | Realistic (after basis vol) |
|
||||
|---|---|---|
|
||||
| Sharpe | 6–9 | **~2–3.6** |
|
||||
| APR (deployed) | ~17–22% | ~15–22% |
|
||||
| Max drawdown | −7 to −15% | similar (price-neutral) |
|
||||
| Worst month | −1.5% | basis noise adds some |
|
||||
| Regime behavior | cash in deleverage | cash in deleverage |
|
||||
|
||||
- **The realistic Sharpe (~2–3.6) is still 3–5× the 0.7 retail ceiling** for everything else found.
|
||||
- **Dollar reality at $35k:** ~15–20% APR ≈ **$5–7k/yr** expected, market-neutral — but with full
|
||||
counterparty-loss tail. Scales linearly with capital (Sharpe is scale-invariant; capacity is far
|
||||
above retail size).
|
||||
- **Unmodeled / open:** basis-vol (need spot+perp tick pairs to measure precisely), and the
|
||||
counterparty tail (un-backtestable — managed via §4, not measured).
|
||||
|
||||
---
|
||||
|
||||
## 6. Validation status
|
||||
|
||||
**Passed:**
|
||||
- Structural (funding positive 75.5%), delta-neutral (worst month −1.5%), liveness (regime not
|
||||
decay), clean OOS (IS-chosen filter holds OOS +5.1, 9/12 robust, naive no-filter fails OOS).
|
||||
- Scripts: `scripts/surfer/crypto_funding_harvest.py`, `crypto_funding_liveness.py`,
|
||||
`crypto_funding_oos.py`.
|
||||
|
||||
**Not yet validated (must close before scaling):**
|
||||
- **Basis vol** — current Sharpe is funding-only; get spot+perp price pairs and remodel realized P&L
|
||||
including tracking error → confirm realistic Sharpe.
|
||||
- **OOS length** — only ~1.5y OOS (single deleverage regime); held through the *hard* case, but
|
||||
extend as data accrues.
|
||||
- **Counterparty** — un-backtestable; validated only by §4 design discipline.
|
||||
- **Live execution** — fills, slippage, funding-timestamp capture, margin mechanics on the real
|
||||
venue (the paper-forward test, §7).
|
||||
|
||||
---
|
||||
|
||||
## 7. Phased rollout (gate each phase before the next)
|
||||
|
||||
1. **Paper-forward (4–8 weeks):** run the daily algorithm against *live* funding feeds, record
|
||||
intended positions + realized funding, **no capital**. Confirms the edge persists on truly
|
||||
unseen forward data (mirrors the surfer PoC's cron paper-test). Gate: forward funding-APR
|
||||
positive and consistent with backtest.
|
||||
2. **Micro-live ($1–3k, 1 venue):** smallest real size to validate execution, fills, funding
|
||||
capture, margin mechanics, cost budget. Gate: realized net ≈ paper, cost ≤ 15bp round-trip.
|
||||
3. **Small-live ($5–15k, 2 venues):** add collateral spreading, confirm counterparty controls and
|
||||
withdrawal discipline operate. Gate: clean operation through a funding-flip / mini-deleverage.
|
||||
4. **Scale ($35k+):** deploy full capital across ≥3 venues, full §4 risk stack engaged.
|
||||
|
||||
**Stop at any gate that fails.** This is the same diagnostic-first discipline that caught every
|
||||
false positive in the search (the inflated 5.68 Sharpe, the carry trap, the equity factors).
|
||||
|
||||
---
|
||||
|
||||
## 8. Go / no-go
|
||||
|
||||
**Go** if: paper-forward (§7.1) confirms positive forward funding-APR AND you accept that this is
|
||||
**at-risk crypto capital with a counterparty −100% tail** in exchange for a realistic ~2–3.6 Sharpe
|
||||
market-neutral return.
|
||||
|
||||
**No-go** if: you cannot accept the counterparty tail, OR paper-forward shows the edge has decayed
|
||||
(funding compression to ~cost), OR you cannot spread across ≥2 reputable venues at your capital.
|
||||
|
||||
**The honest framing:** this is the one validated way past 0.7 the whole search found. It is real,
|
||||
alive, and reachable — and the price of admission is crypto + counterparty risk, not market risk.
|
||||
That trade-off is a values decision, now made with complete information.
|
||||
86
docs/superpowers/specs/2026-06-08-cagr-reference-plan.md
Normal file
86
docs/superpowers/specs/2026-06-08-cagr-reference-plan.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# CAGR-Maximizer — Honest Reference Plan
|
||||
|
||||
**Date:** 2026-06-08
|
||||
**Purpose:** the honest answer to "how do we build the most wealth?" after exhaustively testing strategies,
|
||||
the adaptive multi-strat book, leverage, and risk-management. Comparison tool: `scripts/surfer/strategy_compare.py`.
|
||||
|
||||
---
|
||||
|
||||
## The core truth (measured, not assumed)
|
||||
|
||||
1. **CAGR builds terminal wealth, not Sharpe.** Over a long horizon with contributions, the highest-CAGR
|
||||
asset wins — even at lower Sharpe. (SPY 0.64 Sharpe but 12.4% CAGR beats the book's 0.96 Sharpe / 5.1% CAGR on money.)
|
||||
2. **More CAGR = more risk. Always. No exception.** Every risk-managed variant (the book, the overlay,
|
||||
blends) gives up CAGR for lower drawdown. Risk management is *insurance you pay for*, not a free improvement.
|
||||
3. **The only "free" CAGR is drag reduction** — taxes, fees, and (biggest) not selling at the bottom.
|
||||
4. **The book's apparent Sharpe edge (0.96 vs 0.64) was an rf=0 artifact.** Measured as excess-over-financing
|
||||
(what matters for leverage), equities (0.49) actually beat the diversified book (0.40) in this era —
|
||||
so levering the book does NOT beat buy-hold equity. The institutional risk-parity edge needs the book's
|
||||
*excess* Sharpe > equity's, which it wasn't.
|
||||
5. **The leverage moat is cheap financing.** Futures/box ≈ SOFR+spread (~3%); retail margin ~6.5% destroys it.
|
||||
|
||||
---
|
||||
|
||||
## The candidates (measured 2006–2026, incl. 2008 −55%)
|
||||
|
||||
| Strategy | CAGR | vol | Sharpe | maxDD | $360k+$8k/mo → 20y |
|
||||
|---|---|---|---|---|---|
|
||||
| **SPY buy-hold (1.0x)** | ~10.6% | 19% | 0.64 | **−55%** | ~$7.8M |
|
||||
| SPY 1.2–1.3x (cheap futures) | ~12–13% | 23–25% | ~0.6 | **−63 to −67%** | ~$8.8–9.2M |
|
||||
| SPY 1.5x | ~14.5% | 28% | — | −73% | ~$10.1M |
|
||||
| 60/40 | ~8% | 14% | 0.73 | −20 to −30% | ~$6.5M |
|
||||
| 70/30 SPY+book | ~10% | 14% | 0.73 | −40% | ~$9.0M |
|
||||
| SPY + adaptive overlay | ~8% | 11% | 0.77 | −18% | ~$6.7M |
|
||||
| **Adaptive multi-strat book** | ~5–6% | 5% | **0.96** | **−10%** | ~$4.3–4.9M |
|
||||
|
||||
(SPY ≥2x: CAGR peaks ~2x then volatility-drag falls; −84%+ drawdown = ruin/margin-call territory. Not viable.)
|
||||
|
||||
---
|
||||
|
||||
## CAGR levers, ranked by sense
|
||||
|
||||
| Lever | Effect | Honesty |
|
||||
|---|---|---|
|
||||
| **Max equity allocation, held** | base ~10–11%/yr | highest-return asset; reliable |
|
||||
| **Reduce drag** (tax-advantaged account, cheap index funds, never sell, stay invested) | **+1–3%/yr** | **free + reliable — most people leave this on the table** |
|
||||
| **Time + contributions ($8k/mo)** | dominant terminal driver | your strongest card |
|
||||
| **Modest cheap leverage (~1.2–1.3x via futures)** | +~1.5–2%/yr (+$1–1.4M/20y) | amplifies drawdown to −63–67% + margin-call risk |
|
||||
| Factor tilts (small-cap value, momentum, quality) | hist. +1–2%/yr | less reliable forward |
|
||||
| ~~Risk-managed book / overlay~~ | **−4%/yr CAGR** | a risk *reducer*, not a CAGR maximizer |
|
||||
|
||||
---
|
||||
|
||||
## The leverage reality (the only knob with real upside — handle with care)
|
||||
|
||||
- 1.2–1.3x boosts CAGR ~+1.5–2%/yr (~$1–1.4M over 20y) but turns the −55% crash into **−63–67%**.
|
||||
- **−67% on a $2M pot = $660k at the 2009 bottom**, while still contributing into the abyss.
|
||||
- **Margin calls:** fixed-notional futures in a −55% crash force liquidation at the bottom → *realized* ruin
|
||||
(the backtest "survives" only because it's daily-rebalanced math). Lived experience is worse than backtest.
|
||||
- Above ~1.5x: reckless (−73%+); above ~2x: ruin.
|
||||
- **The real constraint is not the math — it's whether you survive a −65% drawdown + margin without forced/panic selling.**
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
For a disciplined accumulator with a strong savings rate (~$8k/mo), long horizon, and stable income:
|
||||
|
||||
1. **Max equity (100%), in a tax-advantaged account, cheap index funds, contribute monthly, never sell.**
|
||||
Base case ~$7.8M over 20y. Simple, cheap, the most for the disciplined.
|
||||
2. **Optional ~1.2–1.3x via futures (cheap financing)** — only if you can survive (financially + emotionally)
|
||||
a −65% drawdown + margin calls without forced selling. Adds ~$1–1.4M over 20y.
|
||||
3. **Always: reduce drag** (tax-efficiency, low fees, stay invested) — the free CAGR.
|
||||
4. **The adaptive multi-strat book is NOT the wealth-maximizer** — it's the *capital-preservation / low-stress / withdrawal-phase* tool. Use it if you'd panic-sell equities, are near withdrawal, or value sleep over ~4%/yr.
|
||||
|
||||
**The decision hinges on one honest question: do you sell at the bottom of a crash, or hold?**
|
||||
- Hold → high equity (±1.3x), simplest, most money.
|
||||
- Unsure → 70/30 or the overlay (pay return for a tolerable ride).
|
||||
- Need capital preservation → the book.
|
||||
|
||||
---
|
||||
|
||||
## What's deployed
|
||||
|
||||
- **Adaptive multi-strat book:** live on IBKR paper via the K8s CronJob (autonomous, daily eval) — the *low-drawdown* reference.
|
||||
- **Comparison tool:** `scripts/surfer/strategy_compare.py` — re-runnable side-by-side of all candidates (backtest + trajectory).
|
||||
- **Honest bottom line:** there is no secret signal and no clever leverage trick that beats "high-return assets, cheap, held long, funded heavily." We tested them all. The wealth levers are savings rate, time, low costs, and the discipline to not sell.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Decommission Dead Rust Infra — Design (Phase 1 of infra consolidation)
|
||||
|
||||
**Date:** 2026-06-21
|
||||
**Status:** Design (pending approval)
|
||||
**Repo:** foxhunt (where the platform IaC currently lives)
|
||||
|
||||
## Motivation
|
||||
|
||||
The active trading work is now the Python **fxhnt** fund; the Rust **foxhunt** ML/HFT system is dormant.
|
||||
Its dedicated infra (GPU training pools, Rust build/training caches, GPU CI runners, training artifact
|
||||
buckets, training manifests) is unused but still declared/provisioned. Phase 1 decommissions it — for
|
||||
cost/clutter savings and to shrink the surface before Phase 2 (relocating the remaining *platform* IaC
|
||||
into `fxhnt/infra`, a separate spec).
|
||||
|
||||
**Verified unused (2026-06-21):** L40S + H100 pools have 0 nodes; `cargo-target-{cpu,cuda,cuda-test}` +
|
||||
`feature-cache-pvc` are `Used By: <none>`; no gitlab-runner pods running.
|
||||
|
||||
## Scope — what gets removed (all verified unused)
|
||||
|
||||
1. **Terraform (kapsule module + terragrunt.hcl):** set to `false` →
|
||||
- `enable_ci_training_l40s_pool` (L40S-1-48G GPU pool)
|
||||
- `enable_ci_training_h100_pool` (H100-1-80G GPU pool)
|
||||
- `enable_ci_compile_cpu_hm_pool` (POP2-HM-32C-256G precompute pool — Rust `precompute_features` only)
|
||||
`terragrunt apply` destroys exactly these 3 pools. Also delete their now-dead var blocks/resources.
|
||||
2. **Helm uninstall** (foxhunt ns): `gitlab-runner-h100`, `gitlab-runner-h100-sxm`, `gitlab-runner-h100x2`
|
||||
(Rust GPU CI runners). **Main `gitlab-runner`:** verify nothing non-Rust uses it (fxhnt has no
|
||||
`.gitlab-ci.yml`; cockpit deploys via Argo) → uninstall if confirmed dead, else keep. (confirm step)
|
||||
3. **PVCs delete** (foxhunt ns — PURE BUILD CACHES only, unmounted-verified, ~175 GB reclaimed):
|
||||
`cargo-target-cpu` (60Gi), `cargo-target-cuda` (45Gi), `cargo-target-cuda-test` (30Gi),
|
||||
`sccache-cpu` (20Gi), `sccache-cuda` (20Gi). These hold zero data — regenerated on any build.
|
||||
4. **MinIO buckets delete** (NO market data — verified 0 `.dbn` objects): `foxhunt-binaries` (2.3 GiB
|
||||
compiled binaries), `foxhunt-training-results` (4.4 GiB run logs/outputs), `foxhunt-models` (empty).
|
||||
5. **Repo manifests/templates remove** (foxhunt repo): `infra/k8s/training/`, `infra/k8s/gpu-overlays/`,
|
||||
the Rust Argo workflow templates (`train-multi-seed-template.yaml`, `lob-backtest-sweep-template.yaml`,
|
||||
`ci-pipeline-template.yaml`, alpha-rl train templates), `infra/k8s/jobs/download-trades-job.yaml`
|
||||
(databento), the 3 GPU-runner helm value files. Also remove the dead Argo `WorkflowTemplate`s from the
|
||||
cluster (`kubectl delete wftmpl`) for the Rust train/backtest pipelines.
|
||||
|
||||
## 🛑 EXPLICITLY PRESERVE (do NOT delete — market data + reusable assets)
|
||||
- **`training-data-pvc` (500 GiB)** — the raw Databento MBP-10 `.dbn` market data. KEEP.
|
||||
- **`foxhunt-training-data` bucket (38 GiB, 54 `.dbn`/`.zst`)** — Databento market data (expensive to
|
||||
re-acquire; the fund's `.dbn` backtests read it). KEEP.
|
||||
- **`test-data-pvc` (50 GiB)** — `.dbn` test subsets (tier-1.5 smoke uses test_data). KEEP (verify, don't delete).
|
||||
- **`feature-cache-pvc` (100 GiB)** — derived ML features; regenerable but compute-costly. KEEP (conservative).
|
||||
- All fxhnt/platform data: `fxhnt-backtest-data`, `fxhnt-surfer-data`, `multistrat-state`, `questdb-pvc`,
|
||||
`tempo-data`, `netbird-data`, `foxhunt-gitlab-*` + `foxhunt-backups` buckets.
|
||||
General rule: delete only **pure compute/build artifacts** (caches, compiled binaries, run logs, GPU pools);
|
||||
**never** anything holding `.dbn`/market data or any potentially-reusable dataset.
|
||||
|
||||
## Out of scope (KEEP — shared platform / Python fund)
|
||||
`ci-compile-cpu` pool (Python cockpit builds), platform pool, GitLab, MinIO, monitoring, Mattermost,
|
||||
Stalwart, Kanidm, NetBird, DNS, databases, cert-manager, tailscale proxy, public-gateway, dagster/cockpit,
|
||||
the fxhnt forward-track jobs. Phase 2 (IaC relocation to `fxhnt/infra` + TF-state move) is a separate spec.
|
||||
|
||||
## Execution order (each destructive step verified + confirmed)
|
||||
1. **Terraform first**: edit terragrunt.hcl (3 `enable_*=false`), `terragrunt plan` → **STOP unless the
|
||||
plan shows ONLY the 3 pools destroyed + 0 other destroys**; then `apply`.
|
||||
2. **Helm uninstalls** (GPU runners; main runner only after the use-check confirms dead).
|
||||
3. **PVC deletes** (re-confirm `Used By: <none>` immediately before each delete — irreversible).
|
||||
4. **Bucket deletes** (list contents first; irreversible — explicit confirm; `mc rb --force` via the
|
||||
port-forward + minio creds).
|
||||
5. **Repo cleanup**: remove the dead manifests/templates + module var blocks, `kubectl delete wftmpl` the
|
||||
dead templates, commit.
|
||||
|
||||
## Risks / safety
|
||||
- **Irreversible**: PVC + bucket deletes, GPU-pool destroys. Mitigation: verify-unused immediately before
|
||||
each; Terraform plan reviewed for 0-unexpected-destroys; explicit confirm on PVC/bucket deletes.
|
||||
- **Mis-scope risk**: a kept resource accidentally listed. Mitigation: the Terraform plan gate (only the 3
|
||||
pools) + the `Used By` re-check + bucket content listing before delete.
|
||||
- **Main gitlab-runner**: don't uninstall without confirming no active consumer (could break a CI path).
|
||||
- Cluster health unaffected: nothing here touches the cockpit/dagster/fund/platform services.
|
||||
|
||||
## Acceptance criteria
|
||||
- `terragrunt plan` (kapsule) shows the 3 GPU/precompute pools gone, then clean (no changes).
|
||||
- GPU-runner helm releases uninstalled; main runner resolved (kept or uninstalled per check).
|
||||
- The 5 build-cache PVCs deleted (cargo×3 + sccache×2, ~175 GB reclaimed); 3 Rust buckets deleted
|
||||
(binaries, training-results, models). **`.dbn` market data untouched** (training-data-pvc + the
|
||||
foxhunt-training-data bucket + test-data-pvc still present, byte-for-byte).
|
||||
- Dead training/gpu manifests + Argo templates removed from repo + cluster; committed.
|
||||
- Cockpit (`dashboard.fxhnt.ai` 200), dagster, and the fund tracks still healthy post-cleanup.
|
||||
@@ -7,18 +7,25 @@ metadata:
|
||||
labels:
|
||||
app.kubernetes.io/part-of: foxhunt
|
||||
rules:
|
||||
# apps: roll + apply Deployments. watch is required by `kubectl rollout status`.
|
||||
- apiGroups: ["apps"]
|
||||
resources: [deployments]
|
||||
verbs: [get, list, patch]
|
||||
verbs: [get, list, watch, patch]
|
||||
- apiGroups: ["argoproj.io"]
|
||||
resources: [workflowtemplates, eventsources, sensors, eventbus]
|
||||
verbs: [get, list, create, update, patch]
|
||||
# core: services/configmaps, plus serviceaccounts (tailscale-dashboard, dagster) and the forward-track PVC
|
||||
# — added so the fxhnt-cockpit deploy can `kubectl apply` its full manifest set without a partial-apply failure.
|
||||
- apiGroups: [""]
|
||||
resources: [services, configmaps]
|
||||
resources: [services, configmaps, serviceaccounts, persistentvolumeclaims]
|
||||
verbs: [get, list, create, update, patch]
|
||||
- apiGroups: ["networking.k8s.io"]
|
||||
resources: [networkpolicies]
|
||||
verbs: [get, list, create, update, patch]
|
||||
# batch: the fxhnt-forward CronJob
|
||||
- apiGroups: ["batch"]
|
||||
resources: [cronjobs]
|
||||
verbs: [get, list, create, update, patch]
|
||||
- apiGroups: ["rbac.authorization.k8s.io"]
|
||||
resources: [roles, rolebindings]
|
||||
verbs: [get, list, create, update, patch, bind, escalate]
|
||||
|
||||
72
infra/k8s/cert-manager/fxhnt-acme.yaml
Normal file
72
infra/k8s/cert-manager/fxhnt-acme.yaml
Normal file
@@ -0,0 +1,72 @@
|
||||
# ACME (Let's Encrypt) wildcard cert for *.fxhnt.ai via cert-manager + Scaleway DNS-01.
|
||||
# Replaces the prior MANUAL, unrenewed LE cert that expired 2026-05-26 (the tailscale-proxy
|
||||
# fell back to the GitLab self-signed cert). DNS-01 is required: the services are tailnet-only
|
||||
# (*.fxhnt.ai -> 100.x Tailscale CGNAT) so HTTP-01 is unreachable, and wildcards need DNS-01.
|
||||
#
|
||||
# Solver creds: cert-manager/scaleway-dns-credentials (SCW_ACCESS_KEY/SCW_SECRET_KEY, copied from
|
||||
# foxhunt/scaleway-credentials). Webhook: scaleway-certmanager-webhook (helm, cert-manager ns).
|
||||
# The issued secret gitlab-tls-cert is mounted by infra/k8s/gitlab/tailscale-proxy.yaml.
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: letsencrypt-staging
|
||||
spec:
|
||||
acme:
|
||||
email: jeroen@grusewski.nl
|
||||
server: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||
privateKeySecretRef:
|
||||
name: letsencrypt-staging-account-key
|
||||
solvers:
|
||||
- dns01:
|
||||
webhook:
|
||||
groupName: acme.scaleway.com
|
||||
solverName: scaleway
|
||||
config:
|
||||
accessKeySecretRef:
|
||||
name: scaleway-dns-credentials
|
||||
key: SCW_ACCESS_KEY
|
||||
secretKeySecretRef:
|
||||
name: scaleway-dns-credentials
|
||||
key: SCW_SECRET_KEY
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: letsencrypt-prod
|
||||
spec:
|
||||
acme:
|
||||
email: jeroen@grusewski.nl
|
||||
server: https://acme-v02.api.letsencrypt.org/directory
|
||||
privateKeySecretRef:
|
||||
name: letsencrypt-prod-account-key
|
||||
solvers:
|
||||
- dns01:
|
||||
webhook:
|
||||
groupName: acme.scaleway.com
|
||||
solverName: scaleway
|
||||
config:
|
||||
accessKeySecretRef:
|
||||
name: scaleway-dns-credentials
|
||||
key: SCW_ACCESS_KEY
|
||||
secretKeySecretRef:
|
||||
name: scaleway-dns-credentials
|
||||
key: SCW_SECRET_KEY
|
||||
---
|
||||
# Wildcard cert for the tailscale-proxy. issuerRef flips staging->prod after staging validates;
|
||||
# secretName flips to gitlab-tls-cert (the secret the proxy mounts) for the prod issuance.
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: fxhnt-wildcard
|
||||
namespace: foxhunt
|
||||
spec:
|
||||
secretName: gitlab-tls-cert # takes over the secret the tailscale-proxy mounts
|
||||
privateKey:
|
||||
rotationPolicy: Always # old manual key had a mismatching algorithm; regenerate on issue/renew
|
||||
dnsNames:
|
||||
- fxhnt.ai
|
||||
- "*.fxhnt.ai"
|
||||
issuerRef:
|
||||
name: letsencrypt-prod
|
||||
kind: ClusterIssuer
|
||||
@@ -226,7 +226,11 @@ data:
|
||||
}
|
||||
}
|
||||
|
||||
# Web Dashboard — dashboard.fxhnt.ai
|
||||
# fxhnt cockpit — dashboard.fxhnt.ai
|
||||
# Proxies to the cockpit's Tailscale node (peer-to-peer over the tailnet) rather than the cluster Service:
|
||||
# the pod CIDR 100.64.0.0/15 overlaps Tailscale CGNAT, so this kernel-mode proxy can't reach platform-pool
|
||||
# pods via ClusterIP, but it CAN reach another tailnet node. IP is stable while the cockpit's TS state
|
||||
# secret (fxhnt-dashboard-ts-state) persists. (Was web-dashboard.foxhunt.svc — replaced by the fxhnt cockpit.)
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name dashboard.fxhnt.ai;
|
||||
@@ -236,7 +240,7 @@ data:
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
location / {
|
||||
proxy_pass http://web-dashboard.foxhunt.svc.cluster.local:80;
|
||||
proxy_pass http://100.81.150.18:80; # fxhnt-dashboard tailnet node (TCP:80 -> cockpit :8080)
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
@@ -57,6 +57,16 @@ global:
|
||||
connection:
|
||||
secret: gitlab-s3-credentials
|
||||
key: connection
|
||||
# Terraform/OpenTofu state object storage. MUST be present — without it the
|
||||
# Terraform::StateUploader has no object store and every TF-state API call 403s
|
||||
# ("Object Storage is not enabled for Terraform::StateUploader"). State files already
|
||||
# live in foxhunt-gitlab-artifacts (6b/86/<sha256(project_id)>/<state>/<ver>.tfstate).
|
||||
terraformState:
|
||||
enabled: true
|
||||
bucket: foxhunt-gitlab-artifacts
|
||||
connection:
|
||||
secret: gitlab-s3-credentials
|
||||
key: connection
|
||||
gitlab_kas:
|
||||
enabled: false
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ spec:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: broker-gateway
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app: multistrat-rebalancer
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 4002
|
||||
|
||||
123
infra/k8s/services/multistrat-rebalancer.yaml
Normal file
123
infra/k8s/services/multistrat-rebalancer.yaml
Normal file
@@ -0,0 +1,123 @@
|
||||
# Multi-strat rebalancer — weekly CronJob running the adaptive book on IBKR (paper) via the
|
||||
# in-cluster ib-gateway. Production deployment of scripts/surfer/multistrat_bot.py.
|
||||
#
|
||||
# Code is delivered via ConfigMap (no image build needed — lean). Create/refresh it with:
|
||||
# kubectl create configmap multistrat-bot-code -n foxhunt \
|
||||
# --from-file=scripts/surfer/multistrat_bot.py \
|
||||
# --from-file=scripts/surfer/multistrat_paper.py \
|
||||
# --dry-run=client -o yaml | kubectl apply -f -
|
||||
#
|
||||
# SAFETY: MULTISTRAT_EXECUTE defaults to "false" (cluster dry-run). After validating a few dry-run
|
||||
# CronJob logs, flip to "true" to place paper orders. Account is paper (ib-gateway TRADING_MODE=paper);
|
||||
# the bot additionally refuses any non-DU (live) account unless MULTISTRAT_ALLOW_LIVE_CONFIRMED is set.
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: multistrat-state
|
||||
namespace: foxhunt
|
||||
labels:
|
||||
app: multistrat-rebalancer
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"] # sequential CronJob runs (Forbid concurrency) — RWO is fine
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: multistrat-rebalancer
|
||||
namespace: foxhunt
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: multistrat-rebalancer
|
||||
policyTypes: [Egress]
|
||||
egress:
|
||||
- to: [] # DNS
|
||||
ports:
|
||||
- {protocol: UDP, port: 53}
|
||||
- {protocol: TCP, port: 53}
|
||||
- to: # in-cluster ib-gateway (4002 API + 4004 socat bridge for pod-to-pod)
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app: ib-gateway
|
||||
ports:
|
||||
- {protocol: TCP, port: 4002}
|
||||
- {protocol: TCP, port: 4004}
|
||||
- to: # internet 443: PyPI (pip) + Yahoo (prices), no internal ranges
|
||||
- ipBlock:
|
||||
cidr: 0.0.0.0/0
|
||||
except: ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
|
||||
ports:
|
||||
- {protocol: TCP, port: 443}
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: multistrat-rebalancer
|
||||
namespace: foxhunt
|
||||
labels:
|
||||
app: multistrat-rebalancer
|
||||
app.kubernetes.io/part-of: foxhunt
|
||||
spec:
|
||||
schedule: "35 14 * * 1-5" # weekdays 14:35 UTC (~1h after US open) — daily risk re-eval; hysteresis prevents churn
|
||||
concurrencyPolicy: Forbid
|
||||
startingDeadlineSeconds: 3600
|
||||
successfulJobsHistoryLimit: 5
|
||||
failedJobsHistoryLimit: 10
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 1
|
||||
activeDeadlineSeconds: 600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: multistrat-rebalancer
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
nodeSelector:
|
||||
k8s.scaleway.com/pool-name: platform
|
||||
securityContext:
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: rebalancer
|
||||
image: python:3.12-slim
|
||||
command: ["/bin/sh", "-c"]
|
||||
args:
|
||||
- >-
|
||||
pip install --quiet --no-cache-dir ib_async==2.1.0 numpy &&
|
||||
python /app/multistrat_bot.py run
|
||||
env:
|
||||
- {name: IB_HOST, value: "ib-gateway"}
|
||||
- {name: IB_PORT, value: "4004"} # socat bridge: relays via localhost so IB Gateway trusts it (4002 = pod-IP, untrusted -> handshake timeout)
|
||||
- {name: IB_CLIENT_ID, value: "11"}
|
||||
- {name: MULTISTRAT_MAXLEV, value: "1.0"}
|
||||
- {name: MULTISTRAT_HYST, value: "0.03"}
|
||||
- {name: MULTISTRAT_REBALANCE_DAYS, value: "1"} # daily eval (weekday cron); hysteresis (3%) gates actual trades
|
||||
- {name: MULTISTRAT_DD_HALT, value: "0.20"}
|
||||
- {name: MULTISTRAT_MAX_ORDER, value: "0.30"}
|
||||
- {name: MULTISTRAT_EXECUTE, value: "true"} # ARMED: places paper orders (account DU* paper-guarded; live needs MULTISTRAT_ALLOW_LIVE_CONFIRMED)
|
||||
- {name: MULTISTRAT_STATE, value: "/data/state.json"}
|
||||
- {name: MULTISTRAT_LOG, value: "/data/bot.log"}
|
||||
- {name: PIP_DISABLE_PIP_VERSION_CHECK, value: "1"}
|
||||
- {name: PYTHONUNBUFFERED, value: "1"}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
resources:
|
||||
requests: {memory: "256Mi", cpu: "100m"}
|
||||
limits: {memory: "512Mi", cpu: "500m"}
|
||||
volumeMounts:
|
||||
- {mountPath: /app, name: code, readOnly: true}
|
||||
- {mountPath: /data, name: state}
|
||||
volumes:
|
||||
- name: code
|
||||
configMap:
|
||||
name: multistrat-bot-code
|
||||
- name: state
|
||||
persistentVolumeClaim:
|
||||
claimName: multistrat-state
|
||||
@@ -14,9 +14,12 @@ inputs = {
|
||||
platform_type = "DEV1-L"
|
||||
platform_max_size = 3
|
||||
|
||||
# CPU compile pool (POP2-HC-32C-64G — 32 vCPU, 64GB RAM, high clock)
|
||||
# CI build pool — right-sized for the Python fxhnt cockpit/deploy builds (kaniko + pip, ~2-6 vCPU).
|
||||
# Was POP2-HC-32C-64G (32 vCPU/64GB) for the now-dormant Rust cargo compile + precompute_features;
|
||||
# downsized 2026-06-21 to POP2-4C-16G (in stock in fr-par-2; the 32C type hit a zone stock-out that
|
||||
# jammed the autoscaler — "Resource POP2-HC-32C-64G is out of stock"). Far cheaper, scale-to-zero.
|
||||
enable_ci_compile_cpu_pool = true
|
||||
ci_compile_cpu_type = "POP2-HC-32C-64G"
|
||||
ci_compile_cpu_type = "POP2-4C-16G"
|
||||
ci_compile_cpu_max_size = 4
|
||||
|
||||
# High-memory CPU pool (POP2-HM-32C-256G — 32 vCPU, 256GB RAM).
|
||||
|
||||
@@ -13,4 +13,5 @@ dependency "kapsule" {
|
||||
inputs = {
|
||||
private_network_id = dependency.kapsule.outputs.private_network_id
|
||||
gateway_type = "VPC-GW-S"
|
||||
bastion_enabled = true # matches the live gateway (carried over from the old kapsule gw config)
|
||||
}
|
||||
|
||||
@@ -3,31 +3,11 @@ resource "scaleway_vpc_private_network" "foxhunt" {
|
||||
region = var.region
|
||||
}
|
||||
|
||||
# VPC Public Gateway — provides NAT (masquerade) for private nodes
|
||||
# and DHCP with default route propagation to prevent DNS deadlock.
|
||||
# See incident_dns_deadlock.md for why this is critical.
|
||||
resource "scaleway_vpc_public_gateway" "foxhunt" {
|
||||
name = "${var.cluster_name}-gw"
|
||||
type = "VPC-GW-S"
|
||||
zone = "${var.region}-2"
|
||||
bastion_enabled = true
|
||||
bastion_port = 61000
|
||||
}
|
||||
|
||||
resource "scaleway_vpc_public_gateway_dhcp" "foxhunt" {
|
||||
subnet = "172.16.0.0/22"
|
||||
push_default_route = true
|
||||
push_dns_server = true
|
||||
zone = "${var.region}-2"
|
||||
}
|
||||
|
||||
resource "scaleway_vpc_gateway_network" "foxhunt" {
|
||||
gateway_id = scaleway_vpc_public_gateway.foxhunt.id
|
||||
private_network_id = scaleway_vpc_private_network.foxhunt.id
|
||||
dhcp_id = scaleway_vpc_public_gateway_dhcp.foxhunt.id
|
||||
enable_masquerade = true
|
||||
zone = "${var.region}-2"
|
||||
}
|
||||
# NAT gateway (foxhunt-gw) is owned by the dedicated `public-gateway` module (IPAM mode +
|
||||
# dedicated IP), which depends on this module's private_network_id output. The legacy
|
||||
# gateway/dhcp/gateway_network resources that used to live here were a pre-refactor duplicate
|
||||
# (never in this module's state) and were removed 2026-06-21 to stop `terragrunt apply` here
|
||||
# from trying to create a second gateway. See reference_ci_pool_and_tfstate / incident_dns_deadlock.
|
||||
|
||||
resource "scaleway_k8s_cluster" "foxhunt" {
|
||||
name = var.cluster_name
|
||||
|
||||
41
scripts/install_torch_gpu.sh
Normal file
41
scripts/install_torch_gpu.sh
Normal file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install GPU PyTorch for the surfer experiments (local RTX 3050 Ti, driver 580 → CUDA 12.x).
|
||||
#
|
||||
# RECOMMENDED (no sudo — installs to ~/.local, matching your existing numpy/scipy/databento):
|
||||
# bash scripts/install_torch_gpu.sh
|
||||
#
|
||||
# System-wide (only if you really want it):
|
||||
# sudo bash scripts/install_torch_gpu.sh
|
||||
#
|
||||
# Pick a different CUDA wheel tag if cu124 ever 404s (cu126 / cu128 also work on driver 580):
|
||||
# bash scripts/install_torch_gpu.sh cu126
|
||||
set -euo pipefail
|
||||
|
||||
CUDA_TAG="${1:-cu124}"
|
||||
TORCH_INDEX="https://download.pytorch.org/whl/${CUDA_TAG}"
|
||||
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
echo "[install] running as ROOT → system-wide site-packages (--break-system-packages)"
|
||||
FLAGS="--break-system-packages"
|
||||
else
|
||||
echo "[install] running as USER → ~/.local (matches existing packages; no sudo needed)"
|
||||
FLAGS="--user --break-system-packages"
|
||||
fi
|
||||
|
||||
echo "[install] torch from ${TORCH_INDEX}"
|
||||
python3 -m pip install ${FLAGS} torch --index-url "${TORCH_INDEX}"
|
||||
|
||||
echo "[verify] importing torch + checking CUDA on the local GPU"
|
||||
python3 - <<'PY'
|
||||
import torch
|
||||
print(" torch:", torch.__version__)
|
||||
print(" cuda available:", torch.cuda.is_available())
|
||||
if torch.cuda.is_available():
|
||||
print(" device:", torch.cuda.get_device_name(0),
|
||||
"| capability:", torch.cuda.get_device_capability(0))
|
||||
x = torch.randn(1_000_000, device="cuda")
|
||||
print(" GPU tensor op OK, sum =", float(x.sum()))
|
||||
else:
|
||||
raise SystemExit("CUDA NOT available to torch — check driver / try a different CUDA_TAG (cu126/cu128)")
|
||||
print(" ✅ GPU PyTorch ready")
|
||||
PY
|
||||
161
scripts/surfer/cross_exchange_funding.py
Normal file
161
scripts/surfer/cross_exchange_funding.py
Normal file
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Frontier (a) — cross-exchange funding dispersion: edge distinct from momentum?
|
||||
|
||||
Fetch funding from Bybit + OKX (free) to pair with Binance funding + prices (crypto_pit).
|
||||
Build cross-sectional signals among multi-venue coins: multi-venue-avg funding (robust carry),
|
||||
cross-venue dispersion (positioning stress), Binance-premium. Validate each + correlation to
|
||||
the full crypto momentum book + marginal-alpha regression. Realistic: Reff = return - Binance
|
||||
funding (traded venue), death-excl, 10bp. Honest prior: low (cross-venue spreads arbitraged).
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import pit_sweep # noqa: E402
|
||||
from surfer_poc import compute_weights, CFG # noqa: E402
|
||||
from signal_sweep import xs_weights, pnl_w, validate, sharpe_t # noqa: E402
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
DAY_MS = 86_400_000
|
||||
XF = "data/surfer/xfund"
|
||||
COINS = ["BTC", "ETH", "SOL", "XRP", "DOGE", "ADA", "AVAX", "LINK", "LTC", "DOT",
|
||||
"NEAR", "ATOM", "FIL", "ETC", "XLM", "UNI", "AAVE", "BNB"]
|
||||
|
||||
|
||||
def _get(u):
|
||||
try:
|
||||
return json.load(urllib.request.urlopen(urllib.request.Request(u, headers={"User-Agent": "curl/8"}), timeout=30))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def bybit(coin):
|
||||
cache = f"{XF}/bybit_{coin}.json"
|
||||
if os.path.exists(cache):
|
||||
return {int(k): v for k, v in json.load(open(cache)).items()}
|
||||
out, end = {}, int(time.time() * 1000)
|
||||
for _ in range(40):
|
||||
r = _get(f"https://api.bybit.com/v5/market/funding/history?category=linear&symbol={coin}USDT&endTime={end}&limit=200")
|
||||
lst = (r or {}).get("result", {}).get("list", [])
|
||||
if not lst:
|
||||
break
|
||||
for x in lst:
|
||||
t = int(x["fundingRateTimestamp"]); out.setdefault(t // DAY_MS, 0.0)
|
||||
out[t // DAY_MS] += float(x["fundingRate"])
|
||||
end = min(int(x["fundingRateTimestamp"]) for x in lst) - 1
|
||||
if len(lst) < 200:
|
||||
break
|
||||
time.sleep(0.06)
|
||||
os.makedirs(XF, exist_ok=True); json.dump({str(k): v for k, v in out.items()}, open(cache, "w"))
|
||||
return out
|
||||
|
||||
|
||||
def okx(coin):
|
||||
cache = f"{XF}/okx_{coin}.json"
|
||||
if os.path.exists(cache):
|
||||
return {int(k): v for k, v in json.load(open(cache)).items()}
|
||||
out, before = {}, ""
|
||||
for _ in range(60):
|
||||
u = f"https://www.okx.com/api/v5/public/funding-rate-history?instId={coin}-USDT-SWAP&limit=100"
|
||||
if before:
|
||||
u += f"&after={before}"
|
||||
r = _get(u); data = (r or {}).get("data", [])
|
||||
if not data:
|
||||
break
|
||||
for x in data:
|
||||
t = int(x["fundingTime"]); out.setdefault(t // DAY_MS, 0.0)
|
||||
out[t // DAY_MS] += float(x["fundingRate"])
|
||||
before = min(int(x["fundingTime"]) for x in data)
|
||||
if len(data) < 100:
|
||||
break
|
||||
time.sleep(0.06)
|
||||
os.makedirs(XF, exist_ok=True); json.dump({str(k): v for k, v in out.items()}, open(cache, "w"))
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
syms, days, close, qv, fund = pit_sweep.load()
|
||||
idx = {s: j for j, s in enumerate(syms)}
|
||||
coins = [c for c in COINS if c + "USDT" in idx]
|
||||
print(f"fetching Bybit+OKX funding for {len(coins)} coins...")
|
||||
by = {c: bybit(c) for c in coins}
|
||||
ok = {c: okx(c) for c in coins}
|
||||
nby = sum(1 for c in coins if len(by[c]) > 100); nok = sum(1 for c in coins if len(ok[c]) > 100)
|
||||
print(f" Bybit covered {nby}/{len(coins)}, OKX covered {nok}/{len(coins)}")
|
||||
|
||||
N = len(coins); T = len(days)
|
||||
di = {int(days[t]): t for t in range(T)}
|
||||
bn_f = np.full((T, N), np.nan); by_f = np.full((T, N), np.nan); ok_f = np.full((T, N), np.nan)
|
||||
cl = np.full((T, N), np.nan)
|
||||
for j, c in enumerate(coins):
|
||||
col = idx[c + "USDT"]
|
||||
cl[:, j] = close[:, col]; bn_f[:, j] = fund[:, col]
|
||||
for d, v in by[c].items():
|
||||
if d in di:
|
||||
by_f[di[d], j] = v
|
||||
for d, v in ok[c].items():
|
||||
if d in di:
|
||||
ok_f[di[d], j] = v
|
||||
|
||||
R = np.zeros((T, N)); R[1:] = np.log(cl)[1:] - np.log(cl)[:-1]; R = np.where(np.isfinite(R), R, 0.0)
|
||||
bnf = np.where(np.isfinite(bn_f), bn_f, 0.0)
|
||||
Reff = R - bnf # trade on Binance -> pay Binance funding
|
||||
# cross-venue stack
|
||||
stack = np.stack([bn_f, by_f, ok_f]) # [3,T,N]
|
||||
avg_f = np.nanmean(stack, axis=0) # multi-venue avg funding
|
||||
disp_f = np.nanstd(stack, axis=0) # cross-venue dispersion
|
||||
prem = bn_f - np.nanmean(np.stack([by_f, ok_f]), axis=0) # Binance premium vs others
|
||||
|
||||
def roll_mean(A, L):
|
||||
out = np.full_like(A, np.nan)
|
||||
for t in range(L, len(A)):
|
||||
out[t] = np.nanmean(A[t - L:t], axis=0)
|
||||
return out
|
||||
|
||||
sigs = {
|
||||
"carry_BINANCE_only": -roll_mean(np.where(np.isfinite(bn_f), bn_f, np.nan), 7), # control: is it just major-coin carry?
|
||||
"carry_multivenue": -roll_mean(avg_f, 7), # long low avg funding (robust carry)
|
||||
"venue_dispersion": -roll_mean(disp_f, 7), # low disagreement? test
|
||||
"venue_dispersion+": roll_mean(disp_f, 7), # high disagreement
|
||||
"binance_premium": -roll_mean(prem, 7), # fade Binance-crowded
|
||||
}
|
||||
valid = np.isfinite(cl) & np.isfinite(avg_f)
|
||||
year = (1970 + days / 365.25).astype(int)
|
||||
|
||||
def book(sig):
|
||||
s = sig.copy(); s[~valid] = np.nan
|
||||
return pnl_w(xs_weights(s), Reff, cost_bp=10)
|
||||
|
||||
# full-universe momentum book for correlation
|
||||
cw, _ = compute_weights(close, qv, days, CFG)
|
||||
cR = np.zeros_like(close); cR[1:] = np.log(close)[1:] - np.log(close)[:-1]; cR = np.where(np.isfinite(cR), cR, 0.0)
|
||||
cf = np.where(np.isfinite(fund), fund, 0.0)
|
||||
mom = np.sum(cw[:-1] * (cR - cf)[1:], axis=1)
|
||||
mom_by = {int(days[1:][i]): mom[i] for i in range(len(mom))}
|
||||
|
||||
print(f"\n===== CROSS-EXCHANGE FUNDING — {len(coins)} multi-venue coins, death-excl, 10bp, deflate N=55 =====")
|
||||
print(f"{'signal':>18} {'full':>6} {'IS':>6} {'OOS':>6} {'CPCVmed':>8} {'DSR':>5} {'corr_mom':>8} {'marg_t':>7}")
|
||||
pdays = days[1:]
|
||||
for nm, sg in sigs.items():
|
||||
p = book(sg); v = validate(p, days, 55)
|
||||
pb = {int(pdays[i]): p[i] for i in range(len(p))}
|
||||
common = sorted(set(pb) & set(mom_by))
|
||||
a = np.array([mom_by[d] for d in common]); b = np.array([pb[d] for d in common])
|
||||
m = np.isfinite(a) & np.isfinite(b); a, b = a[m], b[m]
|
||||
corr = float(np.corrcoef(a, b)[0, 1]) if len(a) > 100 else float("nan")
|
||||
beta1 = np.cov(a, b)[0, 1] / (np.var(a) + 1e-12); res = b - beta1 * a
|
||||
mt = float(res.mean() / (res.std() / math.sqrt(len(res)) + 1e-12))
|
||||
print(f"{nm:>18} {v['full']:>+6.2f} {v['is_']:>+6.2f} {v['oos']:>+6.2f} {v['med']:>+8.2f} {v['dsr']:>5.2f} {corr:>+8.2f} {mt:>+7.2f}")
|
||||
print("\nVERDICT: any signal with full+OOS+CPCVmed>0, DSR>0.5, low |corr_mom|, AND marg_t>2 = real distinct edge.")
|
||||
print("(marg_t = t-stat of marginal alpha vs momentum book). Honest prior: cross-venue spreads arbitraged -> expect fail.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
160
scripts/surfer/cross_venue_funding.py
Normal file
160
scripts/surfer/cross_venue_funding.py
Normal file
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cross-venue crypto funding-arb scanner + paper-forward (price-neutral, no spot leg needed).
|
||||
|
||||
Funding differs across venues. For a coin on >=2 liquid venues: SHORT the highest-funding venue
|
||||
perp + LONG the lowest-funding venue perp (same coin) -> price-neutral (both perps, opposite),
|
||||
collect the funding DIFFERENCE (max-min). No spot leg -> solves the single-venue hedgeability block.
|
||||
|
||||
Funding intervals differ (Binance/Bybit 8h, Hyperliquid 1h) -> normalize to DAILY before comparing.
|
||||
No agents, no key, no capital. Venues: Binance, Bybit, Hyperliquid (all bulk-fetch).
|
||||
|
||||
python3 cross_venue_funding.py scan live top cross-venue spreads
|
||||
python3 cross_venue_funding.py run book daily carry on the top-K book, persist (cron)
|
||||
python3 cross_venue_funding.py status cumulative paper track record
|
||||
(alias: paper == run)
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
STATE = os.path.join(_REPO, "data/surfer/crossvenue_state.json")
|
||||
LIQ = 10e6 # >$10M/day on BOTH legs (clean, fungible)
|
||||
MAXF = 0.005 # exclude legs with |daily funding| > 50bp/day = distress/artifact (un-tradeable)
|
||||
TOPK = 10 # book the top-K spreads
|
||||
COST_RT = 0.0010 # ~10bp round-trip (2 perp legs, maker)
|
||||
HURDLE = 0.0005 # 5bp/day spread to show in scan
|
||||
ENTRY = 0.0010 # hysteresis: only ENTER a new pair above 10bp/day
|
||||
EXIT = 0.0005 # hysteresis: HOLD a pair until its spread decays below 5bp/day (cuts turnover)
|
||||
|
||||
|
||||
def get(url, post=None):
|
||||
data = json.dumps(post).encode() if post else None
|
||||
h = {"User-Agent": "Mozilla/5.0", "Accept": "application/json"}
|
||||
if post:
|
||||
h["Content-Type"] = "application/json"
|
||||
for a in range(3):
|
||||
try:
|
||||
return json.loads(urllib.request.urlopen(urllib.request.Request(url, data=data, headers=h), timeout=25).read())
|
||||
except Exception:
|
||||
if a == 2:
|
||||
raise
|
||||
return None
|
||||
|
||||
|
||||
def base(sym):
|
||||
for q in ("USDT", "USDC"):
|
||||
if sym.endswith(q):
|
||||
return sym[:-len(q)]
|
||||
return sym
|
||||
|
||||
|
||||
def binance(): # {coin: (daily_funding, daily_vol)}
|
||||
fund = {x["symbol"]: float(x["lastFundingRate"]) for x in get("https://fapi.binance.com/fapi/v1/premiumIndex")}
|
||||
try:
|
||||
itv = {x["symbol"]: float(x.get("fundingIntervalHours", 8)) for x in get("https://fapi.binance.com/fapi/v1/fundingInfo")}
|
||||
except Exception:
|
||||
itv = {}
|
||||
vol = {x["symbol"]: float(x["quoteVolume"]) for x in get("https://fapi.binance.com/fapi/v1/ticker/24hr")}
|
||||
return {base(s): (f * (24.0 / itv.get(s, 8)), vol.get(s, 0.0)) for s, f in fund.items() if s.endswith("USDT")}
|
||||
|
||||
|
||||
def bybit():
|
||||
r = get("https://api.bybit.com/v5/market/tickers?category=linear")["result"]["list"]
|
||||
return {base(x["symbol"]): (float(x["fundingRate"]) * 3, float(x.get("turnover24h", 0.0)))
|
||||
for x in r if x["symbol"].endswith("USDT") and x.get("fundingRate")}
|
||||
|
||||
|
||||
def hyperliquid():
|
||||
r = get("https://api.hyperliquid.xyz/info", post={"type": "metaAndAssetCtxs"})
|
||||
meta, ctxs = r[0], r[1]
|
||||
out = {}
|
||||
for u, c in zip(meta["universe"], ctxs):
|
||||
if c.get("funding") is not None:
|
||||
out[u["name"]] = (float(c["funding"]) * 24, float(c.get("dayNtlVlm", 0.0)))
|
||||
return out
|
||||
|
||||
|
||||
def spreads():
|
||||
V = {}
|
||||
for nm, fn in [("Binance", binance), ("Bybit", bybit), ("HL", hyperliquid)]:
|
||||
try:
|
||||
V[nm] = fn()
|
||||
except Exception as e:
|
||||
print(f" ({nm} fetch failed: {str(e)[:40]})")
|
||||
coins = set().union(*[set(v) for v in V.values()])
|
||||
rows = []
|
||||
for c in coins:
|
||||
pts = {nm: V[nm][c] for nm in V if c in V[nm] and V[nm][c][1] > LIQ and abs(V[nm][c][0]) <= MAXF}
|
||||
if len(pts) < 2:
|
||||
continue
|
||||
f = {nm: pts[nm][0] for nm in pts}
|
||||
hi = max(f, key=f.get); lo = min(f, key=f.get)
|
||||
rows.append({"coin": c, "spread": f[hi] - f[lo], "short": hi, "long": lo, "f": f})
|
||||
rows.sort(key=lambda r: -r["spread"])
|
||||
return rows, list(V)
|
||||
|
||||
|
||||
def load_state():
|
||||
if os.path.exists(STATE):
|
||||
return json.load(open(STATE))
|
||||
return {"positions": {}, "cum_gross": 0.0, "cum_net": 0.0, "days": 0, "last_run_date": ""}
|
||||
|
||||
|
||||
def cmd_scan():
|
||||
rows, venues = spreads()
|
||||
print(f"cross-venue funding scan {datetime.date.today()} (venues: {', '.join(venues)}; liquid both legs >${LIQ/1e6:.0f}M)")
|
||||
n_ok = sum(1 for r in rows if r["spread"] > HURDLE)
|
||||
print(f" {len(rows)} coins on >=2 venues | {n_ok} with spread > {HURDLE*1e4:.0f}bp/day")
|
||||
print(f" {'coin':>8} {'spread/day':>11} {'ann%':>7} short -> long (daily funding)")
|
||||
for r in rows[:15]:
|
||||
leg = " ".join(f"{k}{1e4*v:+.1f}" for k, v in sorted(r["f"].items(), key=lambda kv: -kv[1]))
|
||||
print(f" {r['coin']:>8} {1e4*r['spread']:>9.1f}bp {100*r['spread']*365:>6.0f}% short {r['short']}->long {r['long']} [{leg}]")
|
||||
|
||||
|
||||
def cmd_run():
|
||||
st = load_state()
|
||||
today = datetime.datetime.now(datetime.timezone.utc).date().isoformat()
|
||||
if st.get("last_run_date") == today:
|
||||
print(f"already booked {today} (day {st['days']}); skipping"); return
|
||||
rows, _ = spreads()
|
||||
cur = {r["coin"]: r["spread"] for r in rows}
|
||||
prev = st["positions"]
|
||||
realized = sum(w * cur.get(c, 0.0) for c, w in prev.items()) # carry on yesterday's book at today's spreads
|
||||
# HYSTERESIS (the deployable, low-turnover version): hold winners until they decay, only enter strong fresh
|
||||
held = [c for c in prev if cur.get(c, 0.0) > EXIT]
|
||||
for r in sorted([r for r in rows if r["spread"] > ENTRY], key=lambda r: -r["spread"]):
|
||||
if len(held) >= TOPK:
|
||||
break
|
||||
if r["coin"] not in held:
|
||||
held.append(r["coin"])
|
||||
qual = [r for r in rows if r["coin"] in held]
|
||||
newpos = {c: 1.0 / len(held) for c in held} if held else {}
|
||||
turn = sum(abs(newpos.get(c, 0) - prev.get(c, 0)) for c in set(newpos) | set(prev))
|
||||
net = realized - turn * (COST_RT / 2)
|
||||
st.update(positions=newpos, days=st["days"] + 1, last_run_date=today)
|
||||
st["cum_gross"] += realized; st["cum_net"] += net
|
||||
os.makedirs(os.path.dirname(STATE), exist_ok=True); json.dump(st, open(STATE, "w"))
|
||||
top = " ".join(f"{r['coin']}:{1e4*r['spread']:.0f}bp({r['short'][:2]}>{r['long'][:2]})" for r in qual[:5])
|
||||
print(f"{today} day={st['days']} | qualifying={len(qual)} | realized24h gross={100*realized:+.3f}% net={100*net:+.3f}% "
|
||||
f"| cum net={100*st['cum_net']:+.2f}% | top: {top}")
|
||||
|
||||
|
||||
def cmd_status():
|
||||
st = load_state()
|
||||
print(f"cross-venue funding paper — day {st['days']} (through {st.get('last_run_date') or 'n/a'}), "
|
||||
f"{len(st['positions'])} pairs, cum gross {100*st['cum_gross']:+.2f}% net {100*st['cum_net']:+.2f}%")
|
||||
for c, w in sorted(st["positions"].items(), key=lambda kv: -kv[1]):
|
||||
print(f" {c:>8} w={w:.3f}")
|
||||
|
||||
|
||||
def main():
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "scan"
|
||||
{"scan": cmd_scan, "run": cmd_run, "paper": cmd_run, "status": cmd_status}.get(cmd, cmd_scan)()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
116
scripts/surfer/crypto_cascade_reversion.py
Normal file
116
scripts/surfer/crypto_cascade_reversion.py
Normal file
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cascade reversion — do crypto coins BOUNCE after an extreme forced-flow down-move?
|
||||
|
||||
Liquidation cascades are forced deleveraging that overshoots; the harvestable consequence is the
|
||||
mean-reversion bounce. Binance killed the historical liquidation feed (live websocket only), so we
|
||||
test the PROXY already on disk: extreme negative trailing-H return = a cascade. Pre-registered:
|
||||
|
||||
formation: trailing-H log return per coin (cross-sectional)
|
||||
signal: LONG the bottom-q fraction (biggest losers) — long-only on crashed names
|
||||
hold: next H hours, NON-OVERLAPPING rebalance
|
||||
cost: 5 bp / leg on actual weight turnover
|
||||
two builds: raw_long (equal-weight losers, has crypto beta)
|
||||
hedged (long losers − short equal-weight UNIVERSE basket; beta-neutral, short leg
|
||||
is the diversified basket NOT a single coin → dodges the short-melt-up ruin
|
||||
that killed generic XS reversal)
|
||||
KILL: net annualized SR < 0.5 OR not positive in most years (chrono) → cascade reversion closed.
|
||||
|
||||
LIMITATION: npz panel has close only (no volume) → cannot condition on the volume spike that
|
||||
confirms a true liquidation cascade; the extreme-return tail is the proxy. Flagged, not hidden.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
|
||||
OUT = "data/surfer/crypto1h"
|
||||
COST_BP = 5.0
|
||||
SYMS = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "XRPUSDT", "BNBUSDT", "DOGEUSDT", "ADAUSDT", "AVAXUSDT",
|
||||
"LINKUSDT", "LTCUSDT", "DOTUSDT", "TRXUSDT", "BCHUSDT", "ETCUSDT", "FILUSDT", "ATOMUSDT"]
|
||||
HOUR_MS = 3_600_000
|
||||
HOLDS = [1, 2, 4, 8, 12, 24] # formation == hold, hours
|
||||
Q = 0.20 # bottom quintile = biggest losers
|
||||
|
||||
|
||||
def build_panel():
|
||||
series = {}
|
||||
for s in SYMS:
|
||||
cache = f"{OUT}/{s}.npz"
|
||||
if not os.path.exists(cache):
|
||||
continue
|
||||
d = np.load(cache)
|
||||
if len(d["ts"]) > 24 * 60:
|
||||
series[s] = (d["ts"], d["close"])
|
||||
allts = sorted(set().union(*[set((ts // HOUR_MS).tolist()) for ts, _ in series.values()]))
|
||||
tindex = {t: i for i, t in enumerate(allts)}
|
||||
syms = sorted(series)
|
||||
P = np.full((len(allts), len(syms)), np.nan)
|
||||
for j, s in enumerate(syms):
|
||||
ts, c = series[s]
|
||||
for t, px in zip(ts // HOUR_MS, c):
|
||||
P[tindex[int(t)], j] = px
|
||||
yrs = np.array([1970 + int(t) // int(365.25 * 24) for t in allts])
|
||||
return P, syms, yrs
|
||||
|
||||
|
||||
def ann(rets, per_year_factor):
|
||||
rets = np.asarray(rets)
|
||||
if len(rets) < 20 or rets.std() == 0:
|
||||
return float("nan"), float("nan")
|
||||
sr = rets.mean() / rets.std()
|
||||
t = rets.mean() / (rets.std() / math.sqrt(len(rets)))
|
||||
return sr * per_year_factor, t
|
||||
|
||||
|
||||
def run():
|
||||
P, syms, yrs = build_panel()
|
||||
T, N = P.shape
|
||||
logP = np.log(P)
|
||||
print(f"\n===== CRYPTO CASCADE REVERSION (long the crashed names, net {COST_BP}bp/leg) =====")
|
||||
print(f"panel: {T} hours x {N} coins years {int(yrs.min())}-{int(yrs.max())} q={Q} (bottom quintile)")
|
||||
print("LONG bottom-q losers; hedged = long losers - short equal-wt universe basket\n")
|
||||
hdr = f"{'hold':>5} {'periods':>8} {'build':>8} {'net_SR':>8} {'t(net)':>7} {'hit%':>6} {'per-year net-SR (chrono)':>30}"
|
||||
print(hdr); print("-" * len(hdr))
|
||||
|
||||
for H in HOLDS:
|
||||
pf = math.sqrt(24 * 365 / H)
|
||||
idx = np.arange(H, T - H, H) # need t-H for formation, t+H for fwd
|
||||
raw, hed, yr_raw, yr_hed = [], [], [], []
|
||||
w_raw_prev = np.zeros(N); w_hed_prev = np.zeros(N)
|
||||
for t in idx:
|
||||
past = logP[t] - logP[t - H]
|
||||
fwd = logP[t + H] - logP[t]
|
||||
ok = np.isfinite(past) & np.isfinite(fwd)
|
||||
n_ok = int(ok.sum())
|
||||
if n_ok < 6:
|
||||
continue
|
||||
okidx = np.where(ok)[0]
|
||||
k = max(1, int(round(Q * n_ok)))
|
||||
losers = okidx[np.argsort(past[okidx])[:k]] # most-negative trailing return
|
||||
# raw long
|
||||
w_raw = np.zeros(N); w_raw[losers] = 1.0 / k
|
||||
# hedged: long losers - short equal-weight universe basket
|
||||
w_hed = np.zeros(N); w_hed[losers] += 1.0 / k; w_hed[okidx] -= 1.0 / n_ok
|
||||
r_raw = float(np.nansum(w_raw * fwd)) - np.abs(w_raw - w_raw_prev).sum() * COST_BP / 1e4
|
||||
r_hed = float(np.nansum(w_hed * fwd)) - np.abs(w_hed - w_hed_prev).sum() * COST_BP / 1e4
|
||||
raw.append(r_raw); hed.append(r_hed)
|
||||
yr_raw.append(int(yrs[t])); yr_hed.append(int(yrs[t]))
|
||||
w_raw_prev = w_raw; w_hed_prev = w_hed
|
||||
if len(raw) < 20:
|
||||
print(f"{H:>5} (too few periods)"); continue
|
||||
for name, series, yrl in (("raw_long", raw, yr_raw), ("hedged", hed, yr_hed)):
|
||||
sr, t = ann(series, pf)
|
||||
hit = 100.0 * np.mean(np.asarray(series) > 0)
|
||||
py = {}
|
||||
for r, y in zip(series, yrl):
|
||||
py.setdefault(y, []).append(r)
|
||||
pystr = " ".join(f"{y}:{(np.mean(v)/(np.std(v)+1e-12)):+.2f}"
|
||||
for y, v in sorted(py.items()) if len(v) >= 10)
|
||||
tag = f"{H}h" if name == "raw_long" else ""
|
||||
print(f"{tag:>5} {len(series):>8} {name:>8} {sr:>+8.2f} {t:>+7.2f} {hit:>6.1f} {pystr}")
|
||||
print("-" * len(hdr))
|
||||
print("PASS: hedged net SR > 0.5, t>=2, positive in most years. Else cascade reversion closed.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
91
scripts/surfer/crypto_funding_harvest.py
Normal file
91
scripts/surfer/crypto_funding_harvest.py
Normal file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Delta-neutral crypto funding HARVEST (cash-and-carry) — the one retail path to higher Sharpe.
|
||||
|
||||
When perp funding > 0 (longs pay shorts), go short-perp + long-spot (DELTA-NEUTRAL: price cancels)
|
||||
and collect the funding rate as carry. No price prediction. Causal: position[t] from funding[t-1]
|
||||
(funding is persistent), collect funding[t]. Net of realistic round-trip cost. Stress: worst months
|
||||
(crashes flip funding negative). Variants: broad vs majors, threshold, cost sensitivity.
|
||||
Treats `fund` as per-day funding (conservative; if per-8h the real APR/Sharpe is higher).
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import pit_sweep # noqa: E402
|
||||
|
||||
|
||||
def metrics(r):
|
||||
r = np.asarray(r); r = r[np.isfinite(r)]
|
||||
if len(r) < 50 or r.std() == 0:
|
||||
return (float("nan"),) * 4
|
||||
ann = r.mean() * 365; vol = r.std() * math.sqrt(365)
|
||||
eq = np.cumprod(1 + r); dd = float((eq / np.maximum.accumulate(eq) - 1).min())
|
||||
return ann, vol, ann / vol, dd
|
||||
|
||||
|
||||
def harvest(fund, liq, thr, c_round, year, label, topk=None):
|
||||
T, N = fund.shape
|
||||
elig = liq & np.isfinite(fund)
|
||||
sig = np.zeros((T, N))
|
||||
sig[1:] = np.where(elig[1:] & (fund[:-1] > thr), 1.0, 0.0) # causal: position from yesterday's funding
|
||||
if topk: # restrict to top-K best-funded eligible
|
||||
for t in range(1, T):
|
||||
idx = np.where(sig[t] > 0)[0]
|
||||
if len(idx) > topk:
|
||||
keep = idx[np.argsort(-fund[t - 1, idx])[:topk]]
|
||||
m = np.zeros(N); m[keep] = 1.0; sig[t] = m
|
||||
w = sig / np.maximum(sig.sum(1, keepdims=True), 1) # equal-weight positioned
|
||||
f = np.nan_to_num(fund)
|
||||
gross = np.sum(w[:-1] * f[1:], axis=1)
|
||||
turn = np.sum(np.abs(w[1:] - w[:-1]), axis=1) # fraction of book traded
|
||||
net = gross - turn * (c_round / 2) # one-way cost each side
|
||||
ann, vol, sr, dd = metrics(net)
|
||||
npos = sig.sum(1); avgpos = npos[npos > 0].mean()
|
||||
print(f"{label:>26} {100*ann:>6.1f} {100*vol:>6.1f} {sr:>+7.2f} {100*dd:>+7.1f} {avgpos:>6.0f} {100*turn.mean():>6.1f}")
|
||||
return net
|
||||
|
||||
|
||||
def main():
|
||||
syms, days, close, qv, fund = pit_sweep.load()
|
||||
T, N = fund.shape
|
||||
year = (1970 + days / 365.25).astype(int)
|
||||
qv30 = np.full_like(qv, np.nan)
|
||||
for t in range(30, T):
|
||||
qv30[t] = np.nanmean(qv[t - 30:t], axis=0)
|
||||
liq = np.isfinite(qv30) & (qv30 > 5e6) # >$5M/day quote volume
|
||||
majors = np.array([s in ("BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT", "XRPUSDT", "ADAUSDT",
|
||||
"DOGEUSDT", "AVAXUSDT", "LINKUSDT", "MATICUSDT") for s in syms])
|
||||
liq_maj = liq & majors[None, :]
|
||||
|
||||
print(f"\n===== CRYPTO FUNDING HARVEST (delta-neutral, {N} coins, {T} days) =====")
|
||||
print(f"{'variant':>26} {'APR%':>6} {'vol%':>6} {'Sharpe':>7} {'maxDD%':>7} {'#pos':>6} {'turn%':>6}")
|
||||
base = harvest(fund, liq, 0.0, 0.0010, year, "broad, thr=0, 10bp")
|
||||
harvest(fund, liq, 0.0003, 0.0010, year, "broad, thr=3bp, 10bp")
|
||||
harvest(fund, liq, 0.0, 0.0010, year, "broad top-20, 10bp", topk=20)
|
||||
harvest(fund, liq_maj, 0.0, 0.0010, year, "majors only, 10bp")
|
||||
print(" -- cost sensitivity (broad, thr=0) --")
|
||||
harvest(fund, liq, 0.0, 0.0005, year, "broad, 5bp")
|
||||
harvest(fund, liq, 0.0, 0.0020, year, "broad, 20bp")
|
||||
harvest(fund, liq, 0.0, 0.0040, year, "broad, 40bp (harsh)")
|
||||
|
||||
# per-year + worst-month stress on the base case
|
||||
print("\n per-year Sharpe (broad, thr=0, 10bp):")
|
||||
print(" " + " ".join(f"{y}:{metrics(base[year[1:]==y])[2]:+.1f}" for y in range(2019, 2027) if (year[1:] == y).sum() > 60))
|
||||
# monthly buckets
|
||||
mo = (days[1:] / 30.4).astype(int)
|
||||
worst = sorted(set(mo), key=lambda m: base[mo == m].sum())[:5]
|
||||
print(" worst 5 ~monthly buckets (crash stress — does delta-neutral hold?):")
|
||||
for m in worst:
|
||||
seg = base[mo == m]
|
||||
d0 = int(days[1:][mo == m][0])
|
||||
import datetime
|
||||
print(f" ~{datetime.date.fromordinal(d0+719163)}: {100*seg.sum():+.1f}% over {len(seg)}d")
|
||||
print("\nVERDICT: if net Sharpe >> 1 AND survives crash months (delta-neutral so price-neutral) = the genuine")
|
||||
print("retail higher-Sharpe edge. Watch: turnover cost (funding flips), and whether tail months go deeply negative.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
97
scripts/surfer/crypto_funding_liveness.py
Normal file
97
scripts/surfer/crypto_funding_liveness.py
Normal file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Decay/liveness: is the crypto funding carry still ALIVE now, or competed to death?
|
||||
|
||||
The harvest was +12 Sharpe 2020-24 but negative 2022, 2025, 2026. Decisive question: is that
|
||||
(a) STRUCTURAL COMPRESSION (funding yield shrinking year-over-year = arbitraged away = dead) or
|
||||
(b) REGIME (funding flips negative in deleverage, recovers = dormant)? And does a regime filter
|
||||
(only harvest reliably-positive funding, else sit in cash) rescue the recent period?
|
||||
|
||||
Diagnostics: (1) funding-yield decay table by year; (2) quarterly net-harvest liveness timeline;
|
||||
(3) regime-filtered vs unfiltered in the recent window.
|
||||
"""
|
||||
import datetime
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import pit_sweep # noqa: E402
|
||||
|
||||
|
||||
def sharpe(r):
|
||||
r = r[np.isfinite(r)]
|
||||
return float("nan") if len(r) < 20 or r.std() == 0 else r.mean() / r.std() * math.sqrt(365)
|
||||
|
||||
|
||||
def harvest(fund, elig, hurdle, c_round, trail=None):
|
||||
T, N = fund.shape
|
||||
cond = fund > hurdle
|
||||
if trail: # regime filter: trailing-mean funding > hurdle
|
||||
tf = np.full_like(fund, np.nan)
|
||||
for t in range(trail, T):
|
||||
tf[t] = np.nanmean(fund[t - trail:t], axis=0)
|
||||
cond = tf > hurdle
|
||||
sig = np.zeros((T, N))
|
||||
sig[1:] = np.where(elig[1:] & cond[:-1], 1.0, 0.0) # causal
|
||||
w = sig / np.maximum(sig.sum(1, keepdims=True), 1)
|
||||
f = np.nan_to_num(fund)
|
||||
gross = np.sum(w[:-1] * f[1:], axis=1)
|
||||
turn = np.sum(np.abs(w[1:] - w[:-1]), axis=1)
|
||||
net = gross - turn * (c_round / 2)
|
||||
inmkt = sig.sum(1)[1:] > 0 # days actually positioned
|
||||
return net, inmkt
|
||||
|
||||
|
||||
def main():
|
||||
syms, days, close, qv, fund = pit_sweep.load()
|
||||
T, N = fund.shape
|
||||
year = (1970 + days / 365.25).astype(int)
|
||||
qv30 = np.full_like(qv, np.nan)
|
||||
for t in range(30, T):
|
||||
qv30[t] = np.nanmean(qv[t - 30:t], axis=0)
|
||||
elig = np.isfinite(qv30) & (qv30 > 5e6) & np.isfinite(fund)
|
||||
|
||||
print("\n===== (1) FUNDING-YIELD DECAY by year (compression vs regime) =====")
|
||||
print(f"{'year':>6} {'#coins':>7} {'frac_pos':>9} {'mean_all(bp)':>13} {'mean_pos(bp)':>13}")
|
||||
for y in range(2019, 2027):
|
||||
m = year == y
|
||||
e = elig[m]
|
||||
fy = fund[m][e]
|
||||
if len(fy) < 100:
|
||||
continue
|
||||
ncoin = int(e.sum(1).mean()) if e.ndim == 2 else int(e.mean() * N)
|
||||
fpos = float((fy > 0).mean())
|
||||
print(f"{y:>6} {ncoin:>7} {fpos:>9.2f} {1e4*fy.mean():>13.2f} {1e4*fy[fy > 0].mean():>13.2f}")
|
||||
|
||||
print("\n===== (2) QUARTERLY net-harvest liveness (broad, thr=0, 10bp) =====")
|
||||
net, _ = harvest(fund, elig, 0.0, 0.0010)
|
||||
q = ((days[1:] - days[1]) / 91.3).astype(int)
|
||||
dd = days[1:]
|
||||
print(f"{'quarter':>10} {'netAPR%':>8} {'Sharpe':>7}")
|
||||
for qq in sorted(set(q)):
|
||||
seg = net[q == qq]
|
||||
d0 = datetime.date.fromordinal(int(dd[q == qq][0]) + 719163)
|
||||
if d0.year >= 2024: # focus recent
|
||||
print(f"{str(d0):>10} {100 * seg.sum() * (365 / max(len(seg), 1)) / 1:>8.1f} {sharpe(seg):>7.1f}")
|
||||
|
||||
print("\n===== (3) REGIME FILTER rescue test (recent: 2025-2026) =====")
|
||||
recent = year[1:] >= 2025
|
||||
variants = [("unfiltered thr=0 10bp", harvest(fund, elig, 0.0, 0.0010)[0]),
|
||||
("filtered tf14>3bp 10bp", harvest(fund, elig, 0.0003, 0.0010, trail=14)[0]),
|
||||
("filtered tf14>5bp 10bp", harvest(fund, elig, 0.0005, 0.0010, trail=14)[0]),
|
||||
("filtered tf30>5bp 10bp", harvest(fund, elig, 0.0005, 0.0010, trail=30)[0])]
|
||||
print(f"{'variant':>24} {'2025-26 APR%':>13} {'2025-26 Sharpe':>15} {'full Sharpe':>12}")
|
||||
for nm, r in variants:
|
||||
rr = r[recent]
|
||||
apr = 100 * np.nansum(rr) * 365 / max(np.isfinite(rr).sum(), 1)
|
||||
print(f"{nm:>24} {apr:>13.1f} {sharpe(rr):>15.1f} {sharpe(r):>12.1f}")
|
||||
|
||||
print("\nVERDICT: if mean_pos(bp) stable across years but frac_pos dipped 2025-26 = REGIME (dormant, recoverable).")
|
||||
print("If mean_pos shrinks monotonically = COMPRESSION (competed to death). If a filter turns 2025-26 positive")
|
||||
print("= alive-but-needs-regime-timing. If even filtered 2025-26 is negative/zero = the edge is GONE now.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
92
scripts/surfer/crypto_funding_oos.py
Normal file
92
scripts/surfer/crypto_funding_oos.py
Normal file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Clean OOS validation of the funding-harvest regime filter.
|
||||
|
||||
Protocol (no peeking): choose the regime-filter params on IN-SAMPLE 2019-2024 by IS Sharpe, then
|
||||
apply that EXACT filter BLIND to OUT-OF-SAMPLE 2025-2026. If the IS-best filter still works OOS,
|
||||
the edge is validated, not curve-fit. Also reports the full grid's OOS so we can see if it's
|
||||
robust across reasonable filters (real) or only the lucky one (suspicious). Strategy structure
|
||||
fixed; only the regime filter (trailing window, hurdle) is the tuned degree of freedom.
|
||||
Raw Sharpe is funding-only (vol understated ~2-3x); realistic ~ raw / 2.5.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import pit_sweep # noqa: E402
|
||||
|
||||
|
||||
def sharpe(r):
|
||||
r = r[np.isfinite(r)]
|
||||
return float("nan") if len(r) < 20 or r.std() == 0 else r.mean() / r.std() * math.sqrt(365)
|
||||
|
||||
|
||||
def apr(r):
|
||||
r = r[np.isfinite(r)]
|
||||
return 100 * r.sum() * 365 / max(len(r), 1)
|
||||
|
||||
|
||||
def main():
|
||||
syms, days, close, qv, fund = pit_sweep.load()
|
||||
T, N = fund.shape
|
||||
year = (1970 + days / 365.25).astype(int)
|
||||
qv30 = np.full_like(qv, np.nan)
|
||||
for t in range(30, T):
|
||||
qv30[t] = np.nanmean(qv[t - 30:t], axis=0)
|
||||
elig = np.isfinite(qv30) & (qv30 > 5e6) & np.isfinite(fund)
|
||||
f = np.nan_to_num(fund)
|
||||
|
||||
# precompute trailing means
|
||||
trailmean = {}
|
||||
for tr in (14, 30):
|
||||
tm = np.full_like(fund, np.nan)
|
||||
for t in range(tr, T):
|
||||
tm[t] = np.nanmean(fund[t - tr:t], axis=0)
|
||||
trailmean[tr] = tm
|
||||
|
||||
def harvest(trail, hurdle, c=0.0010):
|
||||
cond = (fund > hurdle) if trail == 0 else (trailmean[trail] > hurdle)
|
||||
sig = np.zeros((T, N))
|
||||
sig[1:] = np.where(elig[1:] & cond[:-1], 1.0, 0.0) # causal
|
||||
w = sig / np.maximum(sig.sum(1, keepdims=True), 1)
|
||||
gross = np.sum(w[:-1] * f[1:], axis=1)
|
||||
turn = np.sum(np.abs(w[1:] - w[:-1]), axis=1)
|
||||
net = gross - turn * (c / 2)
|
||||
inmkt = (sig.sum(1)[1:] > 0)
|
||||
return net, inmkt
|
||||
|
||||
yr = year[1:]
|
||||
IS = yr <= 2024
|
||||
OOS = yr >= 2025
|
||||
|
||||
grid = [(tr, h) for tr in (0, 14, 30) for h in (0.0, 0.0002, 0.0003, 0.0005)]
|
||||
rows = []
|
||||
for tr, h in grid:
|
||||
net, inmkt = harvest(tr, h)
|
||||
rows.append(dict(tr=tr, h=h, net=net, inmkt=inmkt,
|
||||
is_sr=sharpe(net[IS]), oos_sr=sharpe(net[OOS]),
|
||||
oos_apr=apr(net[OOS]), oos_inmkt=float(inmkt[OOS].mean())))
|
||||
|
||||
print(f"\n===== CLEAN OOS VALIDATION — IS 2019-2024 / OOS 2025-2026 =====")
|
||||
print(f"{'filter':>18} {'IS Sharpe':>10} {'OOS Sharpe':>11} {'OOS APR%':>9} {'OOS in-mkt%':>11}")
|
||||
for r in rows:
|
||||
nm = "naive thr=0" if (r["tr"] == 0 and r["h"] == 0) else (f"inst>{r['h']*1e4:.0f}bp" if r["tr"] == 0 else f"tf{r['tr']}>{r['h']*1e4:.0f}bp")
|
||||
print(f"{nm:>18} {r['is_sr']:>+10.1f} {r['oos_sr']:>+11.1f} {r['oos_apr']:>+9.1f} {100*r['oos_inmkt']:>10.0f}")
|
||||
|
||||
best = max(rows, key=lambda r: r["is_sr"])
|
||||
bnm = f"tf{best['tr']}>{best['h']*1e4:.0f}bp" if best["tr"] else f"inst>{best['h']*1e4:.0f}bp"
|
||||
print(f"\n IS-BEST filter (chosen blind to OOS): {bnm} -> IS Sharpe {best['is_sr']:+.1f}")
|
||||
print(f" ITS OOS RESULT: Sharpe {best['oos_sr']:+.1f} APR {best['oos_apr']:+.1f}% in-market {100*best['oos_inmkt']:.0f}% of days")
|
||||
print(f" (realistic Sharpe after basis vol ~ raw / 2.5 = {best['oos_sr']/2.5:+.1f})")
|
||||
# robustness: how many filters with IS Sharpe>2 also have OOS Sharpe>1
|
||||
good = [r for r in rows if r["is_sr"] > 2]
|
||||
robust = [r for r in good if r["oos_sr"] > 1]
|
||||
print(f"\n robustness: of {len(good)} filters with IS Sharpe>2, {len(robust)} also have OOS Sharpe>1")
|
||||
print("\nVERDICT: IS-best filter OOS Sharpe>1 (realistic) + broad robustness = VALIDATED, deployable.")
|
||||
print("If IS-best collapses OOS or only 1 filter works = curve-fit, not real.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
242
scripts/surfer/crypto_funding_paper.py
Normal file
242
scripts/surfer/crypto_funding_paper.py
Normal file
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
"""fundcli — local CLI for the crypto funding-harvest strategy (Phase-1 paper-forward).
|
||||
|
||||
No agents, no cloud. A self-contained local tool over Binance's public API (no key, no capital).
|
||||
Delta-neutral funding harvest: hold long-spot/short-perp on CRYPTO-NATIVE coins whose trailing-30d
|
||||
mean daily funding > 5bp; collect funding as market-neutral carry. Validated OOS (realistic
|
||||
Sharpe ~2-3.6); this tool tracks the no-capital forward record.
|
||||
|
||||
python3 crypto_funding_paper.py snapshot live liveness check (qualifying coins; no state change)
|
||||
python3 crypto_funding_paper.py run daily step: book funding on prior positions, log, persist
|
||||
python3 crypto_funding_paper.py status current book + cumulative track record
|
||||
python3 crypto_funding_paper.py gate Phase-1 -> Phase-2 assessment vs backtest
|
||||
python3 crypto_funding_paper.py orders [USD] Phase-2: current book -> exact delta-neutral orders
|
||||
python3 crypto_funding_paper.py log [N] last N run-log lines (default 25)
|
||||
(alias: 'paper' == 'run', for the cron)
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
STATE = os.path.join(_REPO, "data/surfer/funding_paper_state.json")
|
||||
LOG = os.path.join(_REPO, "data/surfer/funding_paper_runs.log")
|
||||
FAPI = "https://fapi.binance.com"
|
||||
|
||||
HURDLE = 0.0005 # 5 bp/day trailing-30d mean funding (validated filter)
|
||||
EXIT_HURDLE = 0.0003 # hysteresis: keep held coins until they fall below 3 bp/day
|
||||
LIQ_USD = 5e6 # >$5M/day quote volume
|
||||
COST_RT = 0.0010 # 10 bp round-trip (net-of-cost awareness)
|
||||
INTERVALS_PER_DAY = 3 # Binance funding settles every 8h
|
||||
BACKTEST_APR = (15, 22) # validated APR band on deployed capital
|
||||
|
||||
|
||||
def get(url, tries=4):
|
||||
for a in range(tries):
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "curl/8"})
|
||||
return json.loads(urllib.request.urlopen(req, timeout=30).read())
|
||||
except Exception:
|
||||
if a == tries - 1:
|
||||
raise
|
||||
time.sleep(2 * (a + 1))
|
||||
|
||||
|
||||
def crypto_native():
|
||||
info = get(f"{FAPI}/fapi/v1/exchangeInfo")
|
||||
return {s["symbol"] for s in info["symbols"] if s.get("underlyingType") == "COIN"}
|
||||
|
||||
|
||||
def spot_symbols():
|
||||
"""Symbols with a Binance SPOT market — required to build the delta-neutral hedge.
|
||||
Perp-only coins can't be cash-and-carry harvested (no spot leg); their high funding survives
|
||||
PRECISELY because the arb is impossible, so they must be excluded."""
|
||||
return {x["symbol"] for x in get("https://api.binance.com/api/v3/ticker/price")}
|
||||
|
||||
|
||||
def universe():
|
||||
"""Liquid, crypto-native, HEDGEABLE (spot+perp) USDT perps — the only ones that can be
|
||||
delta-neutral harvested."""
|
||||
native = crypto_native()
|
||||
spot = spot_symbols()
|
||||
t = get(f"{FAPI}/fapi/v1/ticker/24hr")
|
||||
return {x["symbol"]: float(x["quoteVolume"]) for x in t
|
||||
if x["symbol"].endswith("USDT") and x["symbol"] in native and x["symbol"] in spot
|
||||
and float(x["quoteVolume"]) > LIQ_USD}
|
||||
|
||||
|
||||
def funding_hist(sym, limit=90):
|
||||
h = get(f"{FAPI}/fapi/v1/fundingRate?symbol={sym}&limit={limit}")
|
||||
return [float(x["fundingRate"]) for x in h]
|
||||
|
||||
|
||||
def scan(prev=None):
|
||||
"""Fetch universe + funding; return (liq, tf30, last24) for union of universe and prev positions."""
|
||||
liq = universe()
|
||||
need = set(liq) | set(prev or {})
|
||||
tf30, last24 = {}, {}
|
||||
for i, sym in enumerate(sorted(need)):
|
||||
try:
|
||||
h = funding_hist(sym)
|
||||
except Exception:
|
||||
continue
|
||||
if len(h) < 30:
|
||||
continue
|
||||
tf30[sym] = (sum(h) / len(h)) * INTERVALS_PER_DAY
|
||||
last24[sym] = sum(h[-INTERVALS_PER_DAY:])
|
||||
if i % 50 == 49:
|
||||
time.sleep(0.5)
|
||||
return liq, tf30, last24
|
||||
|
||||
|
||||
def qualify(liq, tf30, prev):
|
||||
q = {}
|
||||
for c in liq:
|
||||
t = tf30.get(c)
|
||||
if t is not None and (t > HURDLE or (c in prev and t > EXIT_HURDLE)):
|
||||
q[c] = t
|
||||
return q
|
||||
|
||||
|
||||
def regime(n):
|
||||
return "ALIVE (healthy)" if n >= 15 else ("THIN / deleverage (mostly cash, by design)" if n >= 5 else "COMPRESSED (edge thin/decaying)")
|
||||
|
||||
|
||||
def load_state():
|
||||
if os.path.exists(STATE):
|
||||
return json.load(open(STATE))
|
||||
return {"positions": {}, "cum_gross": 0.0, "cum_net": 0.0, "days": 0}
|
||||
|
||||
|
||||
# ---- commands ----
|
||||
def cmd_snapshot():
|
||||
liq, tf30, _ = scan()
|
||||
q = qualify(liq, tf30, {})
|
||||
top = sorted(q.items(), key=lambda kv: -kv[1])
|
||||
med = sorted(q.values())[len(q) // 2] if q else 0.0
|
||||
print(f"funding snapshot {datetime.date.today()} (crypto-native, live)")
|
||||
print(f" liquid universe: {len(liq)} perps (>${LIQ_USD/1e6:.0f}M/day)")
|
||||
print(f" qualifying (tf30 > {HURDLE*1e4:.0f}bp/day): {len(q)} regime: {regime(len(q))}")
|
||||
print(f" median qualifying funding: {med*1e4:.1f} bp/day")
|
||||
for c, t in top[:15]:
|
||||
print(f" {c:>16} {t*1e4:5.1f} bp/day")
|
||||
|
||||
|
||||
def cmd_run():
|
||||
st = load_state()
|
||||
today = datetime.datetime.now(datetime.timezone.utc).date().isoformat()
|
||||
if st.get("last_run_date") == today: # idempotent: one booking per UTC day
|
||||
print(f"already booked {today} (day {st['days']}); skipping")
|
||||
return
|
||||
prev = st["positions"]
|
||||
liq, tf30, last24 = scan(prev)
|
||||
realized = sum(w * last24.get(c, 0.0) for c, w in prev.items())
|
||||
q = qualify(liq, tf30, prev)
|
||||
n = len(q)
|
||||
newpos = {c: 1.0 / n for c in q} if n else {}
|
||||
turnover = sum(abs(newpos.get(c, 0) - prev.get(c, 0)) for c in set(newpos) | set(prev))
|
||||
net = realized - turnover * (COST_RT / 2)
|
||||
st.update(positions=newpos, days=st["days"] + 1, last_run_date=today)
|
||||
st["cum_gross"] += realized
|
||||
st["cum_net"] += net
|
||||
os.makedirs(os.path.dirname(STATE), exist_ok=True)
|
||||
json.dump(st, open(STATE, "w"))
|
||||
top = " ".join(f"{c}:{t*1e4:.0f}bp" for c, t in sorted(q.items(), key=lambda kv: -kv[1])[:5])
|
||||
line = (f"{datetime.date.today()} day={st['days']} | qualifying={n} liquid={len(liq)} | "
|
||||
f"realized24h gross={100*realized:+.3f}% net={100*net:+.3f}% turn={turnover:.2f} | "
|
||||
f"cum gross={100*st['cum_gross']:+.2f}% net={100*st['cum_net']:+.2f}% | top: {top}")
|
||||
print(line)
|
||||
|
||||
|
||||
def cmd_status():
|
||||
st = load_state()
|
||||
print(f"funding-harvest paper state: day {st['days']}, {len(st['positions'])} positions, "
|
||||
f"cum gross {100*st['cum_gross']:+.2f}% cum net {100*st['cum_net']:+.2f}%")
|
||||
for c, w in sorted(st["positions"].items(), key=lambda kv: -kv[1]):
|
||||
print(f" {c:>16} w={w:.3f}")
|
||||
|
||||
|
||||
def cmd_gate():
|
||||
st = load_state()
|
||||
d = st["days"]
|
||||
if d == 0:
|
||||
print("no data yet — run 'run' (or wait for cron) to start the track record."); return
|
||||
apr = st["cum_net"] * 365 / d * 100
|
||||
print(f"=== Phase-1 gate assessment (day {d}, {d/7:.1f} weeks) ===")
|
||||
print(f" cumulative net: {100*st['cum_net']:+.2f}% annualized: {apr:+.1f}% APR")
|
||||
print(f" backtest target band: {BACKTEST_APR[0]}-{BACKTEST_APR[1]}% APR (gross carry, deployed capital)")
|
||||
if d < 28:
|
||||
rec = f"KEEP ACCUMULATING — need >=4 weeks ({28-d} days to go) before the gate is meaningful."
|
||||
elif apr >= 10:
|
||||
rec = "PROCEED-candidate -> Phase 2 (micro-live $1-3k, ONE tier-1 venue, low leverage). Carry holding forward."
|
||||
elif apr > 0:
|
||||
rec = "BORDERLINE -> extend paper-forward to 8 weeks; carry positive but below backtest band (thin regime)."
|
||||
else:
|
||||
rec = "DO NOT GO LIVE -> carry flat/negative forward; extend or stop. No capital risked."
|
||||
print(f" recommendation: {rec}")
|
||||
print(" caveats: GROSS carry only (realistic Sharpe ~ raw/2.5 after basis vol); counterparty/exchange")
|
||||
print(" tail is the real -100% risk (un-modeled); current qualifiers may be small/niche coins.")
|
||||
|
||||
|
||||
def cmd_orders(capital):
|
||||
"""Phase-2 bridge: turn the current paper book into exact delta-neutral orders for `capital`,
|
||||
and flag coins that are perp-only (no spot leg = can't hedge cleanly on Binance)."""
|
||||
st = load_state()
|
||||
book = st.get("positions", {})
|
||||
if not book:
|
||||
print("no current book — run 'snapshot'/'run' first."); return
|
||||
try:
|
||||
spot_px = {x["symbol"]: float(x["price"]) for x in get("https://api.binance.com/api/v3/ticker/price")}
|
||||
except Exception:
|
||||
spot_px = {}
|
||||
perp_px = {x["symbol"]: float(x["price"]) for x in get(f"{FAPI}/fapi/v1/ticker/price")}
|
||||
LEV = 2.0 # conservative perp leverage (avoid liquidation)
|
||||
n = len(book)
|
||||
X = capital / (n * (1 + 1 / LEV)) # notional per leg per coin
|
||||
print(f"=== Phase-2 delta-neutral orders for ${capital:.0f} across {n} coins (perp {LEV:.0f}x) ===")
|
||||
print(f" per coin: ~${X:.0f} notional/leg, ~${X/LEV:.0f} perp margin, ~${X*(1+1/LEV):.0f} capital")
|
||||
print(f"{'coin':>14} {'hedge':>6} {'SPOT buy (units @ px)':>26} {'PERP short (units @ px)':>26}")
|
||||
ok = 0
|
||||
for c in sorted(book):
|
||||
if c in spot_px and c in perp_px:
|
||||
print(f"{c:>14} {'YES':>6} {X/spot_px[c]:>14.4f} @ {spot_px[c]:<9.5g} {X/perp_px[c]:>14.4f} @ {perp_px[c]:<9.5g}")
|
||||
ok += 1
|
||||
else:
|
||||
why = "no-spot" if c not in spot_px else "no-perp"
|
||||
print(f"{c:>14} {'NO':>6} ({why}) cannot delta-neutral hedge on Binance — skip / alt-venue")
|
||||
print(f"\n {ok}/{n} coins hedgeable on Binance (spot+perp both exist).")
|
||||
print(" Place SPOT + PERP legs together to stay delta-neutral. Keep perp leverage low; never let the")
|
||||
print(" perp leg liquidate while holding spot. API keys: ENV ONLY, never commit. Place manually for")
|
||||
print(" micro-live to validate fills before any automation. DEPLOY ONLY AFTER the Phase-1 gate passes.")
|
||||
|
||||
|
||||
def cmd_log(n=25):
|
||||
if not os.path.exists(LOG):
|
||||
print(f"(no log yet at {LOG} — cron writes it nightly; 'run' appends when redirected)"); return
|
||||
lines = open(LOG).read().splitlines()
|
||||
print("\n".join(lines[-n:]))
|
||||
|
||||
|
||||
def main():
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "snapshot"
|
||||
if cmd in ("run", "paper"):
|
||||
cmd_run()
|
||||
elif cmd == "snapshot":
|
||||
cmd_snapshot()
|
||||
elif cmd == "status":
|
||||
cmd_status()
|
||||
elif cmd == "gate":
|
||||
cmd_gate()
|
||||
elif cmd == "orders":
|
||||
cmd_orders(float(sys.argv[2]) if len(sys.argv) > 2 else 2000.0)
|
||||
elif cmd == "log":
|
||||
cmd_log(int(sys.argv[2]) if len(sys.argv) > 2 else 25)
|
||||
else:
|
||||
print(__doc__)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
129
scripts/surfer/crypto_mft_xsec.py
Normal file
129
scripts/surfer/crypto_mft_xsec.py
Normal file
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""MFT crypto pulse — cross-sectional momentum at HOUR holds, net of turnover cost, hardened-lite.
|
||||
|
||||
The proven crypto edge is DAILY cross-sectional momentum (Sharpe ~1.4). micro_gate showed MINUTE-horizon
|
||||
(per-coin) is dead. This tests the gap: does cross-sectional momentum work at HOUR holds (1h..48h)? If a
|
||||
pulse survives 5bp/leg turnover cost with stable per-year sign, it justifies a full multi-year hardened
|
||||
build; if not, intraday/MFT is closed and the edge is purely daily.
|
||||
|
||||
Hourly Binance klines (free, timestamped) for liquid perps → aligned panel. Pre-registered: lookback=hold=H,
|
||||
weights = dollar-neutral cross-sectional z-score of trailing-H return (gross 1), rebalance every H hours
|
||||
(NON-OVERLAPPING), forward-H return, cost = turnover · 5bp. Report gross/net annualized Sharpe + t + per-year.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
import numpy as np
|
||||
|
||||
OUT = "data/surfer/crypto1h"
|
||||
COST_BP = 5.0
|
||||
HOLDS = [1, 2, 4, 8, 12, 24, 48]
|
||||
SYMS = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "XRPUSDT", "BNBUSDT", "DOGEUSDT", "ADAUSDT", "AVAXUSDT",
|
||||
"LINKUSDT", "LTCUSDT", "DOTUSDT", "TRXUSDT", "BCHUSDT", "ETCUSDT", "FILUSDT", "ATOMUSDT"]
|
||||
HOUR_MS = 3_600_000
|
||||
|
||||
|
||||
def _get(u):
|
||||
return json.load(urllib.request.urlopen(urllib.request.Request(u, headers={"User-Agent": "curl/8"}), timeout=30))
|
||||
|
||||
|
||||
def fetch(sym):
|
||||
cache = f"{OUT}/{sym}.npz"
|
||||
if os.path.exists(cache):
|
||||
d = np.load(cache); return d["ts"], d["close"]
|
||||
end = int(time.time() * 1000); start = end - 5 * 365 * 86_400_000 # up to ~5y
|
||||
rows, cur = [], start
|
||||
for _ in range(400):
|
||||
k = _get(f"https://fapi.binance.com/fapi/v1/klines?symbol={sym}&interval=1h&startTime={cur}&limit=1500")
|
||||
if not k:
|
||||
break
|
||||
rows += k
|
||||
cur = k[-1][0] + 1
|
||||
if len(k) < 1500 or cur >= end:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
if not rows:
|
||||
return np.array([]), np.array([])
|
||||
d = {int(r[0]): float(r[4]) for r in rows}
|
||||
ts = np.array(sorted(d)); close = np.array([d[t] for t in ts])
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
np.savez(cache, ts=ts, close=close)
|
||||
return ts, close
|
||||
|
||||
|
||||
def build_panel():
|
||||
series = {}
|
||||
for s in SYMS:
|
||||
ts, c = fetch(s)
|
||||
if len(ts) > 24 * 60: # need >~2 months
|
||||
series[s] = (ts, c)
|
||||
# align on the union of hourly timestamps (floored to the hour)
|
||||
allts = sorted(set().union(*[set((ts // HOUR_MS).tolist()) for ts, _ in series.values()]))
|
||||
tindex = {t: i for i, t in enumerate(allts)}
|
||||
syms = sorted(series)
|
||||
P = np.full((len(allts), len(syms)), np.nan)
|
||||
for j, s in enumerate(syms):
|
||||
ts, c = series[s]
|
||||
for t, px in zip(ts // HOUR_MS, c):
|
||||
P[tindex[int(t)], j] = px
|
||||
yrs = np.array([1970 + int(t) // (365.25 * 24) for t in allts])
|
||||
return P, syms, yrs
|
||||
|
||||
|
||||
def ann_sharpe(x):
|
||||
x = x[np.isfinite(x)]
|
||||
if len(x) < 20 or x.std() == 0:
|
||||
return float("nan"), float("nan"), 0
|
||||
return float(x.mean() / x.std()), x.mean() / (x.std() / math.sqrt(len(x))), len(x)
|
||||
|
||||
|
||||
def run():
|
||||
P, syms, yrs = build_panel()
|
||||
T, N = P.shape
|
||||
logP = np.log(P)
|
||||
print(f"\n========== MFT CRYPTO CROSS-SECTIONAL MOMENTUM (hour holds, net {COST_BP}bp/turnover) ==========")
|
||||
print(f"panel: {T} hours × {N} coins ({syms[0]}..{syms[-1]}) years {int(yrs.min())}-{int(yrs.max())}")
|
||||
print("dollar-neutral xs z-score momentum, lookback=hold=H, non-overlapping rebalance, cost=turnover·5bp\n")
|
||||
print(f"{'hold':>6} {'periods':>8} {'gross_SR':>9} {'net_SR':>8} {'t(net)':>7} {'pos%':>6} {'per-year net-SR (chrono)':>26}")
|
||||
print("-" * 92)
|
||||
for H in HOLDS:
|
||||
idx = np.arange(0, T - H, H) # non-overlapping rebalance points
|
||||
rets, yr_of = [], []
|
||||
w_prev = np.zeros(N)
|
||||
per_year = {}
|
||||
for t in idx:
|
||||
past = logP[t] - logP[t - H] if t - H >= 0 else np.full(N, np.nan)
|
||||
fwd = logP[t + H] - logP[t]
|
||||
ok = np.isfinite(past) & np.isfinite(fwd)
|
||||
if ok.sum() < 6:
|
||||
continue
|
||||
z = np.zeros(N)
|
||||
pv = past[ok]; zz = (pv - pv.mean()) / (pv.std() + 1e-12)
|
||||
z[ok] = zz
|
||||
g = np.abs(z).sum()
|
||||
w = z / g if g > 0 else z # dollar-neutral, gross 1
|
||||
turn = np.abs(w - w_prev).sum()
|
||||
r = float(np.nansum(w * fwd)) - turn * COST_BP / 1e4
|
||||
rets.append(r); yr_of.append(int(yrs[t]))
|
||||
per_year.setdefault(int(yrs[t]), []).append(r)
|
||||
w_prev = w
|
||||
rets = np.array(rets)
|
||||
if len(rets) < 20:
|
||||
print(f"{H:>6} (too few periods)"); continue
|
||||
gross_sr, _, _ = ann_sharpe(rets + 0) # gross approximated below
|
||||
# recompute gross (no cost) quickly
|
||||
per_yr_str = " ".join(f"{y}:{(np.mean(v)/ (np.std(v)+1e-12)):+.2f}" for y, v in sorted(per_year.items()) if len(v) >= 10)
|
||||
per_period = math.sqrt(24 * 365 / H) # annualization factor for H-hour periods
|
||||
nsr, nt, n = ann_sharpe(rets)
|
||||
# gross series
|
||||
# (recompute gross by adding back mean turnover cost is approximate; instead report net only + per-year)
|
||||
print(f"{H:>6}h {len(rets):>8} {'':>9} {nsr*per_period:>+8.2f} {nt:>+7.2f} {100*np.mean(rets>0):>6.1f} {per_yr_str}")
|
||||
print("-" * 92)
|
||||
print("PASS: net annualized SR > 0 with t≥2 AND positive in most years. Else MFT-crypto closed → daily only.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
119
scripts/surfer/crypto_stablecoin_dislocation.py
Normal file
119
scripts/surfer/crypto_stablecoin_dislocation.py
Normal file
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stablecoin peg-reversion existence test — the cleanest crypto-FX dislocation.
|
||||
|
||||
Stablecoins are supposed to be worth $1; mint/redeem arbitrage (forced flow) pulls a deviation back
|
||||
to parity. This tests whether that reversion is harvestable NET OF COST on free Binance spot data, and
|
||||
— critically — how bad the DEATH-SPIRAL tail is (some "depegs" never revert: UST->0, BUSD wind-down).
|
||||
|
||||
Pre-registered: pair STABLE/USDT, deviation d=close-1. Reversion bet when |d|>thresh: position =
|
||||
-sign(d) (rich->short, cheap->long), hold H days, pnl_net = -sign(d)*(close[t+H]/close[t]-1) - cost_rt.
|
||||
Report per-pair + pooled: trade count, mean net pnl (bp), hit%, WORST trade (bp, the death-spiral tail),
|
||||
and a daily-strategy Sharpe. Cost levels bracket Binance stablecoin fees {0,2,10}bp round-trip.
|
||||
KILL: pooled net mean<=0 OR edge entirely from one terminal name OR worst-trade tail dwarfs mean edge.
|
||||
|
||||
LIMITATION: overlapping forward windows (first-look existence test, not a hardened backtest); daily
|
||||
granularity (intraday depeg spikes under-sampled). Flagged, not hidden.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
import numpy as np
|
||||
|
||||
OUT = "data/surfer/stablecoin"
|
||||
PAIRS = ["USDCUSDT", "FDUSDUSDT", "TUSDUSDT", "DAIUSDT", "BUSDUSDT", "USDPUSDT"]
|
||||
DAY_MS = 86_400_000
|
||||
THRESHOLDS_BP = [10, 25, 50]
|
||||
HOLDS = [1, 2, 5]
|
||||
COSTS_BP = [0.0, 2.0, 10.0]
|
||||
|
||||
|
||||
def _get(u):
|
||||
return json.load(urllib.request.urlopen(urllib.request.Request(u, headers={"User-Agent": "curl/8"}), timeout=30))
|
||||
|
||||
|
||||
def fetch(sym):
|
||||
cache = f"{OUT}/{sym}.npz"
|
||||
if os.path.exists(cache):
|
||||
d = np.load(cache); return d["ts"], d["close"]
|
||||
start = 1_546_300_800_000 # 2019-01-01
|
||||
end = int(time.time() * 1000)
|
||||
rows, cur = [], start
|
||||
for _ in range(400):
|
||||
k = _get(f"https://api.binance.com/api/v3/klines?symbol={sym}&interval=1d&startTime={cur}&limit=1000")
|
||||
if not k:
|
||||
break
|
||||
rows += k
|
||||
cur = k[-1][0] + DAY_MS
|
||||
if len(k) < 1000 or cur >= end:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
if not rows:
|
||||
return np.array([]), np.array([])
|
||||
d = {int(r[0]): float(r[4]) for r in rows}
|
||||
ts = np.array(sorted(d)); close = np.array([d[t] for t in ts])
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
np.savez(cache, ts=ts, close=close)
|
||||
return ts, close
|
||||
|
||||
|
||||
def reversion(close, thresh_bp, H, cost_bp):
|
||||
"""Event reversion bet: enter when |d|>thresh, pnl=-sign(d)*(fwd_ret)-cost_rt. Returns net-pnl array (bp)."""
|
||||
d = close - 1.0
|
||||
th = thresh_bp / 1e4
|
||||
pnls = []
|
||||
for t in range(len(close) - H):
|
||||
if abs(d[t]) > th and close[t] > 0:
|
||||
fwd = close[t + H] / close[t] - 1.0
|
||||
pnl = -np.sign(d[t]) * fwd - cost_bp / 1e4
|
||||
pnls.append(pnl * 1e4) # in bp
|
||||
return np.array(pnls)
|
||||
|
||||
|
||||
def run():
|
||||
series = {}
|
||||
for s in PAIRS:
|
||||
ts, c = fetch(s)
|
||||
if len(ts) > 60:
|
||||
series[s] = c
|
||||
print("\n========== STABLECOIN PEG-REVERSION (crypto-FX dislocation, free Binance spot daily) ==========")
|
||||
print(f"pairs: {', '.join(f'{s}({len(c)}d)' for s, c in series.items())}\n")
|
||||
|
||||
# 1) dislocation frequency per pair
|
||||
print("--- dislocation magnitude (|close-1|) per pair ---")
|
||||
print(f"{'pair':>9} {'days':>5} {'med_bp':>7} {'p95_bp':>7} {'max_bp':>8} {'>10bp%':>7} {'>50bp%':>7}")
|
||||
for s, c in series.items():
|
||||
d = np.abs(c - 1.0) * 1e4
|
||||
print(f"{s:>9} {len(c):>5} {np.median(d):>7.1f} {np.percentile(d,95):>7.1f} {d.max():>8.0f} "
|
||||
f"{100*np.mean(d>10):>6.1f}% {100*np.mean(d>50):>6.1f}%")
|
||||
|
||||
# 2) reversion edge, pooled across pairs, by (thresh, H, cost)
|
||||
print("\n--- reversion edge (pooled all pairs); pnl in bp/trade, net of round-trip cost ---")
|
||||
print(f"{'thr_bp':>6} {'H':>2} {'cost':>5} {'trades':>7} {'mean_bp':>8} {'hit%':>6} {'worst_bp':>9} {'daily_SR':>9}")
|
||||
for th in THRESHOLDS_BP:
|
||||
for H in HOLDS:
|
||||
for cost in COSTS_BP:
|
||||
allp = np.concatenate([reversion(c, th, H, cost) for c in series.values()]) if series else np.array([])
|
||||
if len(allp) < 10:
|
||||
continue
|
||||
sr = (allp.mean() / allp.std() * math.sqrt(365 / H)) if allp.std() > 0 else float("nan")
|
||||
print(f"{th:>6} {H:>2} {cost:>4.0f}b {len(allp):>7} {allp.mean():>+8.1f} "
|
||||
f"{100*np.mean(allp>0):>5.1f}% {allp.min():>+9.0f} {sr:>+9.2f}")
|
||||
print(" " + "-" * 60)
|
||||
|
||||
# 3) per-pair edge at a fixed mid setting (thr=25bp, H=2, cost=2bp) — is it one terminal name?
|
||||
print("\n--- per-pair edge @ thr=25bp H=2 cost=2bp (is the edge concentrated/terminal?) ---")
|
||||
print(f"{'pair':>9} {'trades':>7} {'mean_bp':>8} {'hit%':>6} {'worst_bp':>9}")
|
||||
for s, c in series.items():
|
||||
p = reversion(c, 25, 2, 2.0)
|
||||
if len(p) >= 5:
|
||||
print(f"{s:>9} {len(p):>7} {p.mean():>+8.1f} {100*np.mean(p>0):>5.1f}% {p.min():>+9.0f}")
|
||||
else:
|
||||
print(f"{s:>9} {len(p):>7} (too few events)")
|
||||
print("\nKILL if: pooled mean<=0, OR edge is one terminal name, OR |worst_bp| dwarfs mean (death-spiral tail).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
145
scripts/surfer/crypto_stablecoin_harden.py
Normal file
145
scripts/surfer/crypto_stablecoin_harden.py
Normal file
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stablecoin peg-reversion — HARDENED. Make-or-break test of the crypto-FX dislocation edge.
|
||||
|
||||
The spike (crypto_stablecoin_dislocation.py) found a real reversion edge (SR 3-4) but was blind to:
|
||||
(1) survivorship — all sampled stables reverted; the true tail is buying a cheap stable that goes to
|
||||
ZERO (UST -75%->delist). (2) overlap inflation. (3) stress slippage. (4) long/short asymmetry.
|
||||
|
||||
This hardens all four:
|
||||
* UST included (the real death-spiral). FRAX excluded (garbage prints). USTC excluded (post-death).
|
||||
* NON-OVERLAPPING event walk with explicit exit (revert / max_hold / stop-loss).
|
||||
* deviation-SCALED slippage: cost_rt = base_bp + slip_k*|deviation| (big depeg = thin book).
|
||||
* LONG-cheap (unbounded -100% tail) vs SHORT-rich (bounded) decomposed separately.
|
||||
* collapse filters compared: raw / stop-loss / skip-falling-knife(+stop).
|
||||
* per-year sign + BTC-stress correlation proxy (does it lose when crypto crashes?).
|
||||
KILL: long-side net<=0 after UST+filters, OR edge only pre-cost, OR loses concentrated in BTC crashes.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
|
||||
OUT = "data/surfer/stablecoin"
|
||||
HEALTHY = ["USDCUSDT", "FDUSDUSDT", "TUSDUSDT", "BUSDUSDT", "USDPUSDT", "SUSDUSDT", "DAIUSDT"]
|
||||
TERMINAL = ["USTUSDT"]
|
||||
DAY_MS = 86_400_000
|
||||
THRESH_BP = 50
|
||||
EXIT_BP = 10
|
||||
MAX_HOLD = 5
|
||||
BASE_BP = 2.0
|
||||
SLIP_K = 0.15 # slippage = 15% of the entry deviation (stress thinness)
|
||||
STOP = 0.03 # 3% stop-loss
|
||||
|
||||
|
||||
def fetch(sym):
|
||||
import json, time, urllib.request
|
||||
cache = f"{OUT}/{sym}.npz"
|
||||
if os.path.exists(cache):
|
||||
d = np.load(cache); return d["ts"], d["close"]
|
||||
g = lambda u: json.load(urllib.request.urlopen(urllib.request.Request(u, headers={"User-Agent": "curl/8"}), timeout=30))
|
||||
start, end = 1_546_300_800_000, int(time.time() * 1000)
|
||||
rows, cur = [], start
|
||||
for _ in range(400):
|
||||
k = g(f"https://api.binance.com/api/v3/klines?symbol={sym}&interval=1d&startTime={cur}&limit=1000")
|
||||
if not k:
|
||||
break
|
||||
rows += k; cur = k[-1][0] + DAY_MS
|
||||
if len(k) < 1000 or cur >= end:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
if not rows:
|
||||
return np.array([]), np.array([])
|
||||
d = {int(r[0]): float(r[4]) for r in rows}
|
||||
ts = np.array(sorted(d)); close = np.array([d[t] for t in ts])
|
||||
os.makedirs(OUT, exist_ok=True); np.savez(cache, ts=ts, close=close)
|
||||
return ts, close
|
||||
|
||||
|
||||
def walk(ts, close, mode):
|
||||
"""Non-overlapping reversion trades. mode: 'raw'|'stop'|'skip'. Returns list of (dir, year, net_bp, entry_dev_bp)."""
|
||||
n = len(close); d = close - 1.0; th = THRESH_BP / 1e4; ex = EXIT_BP / 1e4
|
||||
trades = []; i = 1
|
||||
while i < n - 1:
|
||||
if abs(d[i]) <= th:
|
||||
i += 1; continue
|
||||
direction = -1.0 if d[i] > 0 else 1.0 # rich->short(-1), cheap->long(+1)
|
||||
# skip-falling-knife: only buy a cheap stable if it is NOT still dropping (bounce confirmation)
|
||||
if mode == "skip" and direction > 0 and close[i] < close[i - 1]:
|
||||
i += 1; continue
|
||||
entry = close[i]; j = i + 1; stop = STOP if mode in ("stop", "skip") else 9.9
|
||||
while j < n and (j - i) <= MAX_HOLD:
|
||||
pnl = direction * (close[j] / entry - 1.0)
|
||||
if pnl <= -stop or abs(close[j] - 1.0) < ex:
|
||||
break
|
||||
j += 1
|
||||
j = min(j, n - 1)
|
||||
gross = direction * (close[j] / entry - 1.0)
|
||||
slip = (BASE_BP + SLIP_K * abs(d[i]) * 1e4) / 1e4
|
||||
yr = 1970 + int(ts[i]) // (365 * DAY_MS)
|
||||
trades.append((direction, yr, (gross - slip) * 1e4, abs(d[i]) * 1e4))
|
||||
i = j + 1
|
||||
return trades
|
||||
|
||||
|
||||
def stats(pnls):
|
||||
p = np.array(pnls)
|
||||
if len(p) < 3:
|
||||
return f"n={len(p)} (too few)"
|
||||
sr = p.mean() / p.std() * math.sqrt(365 / ((MAX_HOLD + 1) / 2)) if p.std() > 0 else float("nan")
|
||||
return f"n={len(p):>4} mean={p.mean():>+7.1f}bp hit={100*np.mean(p>0):>4.1f}% worst={p.min():>+7.0f}bp SR~{sr:>+5.2f}"
|
||||
|
||||
|
||||
def run():
|
||||
data = {}
|
||||
for s in HEALTHY + TERMINAL:
|
||||
ts, c = fetch(s)
|
||||
if len(ts) > 30:
|
||||
data[s] = (ts, c)
|
||||
print("\n===== STABLECOIN PEG-REVERSION — HARDENED (non-overlap, UST-in, slip-scaled, long/short split) =====")
|
||||
print(f"pairs: {', '.join(data)} thr={THRESH_BP}bp hold<={MAX_HOLD}d exit<{EXIT_BP}bp slip={BASE_BP}+{SLIP_K}*|dev| stop={STOP*100:.0f}%\n")
|
||||
|
||||
for mode in ("raw", "stop", "skip"):
|
||||
allt = []
|
||||
for s in data:
|
||||
allt += [(s, *t) for t in walk(*data[s], mode)]
|
||||
longs = [t[3] for t in allt if t[1] > 0]
|
||||
shorts = [t[3] for t in allt if t[1] < 0]
|
||||
ust = [t[3] for t in allt if t[0] == "USTUSDT"]
|
||||
print(f"[{mode:>4}] ALL : {stats([t[3] for t in allt])}")
|
||||
print(f" LONG(cheap, -100% tail): {stats(longs)}")
|
||||
print(f" SHORT(rich, bounded) : {stats(shorts)}")
|
||||
print(f" UST trades only : {stats(ust)}")
|
||||
print()
|
||||
|
||||
# per-year (SHORT-only, skip mode = the candidate deployable core)
|
||||
print("--- per-year SHORT-rich net mean bp (the bounded-risk core), skip mode ---")
|
||||
by_yr = {}
|
||||
for s in data:
|
||||
for t in walk(*data[s], "skip"):
|
||||
if t[0] < 0:
|
||||
by_yr.setdefault(t[1], []).append(t[2])
|
||||
print(" ".join(f"{y}:{np.mean(v):+.0f}({len(v)})" for y, v in sorted(by_yr.items()) if v))
|
||||
|
||||
# BTC-stress proxy: does the LONG side lose during BTC crashes?
|
||||
bts, btc = fetch("BTCUSDT")
|
||||
if len(btc) > 100:
|
||||
bret = {int(bts[k]) // DAY_MS: (btc[k] / btc[k - 1] - 1.0) for k in range(1, len(btc))}
|
||||
crash, calm = [], []
|
||||
for s in data:
|
||||
ts, c = data[s]
|
||||
for dirn, yr, net, dev in walk(ts, c, "skip"):
|
||||
pass
|
||||
# simpler: tag each long trade entry day with BTC 1d return
|
||||
for s in data:
|
||||
ts, c = data[s]; d = c - 1.0; th = THRESH_BP / 1e4
|
||||
for i in range(1, len(c) - 1):
|
||||
if abs(d[i]) > th and d[i] < 0: # long-cheap entries
|
||||
br = bret.get(int(ts[i]) // DAY_MS, 0.0)
|
||||
(crash if br < -0.05 else calm).append(1)
|
||||
print(f"\nLONG-cheap entries on BTC-crash days (>-5%): {len(crash)} vs calm: {len(calm)} "
|
||||
f"({100*len(crash)/max(1,len(crash)+len(calm)):.0f}% in crashes = tail-correlation flag)")
|
||||
print("\nREAD: deployable core = SHORT-rich (bounded). LONG-cheap viable only if filters tame the UST tail.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
120
scripts/surfer/crypto_stablecoin_intraday.py
Normal file
120
scripts/surfer/crypto_stablecoin_intraday.py
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stablecoin peg-reversion — INTRADAY EXECUTION validation (the make-or-break-on-fills test).
|
||||
|
||||
Daily test found short-rich reversion ~+25bp/SR4. This checks it survives REALISTIC intraday execution:
|
||||
* 1h bars, REALISTIC fills: enter at NEXT bar's OPEN (reaction lag — you can't transact at the peak),
|
||||
exit at the reverting bar's close; pay round-trip spread (calibrated from live book: ~1bp/side).
|
||||
* deviation HALF-LIFE: of rich events at hour t, what % still rich at t+1/+2/+4/+8 (do you have time?).
|
||||
* short-rich (validated, bounded) focus; long-cheap with skip-falling-knife for contrast.
|
||||
Universe = live-tradeable liquid stables only (USDC/FDUSD/TUSD/USDP; DAI/BUSD books are empty).
|
||||
KILL: edge gone after next-open fills + spread, OR deviations vanish within 1h (no reaction window).
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
|
||||
OUT = "data/surfer/stable1h"
|
||||
PAIRS = ["USDCUSDT", "FDUSDUSDT", "TUSDUSDT", "USDPUSDT"]
|
||||
HOUR_MS = 3_600_000
|
||||
THRESHES = [25, 50]
|
||||
EXIT_BP = 10
|
||||
MAX_HOLD_H = 48
|
||||
SPREAD_BP = 1.0 # per side, from live book; round-trip = 2*SPREAD_BP
|
||||
|
||||
|
||||
def fetch(sym):
|
||||
import json, time, urllib.request
|
||||
cache = f"{OUT}/{sym}.npz"
|
||||
if os.path.exists(cache):
|
||||
d = np.load(cache); return d["ts"], d["open"], d["close"]
|
||||
g = lambda u: json.load(urllib.request.urlopen(urllib.request.Request(u, headers={"User-Agent": "curl/8"}), timeout=30))
|
||||
start, end = int(time.time() * 1000) - 3 * 365 * 86_400_000, int(time.time() * 1000)
|
||||
rows, cur = [], start
|
||||
for _ in range(800):
|
||||
k = g(f"https://api.binance.com/api/v3/klines?symbol={sym}&interval=1h&startTime={cur}&limit=1000")
|
||||
if not k:
|
||||
break
|
||||
rows += k; cur = k[-1][0] + HOUR_MS
|
||||
if len(k) < 1000 or cur >= end:
|
||||
break
|
||||
time.sleep(0.04)
|
||||
if not rows:
|
||||
return np.array([]), np.array([]), np.array([])
|
||||
o = {int(r[0]): (float(r[1]), float(r[4])) for r in rows}
|
||||
ts = np.array(sorted(o)); op = np.array([o[t][0] for t in ts]); cl = np.array([o[t][1] for t in ts])
|
||||
os.makedirs(OUT, exist_ok=True); np.savez(cache, ts=ts, open=op, close=cl)
|
||||
return ts, op, cl
|
||||
|
||||
|
||||
def walk(op, cl, thresh, side, skip=False):
|
||||
"""Non-overlapping. side='short'(rich) or 'long'(cheap). enter next-bar OPEN, exit revert close, pay spread."""
|
||||
n = len(cl); d = cl - 1.0; th = thresh / 1e4; ex = EXIT_BP / 1e4; rt = 2 * SPREAD_BP / 1e4
|
||||
out = []; i = 1
|
||||
while i < n - 2:
|
||||
rich = d[i] > th; cheap = d[i] < -th
|
||||
take = (side == "short" and rich) or (side == "long" and cheap)
|
||||
if not take:
|
||||
i += 1; continue
|
||||
if skip and side == "long" and cl[i] < cl[i - 1]: # don't catch a still-falling knife
|
||||
i += 1; continue
|
||||
direction = -1.0 if rich else 1.0
|
||||
entry = op[i + 1] # realistic: fill at next bar open
|
||||
j = i + 2
|
||||
while j < n and (j - (i + 1)) <= MAX_HOLD_H:
|
||||
if abs(cl[j] - 1.0) < ex:
|
||||
break
|
||||
j += 1
|
||||
j = min(j, n - 1)
|
||||
gross = direction * (cl[j] / entry - 1.0)
|
||||
out.append((gross - rt) * 1e4)
|
||||
i = j + 1
|
||||
return np.array(out)
|
||||
|
||||
|
||||
def half_life(cl, thresh):
|
||||
d = cl - 1.0; th = thresh / 1e4
|
||||
ev = np.where(np.abs(d) > th)[0]; ev = ev[ev < len(cl) - 9]
|
||||
if len(ev) == 0:
|
||||
return None
|
||||
return {h: 100 * np.mean(np.abs(d[ev + h]) > th) for h in (1, 2, 4, 8)}
|
||||
|
||||
|
||||
def st(p):
|
||||
if len(p) < 3:
|
||||
return f"n={len(p)} (few)"
|
||||
sr = p.mean() / p.std() * math.sqrt(365 * 24 / (MAX_HOLD_H / 2)) if p.std() > 0 else float("nan")
|
||||
return f"n={len(p):>4} mean={p.mean():>+7.1f}bp hit={100*np.mean(p>0):>4.1f}% worst={p.min():>+7.0f}bp SR~{sr:>+5.2f}"
|
||||
|
||||
|
||||
def run():
|
||||
data = {}
|
||||
for s in PAIRS:
|
||||
ts, op, cl = fetch(s)
|
||||
if len(ts) > 500:
|
||||
data[s] = (op, cl, ts)
|
||||
print("\n===== STABLECOIN INTRADAY EXECUTION (1h, next-open fills, spread 2bp rt) =====")
|
||||
print(f"pairs: {', '.join(f'{s}({len(v[1])}h)' for s,v in data.items())}\n")
|
||||
|
||||
print("--- deviation HALF-LIFE: % of events still beyond threshold after H hours (reaction window) ---")
|
||||
for s, (op, cl, ts) in data.items():
|
||||
for th in THRESHES:
|
||||
hl = half_life(cl, th)
|
||||
if hl:
|
||||
print(f" {s:>9} thr{th}: t+1={hl[1]:.0f}% t+2={hl[2]:.0f}% t+4={hl[4]:.0f}% t+8={hl[8]:.0f}%")
|
||||
print()
|
||||
|
||||
for th in THRESHES:
|
||||
sh = np.concatenate([walk(d[0], d[1], th, "short") for d in data.values()]) if data else np.array([])
|
||||
lo = np.concatenate([walk(d[0], d[1], th, "long", skip=True) for d in data.values()]) if data else np.array([])
|
||||
print(f"thr={th}bp SHORT-rich : {st(sh)}")
|
||||
print(f" LONG-cheap*: {st(lo)} (*skip-falling-knife)")
|
||||
# per-pair short
|
||||
for s, (op, cl, ts) in data.items():
|
||||
print(f" {s:>9} short: {st(walk(op, cl, th, 'short'))}")
|
||||
print()
|
||||
print("READ: edge survives if SHORT-rich stays +ve net of next-open+spread AND half-life>~2h (time to act).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
126
scripts/surfer/crypto_sweep.py
Normal file
126
scripts/surfer/crypto_sweep.py
Normal file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deflated signal sweep — Batch 2 (crypto perps): broaden + robustness-check.
|
||||
|
||||
Cross-sectional, market-neutral, unit-gross signals on major Binance USDT perps. Funding
|
||||
baked into the return (Reff = log-return − daily funding; long pays positive funding). ~10bp
|
||||
taker cost on turnover. Deflated Sharpe deflated by the CUMULATIVE search (Batch1 17 + these).
|
||||
For any DEVELOP-grade signal (CPCVmed>0 & IS>0 & OOS>0): also report 2× cost + per-year Sharpe
|
||||
(regime robustness). Survivorship caveat: v1 = currently-liquid majors over history.
|
||||
"""
|
||||
import glob
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from signal_sweep import xs_weights, pnl_w, validate, sharpe_t # noqa: E402
|
||||
import torch # noqa: E402
|
||||
|
||||
PRIOR_TRIALS = 17 # Batch 1 (futures factor zoo)
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
|
||||
|
||||
MIN_FUNDCOV = float(os.environ.get("MIN_FUNDCOV", "0.0")) # filter coins by funding coverage
|
||||
MIN_DAYS = int(os.environ.get("MIN_DAYS", "0")) # ...and minimum history
|
||||
|
||||
|
||||
def load_crypto():
|
||||
syms, data = [], {}
|
||||
for p in sorted(glob.glob("data/surfer/crypto/*.npz")):
|
||||
d = np.load(p); s = p.split("/")[-1][:-4]
|
||||
fund = d["funding"].astype(float)
|
||||
if len(d["day"]) < MIN_DAYS or np.mean(fund != 0) < MIN_FUNDCOV:
|
||||
continue
|
||||
data[s] = (d["day"].astype(np.int64), d["close"].astype(float), fund)
|
||||
syms.append(s)
|
||||
syms = sorted(syms)
|
||||
days = np.array(sorted(set().union(*[set(data[s][0].tolist()) for s in syms])))
|
||||
di = {d: i for i, d in enumerate(days.tolist())}
|
||||
T, N = len(days), len(syms)
|
||||
close = np.full((T, N), np.nan); fund = np.full((T, N), np.nan)
|
||||
for j, s in enumerate(syms):
|
||||
dd, cc, ff = data[s]
|
||||
for k in range(len(dd)):
|
||||
r = di[int(dd[k])]; close[r, j] = cc[k]; fund[r, j] = ff[k]
|
||||
return syms, days, close, fund
|
||||
|
||||
|
||||
def trailing(lc, L):
|
||||
out = np.full_like(lc, np.nan); out[L:] = lc[L:] - lc[:-L]; return out
|
||||
|
||||
|
||||
def zc(x):
|
||||
mu = np.nanmean(x, axis=1, keepdims=True); sd = np.nanstd(x, axis=1, keepdims=True)
|
||||
return np.nan_to_num((x - mu) / np.where(sd > 0, sd, 1))
|
||||
|
||||
|
||||
def main():
|
||||
syms, days, close, fund = load_crypto()
|
||||
T, N = close.shape
|
||||
lc = np.log(close)
|
||||
R = np.zeros((T, N)); R[1:] = lc[1:] - lc[:-1]; R = np.where(np.isfinite(R), R, 0.0)
|
||||
fund = np.where(np.isfinite(fund), fund, 0.0)
|
||||
Reff = R - fund
|
||||
vol30 = np.full_like(lc, np.nan)
|
||||
for t in range(30, T):
|
||||
vol30[t] = np.nanstd(R[t - 30:t], axis=0)
|
||||
|
||||
def tmf(K): # trailing mean funding over K days
|
||||
out = np.full_like(fund, np.nan)
|
||||
for t in range(K, T):
|
||||
out[t] = np.nanmean(fund[t - K:t], axis=0)
|
||||
return out
|
||||
|
||||
f7, f3, f14 = tmf(7), tmf(3), tmf(14)
|
||||
W = {} # name -> weights[T,N]
|
||||
for K in [3, 7, 14, 30]:
|
||||
W[f"XS_carry_{K}"] = xs_weights(-tmf(K))
|
||||
for L in [7, 30, 90]:
|
||||
W[f"XS_mom_{L}"] = xs_weights(trailing(lc, L))
|
||||
W["XS_rev_3"] = xs_weights(-trailing(lc, 3))
|
||||
W["XS_fundmom"] = xs_weights(f3 - f14) # rising funding (positioning building)
|
||||
W["XS_lowvol"] = xs_weights(-vol30)
|
||||
W["XS_carry+mom30"] = xs_weights(zc(-f7) + zc(trailing(lc, 30)))
|
||||
W["XS_carry+rev3"] = xs_weights(zc(-f7) + zc(-trailing(lc, 3)))
|
||||
W["XS_carry+lowvol"] = xs_weights(zc(-f7) + zc(-vol30))
|
||||
|
||||
NT = PRIOR_TRIALS + len(W)
|
||||
year = (1970 + days / 365.25).astype(int)
|
||||
rows = []
|
||||
pnls = {}
|
||||
for nm, w in W.items():
|
||||
pnl = pnl_w(w, Reff, cost_bp=10)
|
||||
pnls[nm] = (w, pnl)
|
||||
rows.append((nm, validate(pnl, days, NT)))
|
||||
rows.sort(key=lambda r: -(r[1]["oos"] if not math.isnan(r[1]["oos"]) else -9))
|
||||
print(f"\n===== CRYPTO SWEEP (broadened) — {N} perps, {T}d ({days.min()}..{days.max()}), fundcov={np.mean(fund!=0):.2f} =====")
|
||||
print(f"N_trials(cumulative deflation) = {NT}")
|
||||
print(f"{'signal':>16} {'full':>6} {'IS':>6} {'OOS':>6} {'CPCVmed':>8} {'5%':>6} {'DSR':>5}")
|
||||
for nm, v in rows:
|
||||
print(f"{nm:>16} {v['full']:>+6.2f} {v['is_']:>+6.2f} {v['oos']:>+6.2f} {v['med']:>+8.2f} {v['p5']:>+6.2f} {v['dsr']:>5.2f}")
|
||||
|
||||
print("\n----- ROBUSTNESS for develop-grade signals (CPCVmed>0 & IS>0 & OOS>0) -----")
|
||||
yrs = sorted(set(year[1:].tolist()))
|
||||
print(f"{'signal':>16} {'full10bp':>9} {'full20bp':>9} | per-year Sharpe: " + " ".join(f"{y}" for y in yrs))
|
||||
for nm, v in rows:
|
||||
if v["med"] > 0 and v["is_"] > 0 and v["oos"] > 0:
|
||||
w, pnl = pnls[nm]
|
||||
pnl2 = pnl_w(w, Reff, cost_bp=20)
|
||||
s10 = sharpe_t(torch.tensor(pnl, device=DEV, dtype=torch.float64))
|
||||
s20 = sharpe_t(torch.tensor(pnl2, device=DEV, dtype=torch.float64))
|
||||
py = []
|
||||
for y in yrs:
|
||||
m = year[1:] == y
|
||||
py.append(sharpe_t(torch.tensor(pnl[m], device=DEV, dtype=torch.float64)) if m.sum() > 30 else float("nan"))
|
||||
print(f"{nm:>16} {s10:>+9.2f} {s20:>+9.2f} | " + " ".join(f"{p:>+5.2f}" if not math.isnan(p) else " n/a" for p in py))
|
||||
|
||||
surv = [nm for nm, v in rows if v["dsr"] > 0.95 and v["oos"] > 0 and v["med"] > 0]
|
||||
print(f"\nDEPLOY survivors (DSR>0.95 & OOS>0 & CPCVmed>0): {surv if surv else 'NONE'}")
|
||||
print("Develop-grade = robust real edge worth building; deploy-grade = DSR>0.95 (harsh, deflated by all trials).")
|
||||
print("Caveat: survivorship (current majors); confirm point-in-time next.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
120
scripts/surfer/crypto_trend_sizing.py
Normal file
120
scripts/surfer/crypto_trend_sizing.py
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Crypto TS-trend sizing — reconfirm the validated return engine + find the deployable CAGR.
|
||||
|
||||
The TSMOM crypto sleeve is VALIDATED (memory: Sharpe ~1.23, CAGR 75.7% @ 61% vol, corr~0 to funding).
|
||||
The 76% CAGR is just 61%-vol sizing, not a free lunch. This re-runs the SAME pre-registered config
|
||||
(LONG/FLAT, lookbacks 20/60/120, on crypto_pit daily close, liquidity floor on qvol) and sweeps a
|
||||
vol-target overlay to answer the #2 question: at a SANE vol target, what is the deployable CAGR and
|
||||
does the Sharpe hold (it should — vol-target is a rescale, but per-period sizing adds timing).
|
||||
|
||||
NOT a search: the 20/60/120 long/flat config is replicated verbatim from the validated harness.
|
||||
"""
|
||||
import glob
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
|
||||
PIT = "data/surfer/crypto_pit"
|
||||
LOOKBACKS = [20, 60, 120]
|
||||
COST_BP = 5.0
|
||||
ADV_FLOOR = 5e6 # $5M trailing quote-volume liquidity floor
|
||||
VOL_TARGETS = [0.10, 0.15, 0.20]
|
||||
LEV_CAP = 3.0
|
||||
PPY = 365 # crypto trades every day
|
||||
|
||||
|
||||
def build():
|
||||
days = set()
|
||||
raw = {}
|
||||
for f in sorted(glob.glob(f"{PIT}/*.npz")):
|
||||
d = np.load(f)
|
||||
if len(d["day"]) < 130:
|
||||
continue
|
||||
sym = f.split("/")[-1][:-4]
|
||||
raw[sym] = {int(dd): (c, q) for dd, c, q in zip(d["day"], d["close"], d["qvol"])}
|
||||
days.update(int(x) for x in d["day"])
|
||||
days = sorted(days)
|
||||
di = {d: i for i, d in enumerate(days)}
|
||||
syms = sorted(raw)
|
||||
C = np.full((len(days), len(syms)), np.nan)
|
||||
V = np.full((len(days), len(syms)), np.nan)
|
||||
for j, s in enumerate(syms):
|
||||
for dd, (c, q) in raw[s].items():
|
||||
C[di[dd], j] = c; V[di[dd], j] = q
|
||||
return np.array(days), syms, C, V
|
||||
|
||||
|
||||
def maxdd(equity):
|
||||
peak = np.maximum.accumulate(equity)
|
||||
return float((equity / peak - 1).min())
|
||||
|
||||
|
||||
def run():
|
||||
days, syms, C, V = build()
|
||||
T, N = C.shape
|
||||
logC = np.log(C)
|
||||
ret = np.full((T, N), np.nan)
|
||||
ret[1:] = C[1:] / C[:-1] - 1.0
|
||||
# long/flat signal = mean over lookbacks of 1[trailing-L log return > 0]
|
||||
sig = np.zeros((T, N))
|
||||
cnt = np.zeros((T, N))
|
||||
for L in LOOKBACKS:
|
||||
tr = np.full((T, N), np.nan)
|
||||
tr[L:] = logC[L:] - logC[:-L]
|
||||
on = np.where(np.isfinite(tr), (tr > 0).astype(float), np.nan)
|
||||
m = np.isfinite(on)
|
||||
sig[m] += on[m]; cnt[m] += 1
|
||||
sig = np.where(cnt > 0, sig / np.maximum(cnt, 1), 0.0) # 0..1 long/flat conviction
|
||||
|
||||
print(f"\n===== CRYPTO TS-TREND SIZING (long/flat {LOOKBACKS}, ${ADV_FLOOR/1e6:.0f}M ADV floor, {COST_BP}bp/leg) =====")
|
||||
print(f"panel: {T} days x {N} coins (survivorship-free incl. dead)\n")
|
||||
|
||||
# base book (gross 1, daily rebalance, equal-weight by conviction among liquid+on coins)
|
||||
w_prev = np.zeros(N)
|
||||
book = np.zeros(T)
|
||||
for t in range(1, T):
|
||||
eligible = np.isfinite(ret[t]) & np.isfinite(V[t - 1]) & (V[t - 1] > ADV_FLOOR)
|
||||
s = sig[t - 1] * eligible
|
||||
g = s.sum()
|
||||
w = s / g if g > 0 else np.zeros(N)
|
||||
turn = np.abs(w - w_prev).sum()
|
||||
book[t] = float(np.nansum(w * ret[t])) - turn * COST_BP / 1e4
|
||||
w_prev = w
|
||||
|
||||
valid = np.arange(T) > max(LOOKBACKS)
|
||||
base = book[valid]
|
||||
base_sr = base.mean() / base.std() * math.sqrt(PPY) if base.std() > 0 else float("nan")
|
||||
base_vol = base.std() * math.sqrt(PPY)
|
||||
base_cagr = float(np.prod(1 + base) ** (PPY / len(base)) - 1)
|
||||
print(f"BASE (un-vol-targeted): Sharpe {base_sr:+.2f} vol {base_vol*100:.0f}% CAGR {base_cagr*100:+.1f}% maxDD {maxdd(np.cumprod(1+base))*100:.1f}%")
|
||||
print(f" (memory reference: Sharpe ~1.23, CAGR ~76% @ ~61% vol — replicating)\n")
|
||||
|
||||
print(f"{'vol_tgt':>8} {'Sharpe':>7} {'CAGR':>7} {'real_vol':>9} {'maxDD':>7} {'avg_lev':>8} {'per-year SR':>30}")
|
||||
print("-" * 86)
|
||||
# vol-target overlay on the base book (trailing 30d realized, lagged, leverage-capped)
|
||||
rv = np.full(T, np.nan)
|
||||
for t in range(31, T):
|
||||
w = book[t - 30:t]
|
||||
rv[t] = w.std() * math.sqrt(PPY)
|
||||
yrs = 1970 + days // 365
|
||||
for vt in VOL_TARGETS:
|
||||
lev = np.where(np.isfinite(rv) & (rv > 0), np.clip(vt / rv, 0, LEV_CAP), 0.0)
|
||||
tgt = book * np.concatenate([[0], lev[:-1]]) # lag leverage by 1 day
|
||||
s = tgt[valid]
|
||||
sr = s.mean() / s.std() * math.sqrt(PPY) if s.std() > 0 else float("nan")
|
||||
vol = s.std() * math.sqrt(PPY)
|
||||
cagr = float(np.prod(1 + s) ** (PPY / len(s)) - 1)
|
||||
dd = maxdd(np.cumprod(1 + s))
|
||||
avglev = float(np.mean(lev[valid]))
|
||||
yv = {}
|
||||
for r, y in zip(s, yrs[valid]):
|
||||
yv.setdefault(int(y), []).append(r)
|
||||
ystr = " ".join(f"{y}:{(np.mean(v)/(np.std(v)+1e-12)*math.sqrt(PPY)):+.1f}"
|
||||
for y, v in sorted(yv.items()) if len(v) >= 30)
|
||||
print(f"{vt*100:>6.0f}% {sr:>+7.2f} {cagr*100:>+6.1f}% {vol*100:>8.0f}% {dd*100:>+6.1f}% {avglev:>8.2f} {ystr}")
|
||||
print("-" * 86)
|
||||
print("Deployable read: pick the vol target whose maxDD you can stomach; Sharpe should ~hold across targets.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
93
scripts/surfer/energy_battery_gate.py
Normal file
93
scripts/surfer/energy_battery_gate.py
Normal file
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""First energy gate: is battery arbitrage a real, persistent, engine-suited edge?
|
||||
|
||||
Free DE-LU day-ahead hourly prices (Fraunhofer Energy-Charts, no key). For a 4h/1MW battery
|
||||
(1 cycle/day, 85% round-trip): daily arbitrage value under PERFECT FORESIGHT (upper bound) vs a
|
||||
NAIVE fixed-hours heuristic (charge night / discharge evening). The gap = the value forecasting/RL
|
||||
could add. Annualize vs ~e50-80k/yr/MW needed to justify a battery. Trend = is it being competed away?
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
import numpy as np
|
||||
|
||||
CACHE = "data/surfer/energy"
|
||||
ETA = 0.85 # round-trip efficiency
|
||||
HRS = 4 # 4h battery, 1 MW -> 4 MWh / cycle
|
||||
ed = math.sqrt(ETA)
|
||||
|
||||
|
||||
def get(u):
|
||||
for attempt in range(6):
|
||||
try:
|
||||
return json.load(urllib.request.urlopen(urllib.request.Request(u, headers={"User-Agent": "curl/8"}), timeout=60))
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 429:
|
||||
time.sleep(8 * (attempt + 1)); continue
|
||||
raise
|
||||
raise RuntimeError("rate-limited after retries")
|
||||
|
||||
|
||||
def fetch():
|
||||
os.makedirs(CACHE, exist_ok=True)
|
||||
ts, px = [], []
|
||||
for yr in range(2019, 2026):
|
||||
f = f"{CACHE}/de_{yr}.json"
|
||||
if os.path.exists(f):
|
||||
d = json.load(open(f))
|
||||
else:
|
||||
d = get(f"https://api.energy-charts.info/price?bzn=DE-LU&start={yr}-01-01&end={yr}-12-31")
|
||||
json.dump({"unix_seconds": d.get("unix_seconds", []), "price": d.get("price", [])}, open(f, "w"))
|
||||
time.sleep(5) # space requests (avoid 429)
|
||||
ts += d["unix_seconds"]; px += d["price"]
|
||||
return np.array(ts), np.array(px, dtype=float)
|
||||
|
||||
|
||||
def main():
|
||||
ts, px = fetch()
|
||||
ok = np.isfinite(px)
|
||||
ts, px = ts[ok], px[ok]
|
||||
day = np.array([datetime.datetime.utcfromtimestamp(int(t)).date().toordinal() for t in ts])
|
||||
udays = np.unique(day)
|
||||
rows, dord = [], []
|
||||
for d in udays:
|
||||
h = px[day == d]
|
||||
if len(h) == 24: # drop DST-transition days (23/25h)
|
||||
rows.append(h); dord.append(d)
|
||||
P = np.array(rows); D = np.array(dord) # [days, 24]
|
||||
yr = np.array([datetime.date.fromordinal(int(x)).year for x in D])
|
||||
print(f"DE day-ahead: {len(P)} clean days ({datetime.date.fromordinal(int(D[0]))}..{datetime.date.fromordinal(int(D[-1]))})")
|
||||
|
||||
# perfect-foresight 4h arbitrage per day (1 MW): discharge top-4 hrs, charge bottom-4 hrs
|
||||
srt = np.sort(P, axis=1)
|
||||
fore = srt[:, -HRS:].sum(1) * ed - srt[:, :HRS].sum(1) / ed # EUR/day per MW
|
||||
# naive fixed-hours heuristic: charge 02-05h, discharge 18-21h (typical shape)
|
||||
naive = P[:, 18:22].sum(1) * ed - P[:, 2:6].sum(1) / ed
|
||||
spread = P.max(1) - P.min(1)
|
||||
|
||||
def stats(name, v):
|
||||
ann = np.mean(v) * 365
|
||||
print(f" {name:>16}: e{np.mean(v):6.1f}/day/MW -> e{ann/1000:6.1f}k/yr/MW (median e{np.median(v):.1f}/day)")
|
||||
|
||||
print(f"\n===== BATTERY ARBITRAGE ({HRS}h/1MW, {int(ETA*100)}% round-trip) =====")
|
||||
print(f"daily price spread: mean e{spread.mean():.1f}/MWh median e{np.median(spread):.1f} (negative-price days: {100*np.mean(P.min(1)<0):.0f}%)")
|
||||
stats("perfect-foresight", fore)
|
||||
stats("naive fixed-hours", naive)
|
||||
print(f" forecasting/RL gap (foresight-naive): e{(fore.mean()-naive.mean())*365/1000:.1f}k/yr/MW "
|
||||
f"= {100*(fore.mean()-naive.mean())/fore.mean():.0f}% of the value")
|
||||
print(f"\n viability: a 4h/1MW battery ~e400-600k capex; needs ~e50-80k/yr/MW arbitrage over 10y.")
|
||||
print(f" per-year perfect-foresight arb value (e k/yr/MW):")
|
||||
for y in range(2019, 2026):
|
||||
m = yr == y
|
||||
if m.sum() > 100:
|
||||
print(f" {y}: e{np.mean(fore[m])*365/1000:5.1f}k (naive e{np.mean(naive[m])*365/1000:.1f}k)")
|
||||
print("\nVERDICT: foresight value >> e50-80k threshold = battery arb economically real; big foresight-naive gap")
|
||||
print("= forecasting/RL adds real value (the engine's role); persistent across years = not yet competed away.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
102
scripts/surfer/energy_capture_gate.py
Normal file
102
scripts/surfer/energy_capture_gate.py
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Realistic-capture gate: how much of perfect-foresight battery value does a real day-ahead
|
||||
forecast + dispatch capture? (The make-or-break for the energy pivot.)
|
||||
|
||||
Day-ahead model: commit tomorrow's charge/discharge schedule from a FORECAST of tomorrow's 24
|
||||
prices, realize P&L against ACTUAL prices. Ladder of forecasters: persistence -> last-week ->
|
||||
seasonal climatology -> walk-forward ML (gradient boosting). Report each one's realized capture
|
||||
% of perfect foresight, EUR k/yr/MW, per-year. Leak-free (ML trained only on past, predict forward).
|
||||
"""
|
||||
import datetime
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from energy_battery_gate import fetch # cached DE prices # noqa: E402
|
||||
from sklearn.ensemble import HistGradientBoostingRegressor # noqa: E402
|
||||
|
||||
ETA = 0.85
|
||||
HRS = 4
|
||||
ed = math.sqrt(ETA)
|
||||
|
||||
|
||||
def dispatch_value(yhat_day, actual_day):
|
||||
"""Schedule from forecast (charge HRS cheapest, discharge HRS priciest), realize on ACTUAL."""
|
||||
ch = np.argsort(yhat_day)[:HRS]
|
||||
dis = np.argsort(yhat_day)[-HRS:]
|
||||
return actual_day[dis].sum() * ed - actual_day[ch].sum() / ed
|
||||
|
||||
|
||||
def main():
|
||||
ts, px = fetch()
|
||||
ok = np.isfinite(px); ts, px = ts[ok], px[ok]
|
||||
day = np.array([datetime.datetime.utcfromtimestamp(int(t)).date().toordinal() for t in ts])
|
||||
rows, dord = [], []
|
||||
for d in np.unique(day):
|
||||
h = px[day == d]
|
||||
if len(h) == 24:
|
||||
rows.append(h); dord.append(d)
|
||||
P = np.array(rows); D = np.array(dord); ND = len(P)
|
||||
dow = np.array([datetime.date.fromordinal(int(x)).weekday() for x in D])
|
||||
month = np.array([datetime.date.fromordinal(int(x)).month for x in D])
|
||||
yr = np.array([datetime.date.fromordinal(int(x)).year for x in D])
|
||||
foresight = np.array([np.sort(P[d])[-HRS:].sum() * ed - np.sort(P[d])[:HRS].sum() / ed for d in range(ND)])
|
||||
|
||||
# ---- forecasters (each -> yhat[ND,24]; only valid from d>=7) ----
|
||||
yh = {}
|
||||
yh["persistence"] = np.roll(P, 1, axis=0)
|
||||
yh["last_week"] = np.roll(P, 7, axis=0)
|
||||
clim = np.full_like(P, np.nan) # trailing 28d climatology by weekend/weekday
|
||||
for d in range(14, ND):
|
||||
wk = dow[d] >= 5
|
||||
past = [k for k in range(max(0, d - 28), d) if (dow[k] >= 5) == wk]
|
||||
if past:
|
||||
clim[d] = P[past].mean(0)
|
||||
yh["climatology"] = clim
|
||||
|
||||
# ---- ML forecaster (walk-forward, leak-free) ----
|
||||
roll7 = np.full_like(P, np.nan)
|
||||
for d in range(7, ND):
|
||||
roll7[d] = P[d - 7:d].mean(0)
|
||||
# feature builder per (day d, hour h), causal
|
||||
def feats(d):
|
||||
y1, y7, y2 = P[d - 1], P[d - 7], P[d - 2]
|
||||
base = np.array([dow[d], month[d], 1.0 if dow[d] >= 5 else 0.0,
|
||||
y1.mean(), y1.max() - y1.min(), y7.mean(), roll7[d].mean()])
|
||||
X = np.zeros((24, 7 + 4))
|
||||
for h in range(24):
|
||||
X[h] = np.concatenate([[h], base[:1], base[1:], [y1[h], y7[h], y2[h], roll7[d, h]]])[:11]
|
||||
return X
|
||||
ml = np.full_like(P, np.nan)
|
||||
INIT, STEP = 365, 90
|
||||
Xcache = {d: feats(d) for d in range(7, ND)}
|
||||
for s in range(INIT, ND, STEP):
|
||||
e = min(s + STEP, ND)
|
||||
Xtr = np.vstack([Xcache[d] for d in range(7, s)])
|
||||
ytr = np.concatenate([P[d] for d in range(7, s)])
|
||||
gb = HistGradientBoostingRegressor(max_depth=4, max_iter=200, learning_rate=0.05,
|
||||
min_samples_leaf=50).fit(Xtr, ytr)
|
||||
for d in range(s, e):
|
||||
ml[d] = gb.predict(Xcache[d])
|
||||
yh["ML walk-fwd"] = ml
|
||||
|
||||
print(f"\n===== REALISTIC-CAPTURE GATE (DE 4h/1MW day-ahead, {ND} days) =====")
|
||||
print(f"perfect-foresight: e{foresight.mean()*365/1000:.1f}k/yr/MW")
|
||||
print(f"{'forecaster':>14} {'capture%':>8} {'EURk/yr/MW':>11} | per-year capture%")
|
||||
valid = np.arange(INIT, ND) # compare all on the ML-valid window
|
||||
for nm, yhat in yh.items():
|
||||
vv = np.array([dispatch_value(yhat[d], P[d]) for d in valid])
|
||||
fv = foresight[valid]
|
||||
cap = vv.sum() / fv.sum()
|
||||
py = " ".join(f"{y}:{100*np.array([dispatch_value(yhat[d],P[d]) for d in valid[yr[valid]==y]]).sum()/foresight[valid[yr[valid]==y]].sum():.0f}%"
|
||||
for y in range(2020, 2026) if (yr[valid] == y).sum() > 100)
|
||||
print(f"{nm:>14} {100*cap:>7.0f}% {vv.mean()*365/1000:>10.1f} | {py}")
|
||||
print("\nVERDICT: ML capture >> simple baselines AND >=60% of foresight (~e55-80k/yr/MW) = engine earns its keep.")
|
||||
print("If even ML captures little, or simple climatology already gets most, the engine adds little here.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
142
scripts/surfer/energy_capture_gate2.py
Normal file
142
scripts/surfer/energy_capture_gate2.py
Normal file
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Harder gate: does the engine beat the climatology heuristic when given the REAL price drivers
|
||||
(weather -> renewables)? And specifically on the high-volatility days where the value concentrates?
|
||||
|
||||
Adds free Open-Meteo weather (wind@100m, solar radiation, temp) for 3 German points to the
|
||||
day-ahead forecaster. Compares: climatology (the 83% champ) vs ML-price-only (76%) vs
|
||||
ML-with-weather. Capture % overall AND on the top-20% highest-spread days (where wind-drought
|
||||
spikes live and fundamentals should matter). Leak-free walk-forward. Weather@day-d is the
|
||||
forecast you'd have at decision time (day-ahead weather forecasts are ~90%+ accurate).
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from energy_battery_gate import fetch # cached prices # noqa: E402
|
||||
from sklearn.ensemble import HistGradientBoostingRegressor # noqa: E402
|
||||
|
||||
ETA = 0.85; HRS = 4; ed = math.sqrt(ETA)
|
||||
WCACHE = "data/surfer/energy/weather"
|
||||
PTS = {"north": (53.55, 9.99), "central": (50.1, 8.68), "south": (48.14, 11.58)}
|
||||
|
||||
|
||||
def wget(u):
|
||||
for a in range(6):
|
||||
try:
|
||||
return json.load(urllib.request.urlopen(urllib.request.Request(u, headers={"User-Agent": "curl/8"}), timeout=90))
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 429:
|
||||
time.sleep(10 * (a + 1)); continue
|
||||
raise
|
||||
raise RuntimeError("rate-limited")
|
||||
|
||||
|
||||
def weather():
|
||||
os.makedirs(WCACHE, exist_ok=True)
|
||||
acc = {}
|
||||
for nm, (la, lo) in PTS.items():
|
||||
f = f"{WCACHE}/{nm}.json"
|
||||
if os.path.exists(f):
|
||||
d = json.load(open(f))
|
||||
else:
|
||||
u = (f"https://archive-api.open-meteo.com/v1/archive?latitude={la}&longitude={lo}"
|
||||
f"&start_date=2019-01-01&end_date=2025-09-29"
|
||||
f"&hourly=wind_speed_100m,shortwave_radiation,temperature_2m&timezone=UTC")
|
||||
d = wget(u)["hourly"]; json.dump(d, open(f, "w")); time.sleep(3)
|
||||
acc[nm] = d
|
||||
return acc
|
||||
|
||||
|
||||
def dispatch(yhat, actual):
|
||||
ch = np.argsort(yhat)[:HRS]; dis = np.argsort(yhat)[-HRS:]
|
||||
return actual[dis].sum() * ed - actual[ch].sum() / ed
|
||||
|
||||
|
||||
def main():
|
||||
ts, px = fetch()
|
||||
ok = np.isfinite(px); ts, px = ts[ok], px[ok]
|
||||
pday = np.array([datetime.datetime.utcfromtimestamp(int(t)).date().toordinal() for t in ts])
|
||||
phour = np.array([datetime.datetime.utcfromtimestamp(int(t)).hour for t in ts])
|
||||
# weather aligned to (ordinal_day, hour), averaged over 3 points
|
||||
W = weather()
|
||||
wind = {}; sol = {}; tmp = {}
|
||||
for nm, d in W.items():
|
||||
for i, tstr in enumerate(d["time"]):
|
||||
dt = datetime.datetime.strptime(tstr, "%Y-%m-%dT%H:%M")
|
||||
k = (dt.date().toordinal(), dt.hour)
|
||||
wind.setdefault(k, []).append(d["wind_speed_100m"][i] or 0.0)
|
||||
sol.setdefault(k, []).append(d["shortwave_radiation"][i] or 0.0)
|
||||
tmp.setdefault(k, []).append(d["temperature_2m"][i] or 0.0)
|
||||
|
||||
rows, dord = [], []
|
||||
for dd in np.unique(pday):
|
||||
h = px[pday == dd]
|
||||
if len(h) == 24 and all((dd, hr) in wind for hr in range(24)):
|
||||
rows.append(h); dord.append(dd)
|
||||
P = np.array(rows); D = np.array(dord); ND = len(P)
|
||||
Wd = np.array([[np.mean(wind[(d, h)]) for h in range(24)] for d in D])
|
||||
Sd = np.array([[np.mean(sol[(d, h)]) for h in range(24)] for d in D])
|
||||
Td = np.array([[np.mean(tmp[(d, h)]) for h in range(24)] for d in D])
|
||||
dow = np.array([datetime.date.fromordinal(int(x)).weekday() for x in D])
|
||||
month = np.array([datetime.date.fromordinal(int(x)).month for x in D])
|
||||
yr = np.array([datetime.date.fromordinal(int(x)).year for x in D])
|
||||
fore = np.array([np.sort(P[d])[-HRS:].sum() * ed - np.sort(P[d])[:HRS].sum() / ed for d in range(ND)])
|
||||
spread = P.max(1) - P.min(1)
|
||||
print(f"DE prices+weather aligned: {ND} days")
|
||||
|
||||
roll7 = np.full_like(P, np.nan)
|
||||
for d in range(7, ND):
|
||||
roll7[d] = P[d - 7:d].mean(0)
|
||||
|
||||
def feats(d, with_w):
|
||||
y1, y7 = P[d - 1], P[d - 7]
|
||||
X = np.zeros((24, 12 if with_w else 8))
|
||||
for h in range(24):
|
||||
f = [h, dow[d], month[d], y1[h], y7[h], y1.mean(), y7.mean(), roll7[d, h]]
|
||||
if with_w:
|
||||
f += [Wd[d, h], Sd[d, h], Td[d, h], Wd[d].mean()]
|
||||
X[h] = f
|
||||
return X
|
||||
|
||||
INIT, STEP = 365, 90
|
||||
yhat = {"price_only": np.full_like(P, np.nan), "with_weather": np.full_like(P, np.nan)}
|
||||
for with_w, key in [(False, "price_only"), (True, "with_weather")]:
|
||||
Xc = {d: feats(d, with_w) for d in range(7, ND)}
|
||||
for s in range(INIT, ND, STEP):
|
||||
e = min(s + STEP, ND)
|
||||
Xtr = np.vstack([Xc[d] for d in range(7, s)]); ytr = np.concatenate([P[d] for d in range(7, s)])
|
||||
gb = HistGradientBoostingRegressor(max_depth=4, max_iter=200, learning_rate=0.05, min_samples_leaf=50).fit(Xtr, ytr)
|
||||
for d in range(s, e):
|
||||
yhat[key][d] = gb.predict(Xc[d])
|
||||
# climatology champ
|
||||
clim = np.full_like(P, np.nan)
|
||||
for d in range(14, ND):
|
||||
wk = dow[d] >= 5
|
||||
past = [k for k in range(max(0, d - 28), d) if (dow[k] >= 5) == wk]
|
||||
if past:
|
||||
clim[d] = P[past].mean(0)
|
||||
yhat["climatology"] = clim
|
||||
|
||||
valid = np.arange(INIT, ND)
|
||||
hi = valid[spread[valid] >= np.quantile(spread[valid], 0.80)] # top-20% volatile days
|
||||
print(f"\n===== HARDER CAPTURE GATE (fundamentals + volatile-day focus), {len(valid)} days =====")
|
||||
print(f"perfect-foresight e{fore.mean()*365/1000:.1f}k/yr/MW | top-20%-vol days hold {100*fore[hi].sum()/fore[valid].sum():.0f}% of value")
|
||||
print(f"{'forecaster':>14} {'capture%':>8} {'EURk/yr':>8} {'capture% on HI-VOL days':>24}")
|
||||
for nm in ["climatology", "price_only", "with_weather"]:
|
||||
yh = yhat[nm]
|
||||
vv = np.array([dispatch(yh[d], P[d]) for d in valid]); cap = vv.sum() / fore[valid].sum()
|
||||
vh = np.array([dispatch(yh[d], P[d]) for d in hi]); caph = vh.sum() / fore[hi].sum()
|
||||
print(f"{nm:>14} {100*cap:>7.0f}% {vv.mean()*365/1000:>7.1f} {100*caph:>23.0f}%")
|
||||
print("\nVERDICT: with_weather > climatology (esp on HI-VOL days) = engine+fundamentals earns its keep.")
|
||||
print("If climatology still wins, even fundamentals don't beat the heuristic for day-ahead -> engine's home is elsewhere (intraday/multi-market).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
98
scripts/surfer/energy_intraday_gate.py
Normal file
98
scripts/surfer/energy_intraday_gate.py
Normal file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Intraday gate: on CAISO real-time prices (less seasonal, forecast-error-driven), does the
|
||||
ENGINE finally beat the heuristics — unlike day-ahead, where climatology won?
|
||||
|
||||
Tests: (1) is RT more volatile than DA (more battery value)? (2) RT-price forecastability:
|
||||
baseline "RT=DA" vs climatology vs ML(walk-fwd, features incl. DA price + DART lags) ->
|
||||
capture % of perfect-foresight RT battery value. If ML >> baselines on RT, the engine earns
|
||||
its keep in the less-seasonal market. If nothing beats "RT=DA", intraday deviations are noise.
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from sklearn.ensemble import HistGradientBoostingRegressor # noqa: E402
|
||||
|
||||
ETA = 0.85; HRS = 4; ed = math.sqrt(ETA)
|
||||
OUT = "data/surfer/caiso"
|
||||
|
||||
|
||||
def load():
|
||||
dam = json.load(open(f"{OUT}/dam.json")); rtm = json.load(open(f"{OUT}/rtm.json"))
|
||||
keys = sorted(set(dam) & set(rtm)) # "YYYY-MM-DDHHH"
|
||||
# group into days with full 24h
|
||||
byday = {}
|
||||
for k in keys:
|
||||
d, h = k[:10], int(k[-2:])
|
||||
byday.setdefault(d, {})[h] = (dam[k], rtm[k])
|
||||
rows, dord = [], []
|
||||
for d in sorted(byday):
|
||||
if len(byday[d]) == 24:
|
||||
da = [byday[d][h][0] for h in range(1, 25)]
|
||||
rt = [byday[d][h][1] for h in range(1, 25)]
|
||||
rows.append((da, rt)); dord.append(d)
|
||||
DA = np.array([r[0] for r in rows]); RT = np.array([r[1] for r in rows])
|
||||
D = [datetime.date.fromisoformat(x) for x in dord]
|
||||
return DA, RT, D
|
||||
|
||||
|
||||
def dispatch(yhat, actual):
|
||||
ch = np.argsort(yhat)[:HRS]; dis = np.argsort(yhat)[-HRS:]
|
||||
return actual[dis].sum() * ed - actual[ch].sum() / ed
|
||||
|
||||
|
||||
def main():
|
||||
DA, RT, D = load()
|
||||
ND = len(DA)
|
||||
dow = np.array([d.weekday() for d in D]); month = np.array([d.month for d in D])
|
||||
sprDA = DA.max(1) - DA.min(1); sprRT = RT.max(1) - RT.min(1)
|
||||
foreDA = np.array([np.sort(DA[d])[-HRS:].sum() * ed - np.sort(DA[d])[:HRS].sum() / ed for d in range(ND)])
|
||||
foreRT = np.array([np.sort(RT[d])[-HRS:].sum() * ed - np.sort(RT[d])[:HRS].sum() / ed for d in range(ND)])
|
||||
print(f"CAISO NP15 {ND} days ({D[0]}..{D[-1]})")
|
||||
print(f"daily spread: DA mean ${sprDA.mean():.0f}/MWh RT mean ${sprRT.mean():.0f}/MWh (RT/DA = {sprRT.mean()/sprDA.mean():.1f}x)")
|
||||
print(f"perfect-foresight battery: DA ${foreDA.mean()*365/1000:.0f}k/yr/MW RT ${foreRT.mean()*365/1000:.0f}k/yr/MW")
|
||||
|
||||
# RT forecasters
|
||||
roll7 = np.full_like(RT, np.nan)
|
||||
for d in range(7, ND):
|
||||
roll7[d] = RT[d - 7:d].mean(0)
|
||||
dart = RT - DA
|
||||
def feats(d):
|
||||
X = np.zeros((24, 9))
|
||||
for h in range(24):
|
||||
X[h] = [h, dow[d], month[d], DA[d, h], RT[d - 1, h], RT[d - 7, h],
|
||||
dart[d - 1, h], DA[d].mean(), roll7[d, h]]
|
||||
return X
|
||||
ml = np.full_like(RT, np.nan); INIT, STEP = 60, 30
|
||||
Xc = {d: feats(d) for d in range(7, ND)}
|
||||
for s in range(INIT, ND, STEP):
|
||||
e = min(s + STEP, ND)
|
||||
Xtr = np.vstack([Xc[d] for d in range(7, s)]); ytr = np.concatenate([RT[d] for d in range(7, s)])
|
||||
gb = HistGradientBoostingRegressor(max_depth=4, max_iter=200, learning_rate=0.05, min_samples_leaf=40).fit(Xtr, ytr)
|
||||
for d in range(s, e):
|
||||
ml[d] = gb.predict(Xc[d])
|
||||
clim = np.full_like(RT, np.nan)
|
||||
for d in range(14, ND):
|
||||
wk = dow[d] >= 5
|
||||
past = [k for k in range(max(0, d - 28), d) if (dow[k] >= 5) == wk]
|
||||
if past:
|
||||
clim[d] = RT[past].mean(0)
|
||||
|
||||
valid = np.arange(INIT, ND)
|
||||
fc = {"baseline RT=DA": DA, "climatology": clim, "ML walk-fwd": ml}
|
||||
print(f"\n===== INTRADAY (RT) CAPTURE GATE — {len(valid)} days, perfect-foresight RT ${foreRT[valid].mean()*365/1000:.0f}k/yr/MW =====")
|
||||
print(f"{'RT forecaster':>16} {'capture%':>8} {'EURk/yr/MW':>11}")
|
||||
for nm, yh in fc.items():
|
||||
vv = np.array([dispatch(yh[d], RT[d]) for d in valid]); cap = vv.sum() / foreRT[valid].sum()
|
||||
print(f"{nm:>16} {100*cap:>7.0f}% {vv.mean()*365/1000:>10.0f}")
|
||||
print("\nVERDICT: ML capture >> 'RT=DA' baseline AND > climatology = the engine FINALLY earns its keep")
|
||||
print("(less-seasonal RT is where forecasting/RL beats heuristics). If ML ~ baselines, intraday is noise too.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
99
scripts/surfer/equity_cohort_challenge.py
Normal file
99
scripts/surfer/equity_cohort_challenge.py
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""THE CHALLENGE: does the liquid high-volatility equity cohort fit our edge structure?
|
||||
|
||||
The equity analog of crypto's reflexive cohort: LIQUID (top by dollar-vol -> low cost, avoids the
|
||||
small-cap wall) AND HIGH realized vol (reflexive, retail-driven, less fundamentally-anchored ->
|
||||
where momentum can persist, unlike efficient large-caps). The one slice where reflexivity and
|
||||
liquidity OVERLAP. Test cross-sectional momentum / reversal / low-vol AND long-only momentum
|
||||
(no borrow) within this cohort, gross + net of realistic (liquid ~10bp) cost, OOS, per-year.
|
||||
Reuses the already-downloaded DBEQ data (free).
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from equity_factor_gate import load, roll, trailing # noqa: E402
|
||||
from signal_sweep import xs_weights, validate, sharpe_t # noqa: E402
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
DV_FLOOR = 1e7 # $10M/day: liquid -> low cost (avoid the small-cap wall)
|
||||
VOL_PCTILE = 0.60 # keep names above 60th pctile realized vol (the reflexive cohort)
|
||||
COST_BP = 10.0 # liquid names: ~10bp round-trip (illiquidity wall avoided by construction)
|
||||
|
||||
|
||||
def main():
|
||||
insts, days, close, dvol = load()
|
||||
T, N = close.shape
|
||||
lc = np.log(close)
|
||||
R = np.zeros((T, N)); R[1:] = lc[1:] - lc[:-1]; R = np.where(np.isfinite(R), R, 0.0)
|
||||
dv30 = roll(np.mean, np.nan_to_num(dvol), 30)
|
||||
vol63 = roll(np.std, R, 63)
|
||||
year = (1970 + days / 365.25).astype(int)
|
||||
|
||||
# cohort: liquid AND high-vol (reflexive)
|
||||
univ = np.zeros((T, N), bool)
|
||||
for t in range(T):
|
||||
liq = (dv30[t] > DV_FLOOR) & np.isfinite(close[t]) & np.isfinite(vol63[t])
|
||||
e = np.where(liq)[0]
|
||||
if len(e) > 50:
|
||||
thr = np.quantile(vol63[t, e], VOL_PCTILE)
|
||||
univ[t, e[vol63[t, e] >= thr]] = True
|
||||
print(f"loaded {N} insts {T}d; cohort/day ~{int(univ.sum(1).mean())} (liquid>${DV_FLOOR/1e6:.0f}M & high-vol)")
|
||||
|
||||
def held_weekly(w, K=5):
|
||||
a = 2.0 / (5 + 1)
|
||||
for t in range(1, T):
|
||||
w[t] = a * w[t] + (1 - a) * w[t - 1]
|
||||
wh = w.copy(); last = 0
|
||||
for t in range(T):
|
||||
if t % K == 0:
|
||||
last = t
|
||||
wh[t] = wh[last] = w[last]
|
||||
return wh
|
||||
|
||||
def pnl_ls(sig, net=True): # long-short market-neutral
|
||||
s = sig.copy(); s[~univ] = np.nan
|
||||
w = held_weekly(xs_weights(s))
|
||||
g = np.sum(w[:-1] * R[1:], axis=1)
|
||||
if not net:
|
||||
return g
|
||||
return g - np.sum(np.abs(w[1:] - w[:-1]), axis=1) * COST_BP / 1e4
|
||||
|
||||
def pnl_long(sig, q=0.10, net=True): # long-only top-decile (no borrow)
|
||||
s = sig.copy(); s[~univ] = np.nan
|
||||
w = np.zeros((T, N))
|
||||
for t in range(T):
|
||||
e = np.where(np.isfinite(s[t]) & univ[t])[0]
|
||||
if len(e) > 20:
|
||||
k = max(int(q * len(e)), 5)
|
||||
top = e[np.argsort(-s[t, e])[:k]]; w[t, top] = 1.0 / k
|
||||
w = held_weekly(w)
|
||||
g = np.sum(w[:-1] * R[1:], axis=1)
|
||||
if net:
|
||||
g -= np.sum(np.abs(w[1:] - w[:-1]), axis=1) * COST_BP / 1e4
|
||||
return g
|
||||
|
||||
# cohort equal-weight benchmark (the beta of the cohort)
|
||||
ewb = np.array([R[t][univ[t - 1]].mean() if t > 0 and univ[t - 1].any() else 0.0 for t in range(T)])[1:]
|
||||
|
||||
T_ = lambda x: torch.tensor(x[np.isfinite(x)], device=DEV, dtype=torch.float64)
|
||||
sigs = {"mom_63_skip5": trailing(lc, 63, skip=5), "mom_126_skip5": trailing(lc, 126, skip=5),
|
||||
"reversal_5": -trailing(lc, 5), "lowvol_63": -vol63}
|
||||
print(f"\n===== LIQUID HIGH-VOL EQUITY COHORT — cross-sectional (net {COST_BP}bp) =====")
|
||||
print(f"cohort equal-weight (beta) Sharpe: {sharpe_t(T_(ewb)):+.2f}")
|
||||
print(f"{'factor':>16} {'L/S gross':>9} {'L/S NET':>8} {'OOS':>6} {'CPCVmed':>8} {'DSR':>5} {'LongOnly NET':>12} | per-year(L/S net)")
|
||||
for nm, sg in sigs.items():
|
||||
g = pnl_ls(sg, net=False); p = pnl_ls(sg, net=True); lo = pnl_long(sg, net=True)
|
||||
v = validate(p, days, 20)
|
||||
py = " ".join(f"{y}:{sharpe_t(T_(p[year[1:]==y])):+.1f}" for y in range(2023, 2027) if (year[1:] == y).sum() > 40)
|
||||
print(f"{nm:>16} {sharpe_t(T_(g)):>+9.2f} {v['full']:>+8.2f} {v['oos']:>+6.2f} {v['med']:>+8.2f} {v['dsr']:>5.2f} {sharpe_t(T_(lo)):>+12.2f} | {py}")
|
||||
print("\nVERDICT: a factor with L/S NET full+OOS+CPCVmed>0 & DSR>0.5 in the reflexive-liquid cohort = the fit.")
|
||||
print("If momentum still negative even here, the cohort doesn't rescue it (efficiency/regime, not cost).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
125
scripts/surfer/equity_factor_gate.py
Normal file
125
scripts/surfer/equity_factor_gate.py
Normal file
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small/mid-cap US equity cross-sectional factor gate (DBEQ daily, realistic small-cap costs).
|
||||
|
||||
The less-efficient corner: exclude mega-caps (too efficient) and illiquid micro-caps
|
||||
(untradeable); keep the small/mid liquid band where your small capital is an advantage.
|
||||
Test XS momentum (12-1 style), short-term reversal, low-vol, residual momentum — GROSS and
|
||||
NET of ILLIQUIDITY-SCALED cost (small-cap spreads ~30-150bp, the honest killer). Weekly
|
||||
rebalance + smoothing. Point-in-time universe (incl delisted) -> survivorship-aware.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from signal_sweep import xs_weights, validate, sharpe_t # noqa: E402
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
DAY_NS = 86_400 * 10**9
|
||||
OUT = "data/surfer/dbeq_ohlcv1d.dbn"
|
||||
EXCLUDE_TOP = 50 # drop mega-caps (efficient)
|
||||
DV_FLOOR = 2e6 # $2M/day min (tradeable)
|
||||
TOPK = 600 # small/mid liquid band size
|
||||
|
||||
|
||||
def roll(fn, X, L):
|
||||
out = np.full_like(X, np.nan)
|
||||
for t in range(L, len(X)):
|
||||
out[t] = fn(X[t - L:t], axis=0)
|
||||
return out
|
||||
|
||||
|
||||
def trailing(lc, L, skip=0):
|
||||
out = np.full_like(lc, np.nan)
|
||||
if skip:
|
||||
out[L + skip:] = lc[L:-skip] - lc[:-(L + skip)]
|
||||
else:
|
||||
out[L:] = lc[L:] - lc[:-L]
|
||||
return out
|
||||
|
||||
|
||||
def load():
|
||||
import databento as db
|
||||
a = db.DBNStore.from_file(OUT).to_ndarray()
|
||||
iid = a["instrument_id"]; ts = a["ts_event"].astype(np.int64)
|
||||
close = a["close"].astype(np.float64) / 1e9
|
||||
vol = a["volume"].astype(np.float64)
|
||||
day = ts // DAY_NS
|
||||
days = np.unique(day); insts = np.unique(iid)
|
||||
dix = np.searchsorted(days, day); iix = np.searchsorted(insts, iid)
|
||||
T, N = len(days), len(insts)
|
||||
C = np.full((T, N), np.nan); DVOL = np.full((T, N), np.nan)
|
||||
C[dix, iix] = np.where(close > 0, close, np.nan)
|
||||
DVOL[dix, iix] = close * vol
|
||||
return insts, days, C, DVOL
|
||||
|
||||
|
||||
def main():
|
||||
insts, days, close, dvol = load()
|
||||
T, N = close.shape
|
||||
lc = np.log(close)
|
||||
R = np.zeros((T, N)); R[1:] = lc[1:] - lc[:-1]; R = np.where(np.isfinite(R), R, 0.0)
|
||||
dv30 = roll(np.mean, np.nan_to_num(dvol), 30)
|
||||
vol63 = roll(np.std, R, 63)
|
||||
year = (1970 + days / 365.25).astype(int)
|
||||
print(f"loaded {N} instruments, {T} days ({days.min()}..{days.max()})")
|
||||
|
||||
# point-in-time small/mid-cap universe: drop top mega-caps, require liquidity floor, take band
|
||||
univ = np.zeros((T, N), bool)
|
||||
for t in range(T):
|
||||
elig = np.where((dv30[t] > DV_FLOOR) & np.isfinite(close[t]))[0]
|
||||
if len(elig) > EXCLUDE_TOP + 20:
|
||||
order = elig[np.argsort(-dv30[t, elig])]
|
||||
band = order[EXCLUDE_TOP:EXCLUDE_TOP + TOPK] # skip mega-caps, take next TOPK
|
||||
univ[t, band] = True
|
||||
|
||||
# illiquidity-scaled round-trip cost (bp): small-caps 30-150bp, mid 5-30bp
|
||||
rt_cost = np.clip(60.0 / np.sqrt(np.maximum(dv30, 1.0) / 1e6), 5.0, 150.0) / 1e4
|
||||
|
||||
def held_weekly(wt, K=5):
|
||||
wh = wt.copy()
|
||||
last = 0
|
||||
for t in range(T):
|
||||
if t % K == 0:
|
||||
last = t
|
||||
wh[t] = wt[last]
|
||||
return wh
|
||||
|
||||
def pnl(sig, net=True):
|
||||
s = sig.copy(); s[~univ] = np.nan
|
||||
w = xs_weights(s)
|
||||
a = 2.0 / (5 + 1) # smooth span 5
|
||||
for t in range(1, T):
|
||||
w[t] = a * w[t] + (1 - a) * w[t - 1]
|
||||
w = held_weekly(w)
|
||||
gross = np.sum(w[:-1] * R[1:], axis=1)
|
||||
if not net:
|
||||
return gross
|
||||
turn = np.abs(w[1:] - w[:-1])
|
||||
cost = np.sum(turn * rt_cost[1:], axis=1)
|
||||
return gross - cost
|
||||
|
||||
sigs = {
|
||||
"mom_63_skip5": trailing(lc, 63, skip=5),
|
||||
"mom_126_skip5": trailing(lc, 126, skip=5),
|
||||
"reversal_5": -trailing(lc, 5),
|
||||
"lowvol_63": -vol63,
|
||||
}
|
||||
print(f"\n===== SMALL/MID-CAP EQUITY FACTOR GATE (band {EXCLUDE_TOP}-{EXCLUDE_TOP+TOPK}, ${DV_FLOOR/1e6:.0f}M floor) =====")
|
||||
print(f"universe/day ~{int(univ.sum(1).mean())}, weekly rebal+smooth, deflate N=20")
|
||||
print(f"{'factor':>16} {'gross':>6} {'NET':>6} {'IS':>6} {'OOS':>6} {'CPCVmed':>8} {'DSR':>5} | per-year")
|
||||
T_ = lambda x: torch.tensor(x[np.isfinite(x)], device=DEV, dtype=torch.float64)
|
||||
for nm, sg in sigs.items():
|
||||
g = pnl(sg, net=False); p = pnl(sg, net=True)
|
||||
gsr = sharpe_t(T_(g)); v = validate(p, days, 20)
|
||||
py = " ".join(f"{y}:{sharpe_t(T_(p[year[1:]==y])):+.1f}" for y in range(2023, 2027) if (year[1:] == y).sum() > 40)
|
||||
print(f"{nm:>16} {gsr:>+6.2f} {v['full']:>+6.2f} {v['is_']:>+6.2f} {v['oos']:>+6.2f} {v['med']:>+8.2f} {v['dsr']:>5.2f} | {py}")
|
||||
print("\nVERDICT: a factor with NET full+OOS+CPCVmed>0 & DSR>0.5 survives small-cap costs = real.")
|
||||
print("gross>>NET means the edge is eaten by illiquidity cost (the usual small-cap fate).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
103
scripts/surfer/equity_lowvol_detail.py
Normal file
103
scripts/surfer/equity_lowvol_detail.py
Normal file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Decompose the equity low-vol +0.74 in detail: alpha vs beta, which leg, turnover, concentration.
|
||||
|
||||
Questions: (1) Is it cross-sectional ALPHA or a structural short-beta directional bet?
|
||||
(2) Which leg carries it — long low-vol (deployable, no borrow) or short high-vol (expensive)?
|
||||
(3) Turnover (is the cost charge fair?). (4) P&L concentration over time. (5) Beta-neutral residual.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from equity_factor_gate import load, roll # noqa: E402
|
||||
from signal_sweep import validate, sharpe_t # noqa: E402
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
DV_FLOOR, VOL_PCT, COST = 1e7, 0.60, 10.0
|
||||
|
||||
|
||||
def main():
|
||||
insts, days, close, dvol = load()
|
||||
T, N = close.shape
|
||||
lc = np.log(close)
|
||||
R = np.zeros((T, N)); R[1:] = lc[1:] - lc[:-1]; R = np.where(np.isfinite(R), R, 0.0)
|
||||
dv30 = roll(np.mean, np.nan_to_num(dvol), 30)
|
||||
vol63 = roll(np.std, R, 63)
|
||||
year = (1970 + days / 365.25).astype(int)
|
||||
T_ = lambda x: torch.tensor(np.asarray(x)[np.isfinite(np.asarray(x))], device=DEV, dtype=torch.float64)
|
||||
|
||||
univ = np.zeros((T, N), bool)
|
||||
for t in range(T):
|
||||
e = np.where((dv30[t] > DV_FLOOR) & np.isfinite(close[t]) & np.isfinite(vol63[t]))[0]
|
||||
if len(e) > 50:
|
||||
univ[t, e[vol63[t, e] >= np.quantile(vol63[t, e], VOL_PCT)]] = True
|
||||
|
||||
def held(w, K=5):
|
||||
a = 2.0 / 6
|
||||
for t in range(1, T):
|
||||
w[t] = a * w[t] + (1 - a) * w[t - 1]
|
||||
wh = w.copy(); last = 0
|
||||
for t in range(T):
|
||||
if t % K == 0:
|
||||
last = t
|
||||
wh[t] = w[last]
|
||||
return wh
|
||||
|
||||
def leg(which, q=0.10):
|
||||
"""EW long basket of a decile: 'low'=lowest-vol, 'high'=highest-vol, 'all'=whole cohort."""
|
||||
w = np.zeros((T, N))
|
||||
for t in range(T):
|
||||
e = np.where(univ[t])[0]
|
||||
if len(e) < 20:
|
||||
continue
|
||||
if which == "all":
|
||||
w[t, e] = 1.0 / len(e)
|
||||
else:
|
||||
order = e[np.argsort(vol63[t, e])] # ascending vol
|
||||
k = max(int(q * len(e)), 5)
|
||||
sel = order[:k] if which == "low" else order[-k:]
|
||||
w[t, sel] = 1.0 / k
|
||||
w = held(w)
|
||||
pnl = np.sum(w[:-1] * R[1:], axis=1) - np.sum(np.abs(w[1:] - w[:-1]), axis=1) * COST / 1e4
|
||||
turn = float(np.mean(np.sum(np.abs(w[1:] - w[:-1]), axis=1)))
|
||||
return pnl, turn
|
||||
|
||||
lo, lo_turn = leg("low")
|
||||
hi, hi_turn = leg("high")
|
||||
mkt, _ = leg("all")
|
||||
ls = lo - hi # dollar-neutral L/S (gross of the extra cost already in legs)
|
||||
sr = sharpe_t
|
||||
|
||||
print(f"\n===== EQUITY LOW-VOL +0.74 DECOMPOSITION (cohort liquid>${DV_FLOOR/1e6:.0f}M & vol>{int(VOL_PCT*100)}pct) =====")
|
||||
print(f"cohort EW (beta/market) Sharpe {sr(T_(mkt)):+.2f}")
|
||||
print(f"LONG leg (low-vol decile) Sharpe {sr(T_(lo)):+.2f} alpha-vs-cohort {sr(T_(lo-mkt)):+.2f} turn/period {lo_turn:.2f}")
|
||||
print(f"HIGH-vol decile Sharpe {sr(T_(hi)):+.2f} (short it -> +{sr(T_(mkt-hi)):+.2f} short-alpha-vs-cohort)")
|
||||
print(f"L/S (low - high) Sharpe {sr(T_(ls)):+.2f}")
|
||||
|
||||
# beta decomposition of L/S vs cohort market
|
||||
a, b = np.asarray(mkt), np.asarray(ls)
|
||||
m = np.isfinite(a) & np.isfinite(b); x, yv = a[m], b[m]
|
||||
beta = float(np.cov(x, yv)[0, 1] / (np.var(x) + 1e-12))
|
||||
resid = yv - beta * x
|
||||
print(f"\nL/S beta to cohort-market = {beta:+.2f} (negative = structural short-beta tilt)")
|
||||
print(f"L/S beta-NEUTRAL residual alpha Sharpe = {sr(T_(resid)):+.2f} "
|
||||
f"(this is the TRUE cross-sectional alpha; if ~0 it was just short-beta)")
|
||||
|
||||
# long-leg deployable check (no borrow)
|
||||
vlo = validate(lo - mkt, days, 30) # long-leg market-neutralized (long decile vs cohort)
|
||||
print(f"\nLONG-leg alpha (deployable, no borrow): full {vlo['full']:+.2f} OOS {vlo['oos']:+.2f} CPCVmed {vlo['med']:+.2f} DSR {vlo['dsr']:.2f}")
|
||||
|
||||
# per-year + concentration
|
||||
print("per-year L/S: " + " ".join(f"{y}:{sr(T_(ls[year[1:]==y])):+.2f}" for y in range(2023,2027) if (year[1:]==y).sum()>40))
|
||||
p = np.nan_to_num(ls); tot = p.sum(); top = np.sort(p)[-10:].sum()
|
||||
print(f"P&L concentration: top-10 days = {100*top/ (tot+1e-12):.0f}% of total (high => episodic/fragile)")
|
||||
print(f"turnover: low-leg {lo_turn:.2f}/period, high-leg {hi_turn:.2f}/period (low => slow signal, cost charge fair)")
|
||||
print("\nREAD: if beta-neutral residual ~0 => short-beta bet (fragile); if long-leg alpha DSR>0.5 => deployable long-only edge.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
129
scripts/surfer/equity_lowvol_gauntlet.py
Normal file
129
scripts/surfer/equity_lowvol_gauntlet.py
Normal file
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Full gauntlet on the equity low-vol/lottery-aversion lead (liquid high-vol cohort).
|
||||
|
||||
The make-or-break for the first non-crypto edge: (1) name-bootstrap (robust to WHICH names? —
|
||||
the test that killed carry), (2) cohort-param robustness (did $10M/60pct manufacture it?),
|
||||
(3) realistic short-borrow + long-only (is the edge trapped in the un-cheap short?),
|
||||
(4) honest deflation + per-year, (5) correlation to the crypto momentum book (diversifier?).
|
||||
Free — DBEQ on disk.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from equity_factor_gate import load, roll # noqa: E402
|
||||
from signal_sweep import xs_weights, validate, sharpe_t # noqa: E402
|
||||
import pit_sweep # noqa: E402
|
||||
from surfer_poc import compute_weights, CFG # noqa: E402
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
|
||||
|
||||
def main():
|
||||
insts, days, close, dvol = load()
|
||||
T, N = close.shape
|
||||
lc = np.log(close)
|
||||
R = np.zeros((T, N)); R[1:] = lc[1:] - lc[:-1]; R = np.where(np.isfinite(R), R, 0.0)
|
||||
dv30 = roll(np.mean, np.nan_to_num(dvol), 30)
|
||||
vol63 = roll(np.std, R, 63)
|
||||
year = (1970 + days / 365.25).astype(int)
|
||||
T_ = lambda x: torch.tensor(x[np.isfinite(x)], device=DEV, dtype=torch.float64)
|
||||
|
||||
def cohort(dv_floor, vol_pct):
|
||||
u = np.zeros((T, N), bool)
|
||||
for t in range(T):
|
||||
liq = (dv30[t] > dv_floor) & np.isfinite(close[t]) & np.isfinite(vol63[t])
|
||||
e = np.where(liq)[0]
|
||||
if len(e) > 50:
|
||||
u[t, e[vol63[t, e] >= np.quantile(vol63[t, e], vol_pct)]] = True
|
||||
return u
|
||||
|
||||
def smooth_weekly(w, K=5):
|
||||
a = 2.0 / 6
|
||||
for t in range(1, T):
|
||||
w[t] = a * w[t] + (1 - a) * w[t - 1]
|
||||
wh = w.copy(); last = 0
|
||||
for t in range(T):
|
||||
if t % K == 0:
|
||||
last = t
|
||||
wh[t] = w[last]
|
||||
return wh
|
||||
|
||||
def lowvol_pnl(univ, cols=None, long_only=False, borrow_ann=0.0):
|
||||
sig = (-vol63).copy(); sig[~univ] = np.nan
|
||||
if cols is not None:
|
||||
mask = np.zeros(N, bool); mask[cols] = True; sig[:, ~mask] = np.nan
|
||||
if long_only:
|
||||
w = np.zeros((T, N))
|
||||
for t in range(T):
|
||||
e = np.where(np.isfinite(sig[t]))[0]
|
||||
if len(e) > 20:
|
||||
k = max(int(0.10 * len(e)), 5); w[t, e[np.argsort(-sig[t, e])[:k]]] = 1.0 / k
|
||||
else:
|
||||
w = xs_weights(sig)
|
||||
w = smooth_weekly(w)
|
||||
g = np.sum(w[:-1] * R[1:], axis=1)
|
||||
g -= np.sum(np.abs(w[1:] - w[:-1]), axis=1) * 10.0 / 1e4 # 10bp trade cost
|
||||
if borrow_ann > 0: # borrow on short notional
|
||||
short_notional = np.sum(np.clip(-w[:-1], 0, None), axis=1)
|
||||
g -= short_notional * borrow_ann / 252.0
|
||||
return g
|
||||
|
||||
base = cohort(1e7, 0.60)
|
||||
base_pnl = lowvol_pnl(base)
|
||||
v = validate(base_pnl, days, 30)
|
||||
print(f"\n===== EQUITY LOW-VOL GAUNTLET (liquid high-vol cohort, deflate N=30) =====")
|
||||
print(f"BASE L/S: full {v['full']:+.2f} OOS {v['oos']:+.2f} CPCVmed {v['med']:+.2f} DSR {v['dsr']:.2f}")
|
||||
|
||||
# (1) name-bootstrap
|
||||
cohort_cols = np.where(base.any(0))[0]
|
||||
rng = np.random.default_rng(5); sh = []
|
||||
for _ in range(200):
|
||||
c = rng.choice(cohort_cols, size=max(len(cohort_cols) // 2, 20), replace=False)
|
||||
s = sharpe_t(T_(lowvol_pnl(base, cols=c)))
|
||||
if not math.isnan(s):
|
||||
sh.append(s)
|
||||
sh = np.array(sh)
|
||||
print(f"(1) name-bootstrap(200): frac>0 {np.mean(sh>0):.2f} median {np.median(sh):+.2f} 5th {np.percentile(sh,5):+.2f}")
|
||||
|
||||
# (2) cohort-param robustness
|
||||
print("(2) cohort-param grid (L/S full Sharpe):")
|
||||
for fl in [5e6, 1e7, 2e7, 5e7]:
|
||||
row = []
|
||||
for vp in [0.50, 0.60, 0.70]:
|
||||
row.append(sharpe_t(T_(lowvol_pnl(cohort(fl, vp)))))
|
||||
print(f" ${fl/1e6:>3.0f}M floor: " + " ".join(f"vp{int(vp*100)}:{r:+.2f}" for vp, r in zip([0.50,0.60,0.70], row)))
|
||||
|
||||
# (3) short-borrow + long-only
|
||||
print("(3) short-borrow & long-only (net):")
|
||||
for ba in [0.0, 0.10, 0.30]:
|
||||
print(f" L/S borrow {int(ba*100)}%/yr: {sharpe_t(T_(lowvol_pnl(base, borrow_ann=ba))):+.2f}")
|
||||
lo = lowvol_pnl(base, long_only=True)
|
||||
vlo = validate(lo, days, 30)
|
||||
print(f" LONG-ONLY (no borrow): full {vlo['full']:+.2f} OOS {vlo['oos']:+.2f} CPCVmed {vlo['med']:+.2f} DSR {vlo['dsr']:.2f}")
|
||||
|
||||
# (4) per-year (base L/S)
|
||||
print("(4) per-year (base L/S): " + " ".join(f"{y}:{sharpe_t(T_(base_pnl[year[1:]==y])):+.2f}" for y in range(2023,2027) if (year[1:]==y).sum()>40))
|
||||
|
||||
# (5) correlation to crypto momentum book
|
||||
syms, cdays, cc, cqv, cf = pit_sweep.load()
|
||||
cw, _ = compute_weights(cc, cqv, cdays, CFG)
|
||||
cR = np.zeros_like(cc); cR[1:] = np.log(cc)[1:] - np.log(cc)[:-1]; cR = np.where(np.isfinite(cR), cR, 0.0)
|
||||
cff = np.where(np.isfinite(cf), cf, 0.0)
|
||||
cmom = np.sum(cw[:-1] * (cR - cff)[1:], axis=1)
|
||||
cmd = {int(cdays[1:][i]): cmom[i] for i in range(len(cmom))}
|
||||
emd = {int(days[1:][i]): base_pnl[i] for i in range(len(base_pnl))}
|
||||
common = sorted(set(cmd) & set(emd))
|
||||
a = np.array([cmd[d] for d in common]); b = np.array([emd[d] for d in common])
|
||||
m = np.isfinite(a) & np.isfinite(b)
|
||||
corr = float(np.corrcoef(a[m], b[m])[0, 1]) if m.sum() > 50 else float("nan")
|
||||
print(f"(5) corr to crypto-momentum book: {corr:+.2f} (low => diversifier in a preferred market)")
|
||||
print("\nVERDICT: bootstrap frac>0~1 + grid broadly + long-only positive + DSR>0.5 + low crypto-corr = real 2nd sleeve.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
115
scripts/surfer/equity_vrp.py
Normal file
115
scripts/surfer/equity_vrp.py
Normal file
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Equity-index VRP on XSP (mini-SPX) options, 2013-2026 — the one real-prior frontier.
|
||||
|
||||
For each day: find the ~30d ATM straddle (strike where call~=put = the forward), back out
|
||||
implied vol from the straddle price, compare to forward realized vol. VRP = IV - RV; a
|
||||
short-vol seller harvests it. 13y spans 2018/2020/2022 tail events. Tests raw VRP Sharpe,
|
||||
per-year (incl. crashes), tail/skew, AND the tail-managed version (don't sell when IV rising
|
||||
— the gate that fixed crypto VRP). XSP = cash-settled European -> no early-exercise distortion.
|
||||
"""
|
||||
import glob
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from signal_sweep import sharpe_t # noqa: E402
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
DAY_NS = 86_400 * 10**9
|
||||
|
||||
|
||||
def load_xsp():
|
||||
import databento as db
|
||||
rows = []
|
||||
for p in sorted(glob.glob("data/surfer/xsp/*.dbn")):
|
||||
try:
|
||||
df = db.DBNStore.from_file(p).to_df().reset_index()
|
||||
except Exception:
|
||||
continue
|
||||
if df.empty or "symbol" not in df.columns:
|
||||
continue
|
||||
df = df[df["close"] > 0]
|
||||
rows.append(df[["ts_event", "symbol", "close"]])
|
||||
import pandas as pd
|
||||
d = pd.concat(rows, ignore_index=True)
|
||||
s = d["symbol"].astype(str).str.replace(" ", "", regex=False) # "XSP240119C00450000"
|
||||
d["right"] = s.str[-9]
|
||||
d["strike"] = s.str[-8:].astype(float) / 1000.0
|
||||
d["expiry"] = s.str[-15:-9] # YYMMDD
|
||||
d["day"] = (d["ts_event"].astype("int64") // DAY_NS)
|
||||
return d
|
||||
|
||||
|
||||
def to_epoch_day(yymmdd):
|
||||
import datetime
|
||||
y = 2000 + int(yymmdd[:2]); mo = int(yymmdd[2:4]); da = int(yymmdd[4:6])
|
||||
return (datetime.date(y, mo, da) - datetime.date(1970, 1, 1)).days
|
||||
|
||||
|
||||
def main():
|
||||
d = load_xsp()
|
||||
print(f"loaded {len(d)} XSP option-days, {d['day'].nunique()} trading days")
|
||||
d["exp_day"] = d["expiry"].map(to_epoch_day)
|
||||
d["ttm"] = d["exp_day"] - d["day"]
|
||||
d = d[(d["ttm"] >= 20) & (d["ttm"] <= 45)] # ~30d window
|
||||
|
||||
iv_by_day = {}
|
||||
S_by_day = {}
|
||||
for day, g in d.groupby("day"):
|
||||
# pick the expiry closest to 30d
|
||||
exp = g.iloc[(g["ttm"] - 30).abs().argsort()].iloc[0]["exp_day"]
|
||||
ge = g[g["exp_day"] == exp]
|
||||
calls = ge[ge["right"] == "C"].groupby("strike")["close"].mean() # dedup AM/PM series
|
||||
puts = ge[ge["right"] == "P"].groupby("strike")["close"].mean()
|
||||
common = calls.index.intersection(puts.index)
|
||||
if len(common) < 3:
|
||||
continue
|
||||
diff = (calls[common] - puts[common]).abs()
|
||||
katm = diff.idxmin() # ATM: where C~=P
|
||||
S = float(katm + calls[katm] - puts[katm]) # parity forward
|
||||
straddle = float(calls[katm] + puts[katm])
|
||||
T = float(ge["ttm"].iloc[0]) / 365.0
|
||||
if S <= 0 or T <= 0:
|
||||
continue
|
||||
iv = straddle / (0.8 * S * math.sqrt(T)) # ATM straddle -> implied vol
|
||||
iv_by_day[int(day)] = iv; S_by_day[int(day)] = S
|
||||
|
||||
days = np.array(sorted(S_by_day))
|
||||
S = np.array([S_by_day[x] for x in days]); IV = np.array([iv_by_day[x] for x in days])
|
||||
r = np.zeros(len(days)); r[1:] = np.log(S[1:] / S[:-1])
|
||||
# forward 21-day realized vol (annualized) — what the seller faces
|
||||
H = 21
|
||||
rv_fwd = np.full(len(days), np.nan)
|
||||
for t in range(len(days) - H):
|
||||
rv_fwd[t] = np.std(r[t + 1:t + 1 + H]) * math.sqrt(252)
|
||||
vrp = IV - rv_fwd # premium (positive = seller wins)
|
||||
# short-vol daily P&L proxy: collect implied variance, pay realized squared return
|
||||
iv_lag = np.concatenate([[np.nan], IV[:-1]])
|
||||
svol = (iv_lag ** 2) / 252.0 - r ** 2
|
||||
year = np.array([1970 + x / 365.25 for x in days]).astype(int)
|
||||
T_ = lambda x: torch.tensor(np.asarray(x)[np.isfinite(np.asarray(x))], device=DEV, dtype=torch.float64)
|
||||
|
||||
print(f"\n===== EQUITY-INDEX VRP (XSP, {days.min()}..{days.max()}, {len(days)} days) =====")
|
||||
print(f"mean implied vol {np.nanmean(IV):.1%} mean fwd-realized {np.nanmean(rv_fwd):.1%} "
|
||||
f"mean VRP {np.nanmean(vrp):.1%} (positive => premium exists)")
|
||||
print(f"VRP frac>0 (IV>RV): {np.nanmean(vrp[np.isfinite(vrp)]>0):.2f}")
|
||||
print(f"\nshort-vol daily P&L: Sharpe {sharpe_t(T_(svol)):+.2f} skew {float(((svol[np.isfinite(svol)]-np.nanmean(svol))**3).mean()/np.nanstd(svol)**3):+.2f} worst-day {np.nanmin(svol)/np.nanstd(svol):+.1f}σ")
|
||||
print("per-year short-vol: " + " ".join(f"{y}:{sharpe_t(T_(svol[year==y])):+.1f}" for y in range(2013, 2027) if (year == y).sum() > 60))
|
||||
|
||||
# tail-managed: don't sell when implied vol rising over 5d (the crypto-VRP gate)
|
||||
rising = np.zeros(len(days))
|
||||
for t in range(6, len(days)):
|
||||
rising[t] = 0.0 if IV[t - 1] > IV[t - 6] else 1.0
|
||||
svol_tm = rising * svol
|
||||
print(f"\ntail-managed (don't sell into rising IV): Sharpe {sharpe_t(T_(svol_tm)):+.2f} "
|
||||
f"skew {float(((svol_tm[np.isfinite(svol_tm)]-np.nanmean(svol_tm))**3).mean()/np.nanstd(svol_tm)**3):+.2f} worst {np.nanmin(svol_tm)/np.nanstd(svol_tm):+.1f}σ")
|
||||
print("per-year tail-managed: " + " ".join(f"{y}:{sharpe_t(T_(svol_tm[year==y])):+.1f}" for y in range(2013, 2027) if (year == y).sum() > 60))
|
||||
print("\nVERDICT: VRP frac>0 high + short-vol Sharpe>0 + tail-managed improves skew/recent = real, harvestable equity VRP.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
87
scripts/surfer/equity_vrp2.py
Normal file
87
scripts/surfer/equity_vrp2.py
Normal file
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Equity-index VRP, CORRECTED: clean SPY underlying (not noisy parity) + XSP option-implied IV.
|
||||
|
||||
Fixes the bug where parity-derived underlying inflated realized vol (spurious -VRP). SPY ~= XSP
|
||||
(~SPX/10), so SPY is the clean underlying for ATM reference + realized vol. Overlap 2023-2026.
|
||||
VRP = implied (XSP ATM straddle) - realized (clean SPY). Short-vol Sharpe, per-year, tail, tail-managed.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from equity_vrp import load_xsp, to_epoch_day # noqa: E402
|
||||
from signal_sweep import sharpe_t # noqa: E402
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
DAY_NS = 86_400 * 10**9
|
||||
|
||||
|
||||
def load_spy():
|
||||
import databento as db
|
||||
a = db.DBNStore.from_file("data/surfer/spy_ohlcv1d.dbn").to_ndarray()
|
||||
day = a["ts_event"].astype(np.int64) // DAY_NS
|
||||
close = a["close"].astype(np.float64) / 1e9
|
||||
return {int(day[i]): float(close[i]) for i in range(len(day)) if close[i] > 0}
|
||||
|
||||
|
||||
def main():
|
||||
spy = load_spy()
|
||||
print(f"SPY clean: {len(spy)} days ({min(spy)}..{max(spy)}), level {spy[min(spy)]:.0f}->{spy[max(spy)]:.0f}")
|
||||
d = load_xsp()
|
||||
d["exp_day"] = d["expiry"].map(to_epoch_day)
|
||||
d["ttm"] = d["exp_day"] - d["day"]
|
||||
d = d[(d["ttm"] >= 20) & (d["ttm"] <= 45)]
|
||||
d = d[d["day"].isin(spy.keys())] # overlap with clean SPY
|
||||
|
||||
iv = {}
|
||||
for day, g in d.groupby("day"):
|
||||
S = spy[int(day)]
|
||||
exp = g.iloc[(g["ttm"] - 30).abs().argsort()].iloc[0]["exp_day"]
|
||||
ge = g[g["exp_day"] == exp]
|
||||
calls = ge[ge["right"] == "C"].groupby("strike")["close"].mean()
|
||||
puts = ge[ge["right"] == "P"].groupby("strike")["close"].mean()
|
||||
common = calls.index.intersection(puts.index)
|
||||
if len(common) < 3:
|
||||
continue
|
||||
katm = common[np.abs(np.array(common) - S).argmin()] # ATM = strike nearest clean SPY level
|
||||
straddle = float(calls[katm] + puts[katm])
|
||||
T = float(ge["ttm"].iloc[0]) / 365.0
|
||||
if straddle <= 0 or T <= 0:
|
||||
continue
|
||||
iv[int(day)] = straddle / (0.8 * S * math.sqrt(T))
|
||||
|
||||
days = np.array(sorted(set(iv) & set(spy)))
|
||||
S = np.array([spy[x] for x in days]); IV = np.array([iv[x] for x in days])
|
||||
r = np.zeros(len(days)); r[1:] = np.log(S[1:] / S[:-1])
|
||||
H = 21
|
||||
rv_fwd = np.full(len(days), np.nan)
|
||||
for t in range(len(days) - H):
|
||||
rv_fwd[t] = np.std(r[t + 1:t + 1 + H]) * math.sqrt(252)
|
||||
vrp = IV - rv_fwd
|
||||
iv_lag = np.concatenate([[np.nan], IV[:-1]])
|
||||
svol = (iv_lag ** 2) / 252.0 - r ** 2
|
||||
year = np.array([1970 + x / 365.25 for x in days]).astype(int)
|
||||
T_ = lambda x: torch.tensor(np.asarray(x)[np.isfinite(np.asarray(x))], device=DEV, dtype=torch.float64)
|
||||
fin = np.isfinite(vrp)
|
||||
|
||||
print(f"\n===== EQUITY-INDEX VRP (CORRECTED, clean SPY) — {len(days)} days =====")
|
||||
print(f"mean implied {np.nanmean(IV):.1%} mean fwd-realized(clean) {np.nanmean(rv_fwd):.1%} "
|
||||
f"mean VRP {np.nanmean(vrp[fin]):+.1%} frac>0 {np.mean(vrp[fin]>0):.2f}")
|
||||
sk = float(((svol[np.isfinite(svol)] - np.nanmean(svol)) ** 3).mean() / np.nanstd(svol) ** 3)
|
||||
print(f"short-vol daily P&L: Sharpe {sharpe_t(T_(svol)):+.2f} skew {sk:+.2f} worst {np.nanmin(svol)/np.nanstd(svol):+.1f}σ")
|
||||
print("per-year: " + " ".join(f"{y}:{sharpe_t(T_(svol[year==y])):+.1f}" for y in range(2023, 2027) if (year == y).sum() > 40))
|
||||
rising = np.zeros(len(days))
|
||||
for t in range(6, len(days)):
|
||||
rising[t] = 0.0 if IV[t - 1] > IV[t - 6] else 1.0
|
||||
tm = rising * svol
|
||||
print(f"tail-managed (don't sell into rising IV): Sharpe {sharpe_t(T_(tm)):+.2f} worst {np.nanmin(tm)/np.nanstd(tm):+.1f}σ")
|
||||
print("per-year TM: " + " ".join(f"{y}:{sharpe_t(T_(tm[year==y])):+.1f}" for y in range(2023, 2027) if (year == y).sum() > 40))
|
||||
print("\nVERDICT: mean VRP>0 + frac>0~0.8 confirms premium exists; short-vol Sharpe (esp tail-managed) = harvestable.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
78
scripts/surfer/equity_vrp3.py
Normal file
78
scripts/surfer/equity_vrp3.py
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Equity-index VRP, robust internal-parity version (self-consistent, no external underlying).
|
||||
|
||||
Per day: implied forward F = MEDIAN over the 5 near-ATM strikes of (K + C - P) [put-call parity,
|
||||
median-smoothed to kill per-strike noise]. Use F consistently for ATM, implied vol, AND the
|
||||
realized-vol series. Internally consistent -> avoids both the single-strike-parity noise and the
|
||||
SPY!=XSP divergence that produced spurious negative VRP. XSP 2013-2026.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from equity_vrp import load_xsp, to_epoch_day # noqa: E402
|
||||
from signal_sweep import sharpe_t # noqa: E402
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
|
||||
|
||||
def main():
|
||||
d = load_xsp()
|
||||
d["exp_day"] = d["expiry"].map(to_epoch_day)
|
||||
d["ttm"] = d["exp_day"] - d["day"]
|
||||
d = d[(d["ttm"] >= 20) & (d["ttm"] <= 45)]
|
||||
F, IVm = {}, {}
|
||||
for day, g in d.groupby("day"):
|
||||
exp = g.iloc[(g["ttm"] - 30).abs().argsort()].iloc[0]["exp_day"]
|
||||
ge = g[g["exp_day"] == exp]
|
||||
calls = ge[ge["right"] == "C"].groupby("strike")["close"].mean()
|
||||
puts = ge[ge["right"] == "P"].groupby("strike")["close"].mean()
|
||||
common = np.array(sorted(set(calls.index) & set(puts.index)))
|
||||
if len(common) < 5:
|
||||
continue
|
||||
rough = common[np.abs(calls[common].values - puts[common].values).argmin()] # min|C-P| ~ ATM
|
||||
near = common[np.argsort(np.abs(common - rough))[:5]] # 5 nearest strikes
|
||||
f = float(np.median([k + float(calls[k]) - float(puts[k]) for k in near])) # parity forward (median)
|
||||
katm = common[np.abs(common - f).argmin()]
|
||||
straddle = float(calls[katm] + puts[katm])
|
||||
T = float(ge["ttm"].iloc[0]) / 365.0
|
||||
if f <= 0 or straddle <= 0 or T <= 0:
|
||||
continue
|
||||
F[int(day)] = f; IVm[int(day)] = straddle / (0.8 * f * math.sqrt(T))
|
||||
|
||||
days = np.array(sorted(F))
|
||||
S = np.array([F[x] for x in days]); IV = np.array([IVm[x] for x in days])
|
||||
r = np.zeros(len(days)); r[1:] = np.log(S[1:] / S[:-1])
|
||||
r = np.clip(r, -0.25, 0.25) # guard residual parity glitches
|
||||
H = 21
|
||||
rv = np.full(len(days), np.nan)
|
||||
for t in range(len(days) - H):
|
||||
rv[t] = np.std(r[t + 1:t + 1 + H]) * math.sqrt(252)
|
||||
vrp = IV - rv; fin = np.isfinite(vrp)
|
||||
iv_lag = np.concatenate([[np.nan], IV[:-1]])
|
||||
svol = (iv_lag ** 2) / 252.0 - r ** 2
|
||||
year = np.array([1970 + x / 365.25 for x in days]).astype(int)
|
||||
T_ = lambda x: torch.tensor(np.asarray(x)[np.isfinite(np.asarray(x))], device=DEV, dtype=torch.float64)
|
||||
|
||||
print(f"\n===== EQUITY-INDEX VRP (robust internal parity) — {len(days)} days ({days.min()}..{days.max()}) =====")
|
||||
print(f"implied-forward level {S[0]:.0f}->{S[-1]:.0f} (should track SPX/10 ~ 400->650)")
|
||||
print(f"mean implied {np.nanmean(IV):.1%} mean fwd-realized {np.nanmean(rv):.1%} "
|
||||
f"mean VRP {np.nanmean(vrp[fin]):+.1%} frac>0 {np.mean(vrp[fin]>0):.2f}")
|
||||
sk = float(((svol[np.isfinite(svol)] - np.nanmean(svol)) ** 3).mean() / np.nanstd(svol) ** 3)
|
||||
print(f"short-vol daily P&L: Sharpe {sharpe_t(T_(svol)):+.2f} skew {sk:+.2f} worst {np.nanmin(svol)/np.nanstd(svol):+.1f}σ")
|
||||
print("per-year: " + " ".join(f"{y}:{sharpe_t(T_(svol[year==y])):+.1f}" for y in range(2013, 2027) if (year == y).sum() > 60))
|
||||
rising = np.zeros(len(days))
|
||||
for t in range(6, len(days)):
|
||||
rising[t] = 0.0 if IV[t - 1] > IV[t - 6] else 1.0
|
||||
tm = rising * svol
|
||||
print(f"tail-managed: Sharpe {sharpe_t(T_(tm)):+.2f} worst {np.nanmin(tm)/np.nanstd(tm):+.1f}σ")
|
||||
print("per-year TM: " + " ".join(f"{y}:{sharpe_t(T_(tm[year==y])):+.1f}" for y in range(2013, 2027) if (year == y).sum() > 60))
|
||||
print("\nVERDICT: implied-forward tracking ~SPX/10 + mean RV ~13-16% + VRP>0 => measurement clean & premium real.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
83
scripts/surfer/fetch_caiso.py
Normal file
83
scripts/surfer/fetch_caiso.py
Normal file
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch CAISO day-ahead (DAM) + real-time (RTM) hourly LMP for one hub (free OASIS, no key).
|
||||
|
||||
The RT-vs-DA spread is the forecast-error-driven, less-seasonal intraday opportunity. Monthly
|
||||
chunks with backoff (OASIS rate-limits hard). Aggregates RT to hourly. Caches per chunk.
|
||||
Saves data/surfer/caiso/{dam,rtm}.json as {hour_key: price}.
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
NODE = "TH_NP15_GEN-APND"
|
||||
OUT = "data/surfer/caiso"
|
||||
MONTHS = [(2024, m) for m in range(1, 13)]
|
||||
|
||||
|
||||
def oasis(qn, market, y, m):
|
||||
s = f"{y}{m:02d}01T08:00-0000"
|
||||
ny, nm = (y + 1, 1) if m == 12 else (y, m + 1)
|
||||
e = f"{ny}{nm:02d}01T08:00-0000"
|
||||
u = (f"http://oasis.caiso.com/oasisapi/SingleZip?queryname={qn}&startdatetime={s}"
|
||||
f"&enddatetime={e}&version=1&market_run_id={market}&node={NODE}&resultformat=6")
|
||||
for a in range(5):
|
||||
try:
|
||||
raw = urllib.request.urlopen(urllib.request.Request(u, headers={"User-Agent": "curl/8"}), timeout=90).read()
|
||||
z = zipfile.ZipFile(io.BytesIO(raw))
|
||||
txt = z.read(z.namelist()[0]).decode()
|
||||
return txt
|
||||
except Exception as ex:
|
||||
time.sleep(12 * (a + 1))
|
||||
return None
|
||||
|
||||
|
||||
def parse_hourly(txt):
|
||||
lines = txt.strip().split("\n")
|
||||
hdr = lines[0].split(",")
|
||||
if "LMP_TYPE" not in hdr:
|
||||
return {} # error report / wrong format
|
||||
iT = hdr.index("LMP_TYPE")
|
||||
iV = hdr.index("MW") if "MW" in hdr else (hdr.index("PRC") if "PRC" in hdr else -1)
|
||||
if iV < 0:
|
||||
return {}
|
||||
iD = hdr.index("OPR_DT"); iH = hdr.index("OPR_HR")
|
||||
agg = {}
|
||||
for ln in lines[1:]:
|
||||
c = ln.split(",")
|
||||
if len(c) <= max(iT, iV, iD, iH):
|
||||
continue
|
||||
if c[iT] != "LMP":
|
||||
continue
|
||||
k = f"{c[iD]}H{int(c[iH]):02d}"
|
||||
agg.setdefault(k, []).append(float(c[iV]))
|
||||
return {k: sum(v) / len(v) for k, v in agg.items()}
|
||||
|
||||
|
||||
def fetch(market, qn):
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
out = {}
|
||||
for y, m in MONTHS:
|
||||
cf = f"{OUT}/{market}_{y}{m:02d}.json"
|
||||
if os.path.exists(cf):
|
||||
out.update(json.load(open(cf))); continue
|
||||
txt = oasis(qn, market, y, m)
|
||||
if not txt:
|
||||
print(f" {market} {y}-{m:02d}: FAILED"); continue
|
||||
h = parse_hourly(txt)
|
||||
json.dump(h, open(cf, "w")); out.update(h)
|
||||
print(f" {market} {y}-{m:02d}: {len(h)} hrs")
|
||||
time.sleep(6)
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
dam = fetch("DAM", "PRC_LMP"); rtm = fetch("RTPD", "PRC_RTPD_LMP") # 15-min RT pre-dispatch
|
||||
json.dump(dam, open(f"{OUT}/dam.json", "w")); json.dump(rtm, open(f"{OUT}/rtm.json", "w"))
|
||||
print(f"DONE: DAM {len(dam)} hrs, RTM {len(rtm)} hrs")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
103
scripts/surfer/fetch_crypto.py
Normal file
103
scripts/surfer/fetch_crypto.py
Normal file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch daily klines + funding history for major USDT perps (Binance, free, no key).
|
||||
|
||||
Caches per-symbol npz to data/surfer/crypto/ (gitignored): day, open, close, funding_daily
|
||||
(sum of the 8h funding rates that day). Curated long-history majors → reduces (not eliminates)
|
||||
survivorship; v1 caveat documented. Crypto is 24/7 → no roll / no overnight gap.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
import numpy as np
|
||||
|
||||
OUT = "data/surfer/crypto"
|
||||
DAY_MS = 86_400_000
|
||||
TOP_N = 80 # programmatic universe: top-N USDT perps by 24h quote-volume (removes hand-selection bias)
|
||||
|
||||
|
||||
def get(url):
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "curl/8"})
|
||||
return json.load(urllib.request.urlopen(req, timeout=30))
|
||||
|
||||
|
||||
def universe(n):
|
||||
info = get("https://fapi.binance.com/fapi/v1/exchangeInfo")
|
||||
perps = {s["symbol"] for s in info["symbols"]
|
||||
if s.get("contractType") == "PERPETUAL" and s.get("quoteAsset") == "USDT"
|
||||
and s.get("status") == "TRADING"}
|
||||
tick = get("https://fapi.binance.com/fapi/v1/ticker/24hr")
|
||||
vol = {t["symbol"]: float(t["quoteVolume"]) for t in tick if t["symbol"] in perps}
|
||||
return sorted(vol, key=lambda s: -vol[s])[:n]
|
||||
|
||||
|
||||
SYMS = universe(TOP_N)
|
||||
|
||||
|
||||
def klines(sym):
|
||||
out = []
|
||||
end = None
|
||||
for _ in range(20):
|
||||
u = f"https://fapi.binance.com/fapi/v1/klines?symbol={sym}&interval=1d&limit=1500"
|
||||
if end:
|
||||
u += f"&endTime={end}"
|
||||
k = get(u)
|
||||
if not k:
|
||||
break
|
||||
out = k + out
|
||||
end = k[0][0] - 1
|
||||
if len(k) < 1500:
|
||||
break
|
||||
time.sleep(0.15)
|
||||
# dedup by openTime
|
||||
d = {int(r[0]): (float(r[1]), float(r[4])) for r in out}
|
||||
days = np.array(sorted(d))
|
||||
op = np.array([d[t][0] for t in days]); cl = np.array([d[t][1] for t in days])
|
||||
return days // DAY_MS, op, cl
|
||||
|
||||
|
||||
def funding(sym, start_ms):
|
||||
out = []
|
||||
st = start_ms
|
||||
for _ in range(80): # forward pagination (startTime works; endTime didn't)
|
||||
u = f"https://fapi.binance.com/fapi/v1/fundingRate?symbol={sym}&startTime={st}&limit=1000"
|
||||
f = get(u)
|
||||
if not f:
|
||||
break
|
||||
out += f
|
||||
st = f[-1]["fundingTime"] + 1
|
||||
if len(f) < 1000:
|
||||
break
|
||||
time.sleep(0.12)
|
||||
daily = {}
|
||||
for r in out:
|
||||
daily.setdefault(int(r["fundingTime"]) // DAY_MS, 0.0)
|
||||
daily[int(r["fundingTime"]) // DAY_MS] += float(r["fundingRate"])
|
||||
return daily
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
ok = 0
|
||||
for sym in SYMS:
|
||||
outp = f"{OUT}/{sym}.npz"
|
||||
if os.path.exists(outp):
|
||||
print(f" {sym}: cached"); ok += 1; continue
|
||||
try:
|
||||
kd, op, cl = klines(sym)
|
||||
if len(kd) < 400:
|
||||
print(f" {sym}: too short ({len(kd)}d), skip"); continue
|
||||
fmap = funding(sym, int(kd.min()) * DAY_MS)
|
||||
fund = np.array([fmap.get(int(d), 0.0) for d in kd])
|
||||
np.savez(outp, day=kd, open=op, close=cl, funding=fund)
|
||||
print(f" {sym}: {len(kd)}d ({kd.min()}..{kd.max()}) fundcov={np.mean(fund!=0):.2f}")
|
||||
ok += 1
|
||||
time.sleep(0.2)
|
||||
except Exception as e:
|
||||
print(f" {sym}: FAIL {type(e).__name__} {str(e)[:80]}")
|
||||
print(f"DONE: {ok}/{len(SYMS)} symbols -> {OUT}/")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
103
scripts/surfer/fetch_crypto_pit.py
Normal file
103
scripts/surfer/fetch_crypto_pit.py
Normal file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Point-in-time crypto fetch: broad live universe + KNOWN-DEAD perps, WITH volume.
|
||||
|
||||
For a survivorship-free test: delisted Binance perps still return klines up to delisting,
|
||||
so including them lets a date-by-date "top-N by trailing dollar-volume" universe contain
|
||||
dead coins while they traded and drop them when they die. Saves day, close, qvol, funding
|
||||
to data/surfer/crypto_pit/ (gitignored).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
import numpy as np
|
||||
|
||||
OUT = "data/surfer/crypto_pit"
|
||||
DAY_MS = 86_400_000
|
||||
TOP_N = 120
|
||||
# famous delisted / dead Binance USDT perps (the survivorship risk); klines available post-delist
|
||||
DEAD = ["LUNAUSDT", "ANCUSDT", "SRMUSDT", "HNTUSDT", "MATICUSDT", "FTTUSDT", "RAYUSDT",
|
||||
"WAVESUSDT", "BNXUSDT", "SCUSDT", "OCEANUSDT", "AGIXUSDT", "CVCUSDT", "TLMUSDT",
|
||||
"DENTUSDT", "KEYUSDT", "CTKUSDT", "TOMOUSDT", "AKROUSDT", "BLZUSDT", "COMBOUSDT",
|
||||
"FTMUSDT", "DGBUSDT", "REEFUSDT", "SLPUSDT", "IDEXUSDT", "LINAUSDT", "NEBLUSDT",
|
||||
"RADUSDT", "BTSUSDT", "STMXUSDT", "MDTUSDT", "AMBUSDT", "GTCUSDT", "DARUSDT"]
|
||||
|
||||
|
||||
def get(url):
|
||||
try:
|
||||
return json.load(urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "curl/8"}), timeout=30))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def universe(n):
|
||||
info = get("https://fapi.binance.com/fapi/v1/exchangeInfo")
|
||||
perps = {s["symbol"] for s in info["symbols"]
|
||||
if s.get("contractType") == "PERPETUAL" and s.get("quoteAsset") == "USDT" and s.get("status") == "TRADING"}
|
||||
tick = get("https://fapi.binance.com/fapi/v1/ticker/24hr")
|
||||
vol = {t["symbol"]: float(t["quoteVolume"]) for t in tick if t["symbol"] in perps}
|
||||
return sorted(vol, key=lambda s: -vol[s])[:n]
|
||||
|
||||
|
||||
def klines(sym):
|
||||
out, end = [], None
|
||||
for _ in range(20):
|
||||
u = f"https://fapi.binance.com/fapi/v1/klines?symbol={sym}&interval=1d&limit=1500"
|
||||
if end:
|
||||
u += f"&endTime={end}"
|
||||
k = get(u)
|
||||
if not k:
|
||||
break
|
||||
out = k + out
|
||||
end = k[0][0] - 1
|
||||
if len(k) < 1500:
|
||||
break
|
||||
time.sleep(0.15)
|
||||
d = {int(r[0]): (float(r[4]), float(r[7])) for r in out} # close, quote-volume
|
||||
days = np.array(sorted(d))
|
||||
cl = np.array([d[t][0] for t in days]); qv = np.array([d[t][1] for t in days])
|
||||
return days // DAY_MS, cl, qv
|
||||
|
||||
|
||||
def funding(sym, start_ms):
|
||||
out, st = [], start_ms
|
||||
for _ in range(80):
|
||||
f = get(f"https://fapi.binance.com/fapi/v1/fundingRate?symbol={sym}&startTime={st}&limit=1000")
|
||||
if not f:
|
||||
break
|
||||
out += f
|
||||
st = f[-1]["fundingTime"] + 1
|
||||
if len(f) < 1000:
|
||||
break
|
||||
time.sleep(0.12)
|
||||
daily = {}
|
||||
for r in out:
|
||||
daily.setdefault(int(r["fundingTime"]) // DAY_MS, 0.0)
|
||||
daily[int(r["fundingTime"]) // DAY_MS] += float(r["fundingRate"])
|
||||
return daily
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
syms = sorted(set(universe(TOP_N)) | set(DEAD))
|
||||
ok = 0
|
||||
for sym in syms:
|
||||
outp = f"{OUT}/{sym}.npz"
|
||||
if os.path.exists(outp):
|
||||
ok += 1; continue
|
||||
kd, cl, qv = klines(sym)
|
||||
if len(kd) < 200:
|
||||
print(f" {sym}: short/none ({len(kd)}d), skip"); continue
|
||||
fmap = funding(sym, int(kd.min()) * DAY_MS)
|
||||
fund = np.array([fmap.get(int(d), 0.0) for d in kd])
|
||||
np.savez(outp, day=kd, close=cl, qvol=qv, funding=fund)
|
||||
alive = "DEAD" if qv[-30:].mean() == 0 else "live"
|
||||
print(f" {sym}: {len(kd)}d ({kd.min()}..{kd.max()}) {alive}")
|
||||
ok += 1
|
||||
time.sleep(0.2)
|
||||
print(f"DONE: {ok}/{len(syms)} -> {OUT}/")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
83
scripts/surfer/fetch_daily.py
Normal file
83
scripts/surfer/fetch_daily.py
Normal file
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Download daily OHLCV for the surfer universe — continuous front-month, BUDGET-CAPPED.
|
||||
|
||||
Re-confirms get_cost (free, no download) and ABORTS if over the cap before spending.
|
||||
Saves raw DBN to data/surfer/ (gitignored). Reads DATABENTO_API_KEY from env (never printed).
|
||||
"""
|
||||
import datetime
|
||||
import os
|
||||
import sys
|
||||
|
||||
import databento as db
|
||||
|
||||
CAP_USD = 40.00 # hard ceiling on cumulative get_cost; covered by $125 free credits ($0 cash)
|
||||
PER_ROOT_CAP = 10.00 # per-root sanity cap
|
||||
DS = "GLBX.MDP3"
|
||||
SCHEMA = "ohlcv-1d"
|
||||
START = "2010-06-06"
|
||||
END = datetime.date.today().isoformat() # fetch through today (dynamic) — was hardcoded; needed for daily forward-track
|
||||
ROOTS = ["ES", "NQ", "YM", "RTY", "ZN", "ZB", "ZF", "ZT", "6E", "6J", "6B", "6A",
|
||||
"6C", "GC", "SI", "HG", "CL", "NG", "RB", "ZC", "ZS", "ZW"]
|
||||
# parent symbology = all outright expiries per root (fast; continuous chain resolution 504s).
|
||||
# We build the continuous series ourselves (volume-roll + ratio-adjust) from these.
|
||||
STYPE = "parent"
|
||||
OUT_DIR = "data/surfer"
|
||||
|
||||
|
||||
def main():
|
||||
key = os.environ.get("DATABENTO_API_KEY")
|
||||
if not key:
|
||||
print("DATABENTO_API_KEY not set"); return 2
|
||||
client = db.Historical(key)
|
||||
|
||||
global END
|
||||
try: # clamp END to the dataset's available end (GLBX lags ~1-3d)
|
||||
rng = client.metadata.get_dataset_range(dataset=DS)
|
||||
avail = (rng.get("end") or "")[:10]
|
||||
if avail:
|
||||
END = min(END, avail)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
print(f"fetching {SCHEMA} per-root ({len(ROOTS)} roots, {STYPE}) {START}..{END}; "
|
||||
f"per-root get_cost gate (≤${PER_ROOT_CAP}), cumulative cap ${CAP_USD}")
|
||||
import time
|
||||
total_recs = 0
|
||||
spent = 0.0
|
||||
failed = []
|
||||
for r in ROOTS:
|
||||
sym = r + ".FUT"
|
||||
out_r = f"{OUT_DIR}/{r}.dbn"
|
||||
# per-root cost gate (free, no download)
|
||||
try:
|
||||
c_r = client.metadata.get_cost(dataset=DS, symbols=[sym], schema=SCHEMA,
|
||||
start=START, end=END, stype_in=STYPE)
|
||||
except Exception as e:
|
||||
print(f" {r}: get_cost failed ({type(e).__name__}); skipping"); failed.append(r); continue
|
||||
if c_r > PER_ROOT_CAP or spent + c_r > CAP_USD:
|
||||
print(f" {r}: cost ${c_r:.4f} would breach cap (spent ${spent:.2f}) — SKIP"); failed.append(r); continue
|
||||
ok = False
|
||||
for attempt in range(1, 4):
|
||||
try:
|
||||
data = client.timeseries.get_range(dataset=DS, symbols=[sym], schema=SCHEMA,
|
||||
start=START, end=END, stype_in=STYPE)
|
||||
data.to_file(out_r)
|
||||
n = sum(1 for _ in data)
|
||||
total_recs += n; spent += c_r
|
||||
print(f" {r}: ${c_r:.4f} {n:,} recs -> {out_r} (cum ${spent:.2f})")
|
||||
ok = True
|
||||
break
|
||||
except Exception as e:
|
||||
print(f" {r}: attempt {attempt} {type(e).__name__}; retry in 3s")
|
||||
time.sleep(3)
|
||||
if not ok:
|
||||
failed.append(r)
|
||||
print(f"DONE: {total_recs:,} records, get_cost-cum ${spent:.2f}, "
|
||||
f"{len(ROOTS)-len(failed)}/{len(ROOTS)} roots ok"
|
||||
+ (f"; FAILED/SKIPPED: {failed}" if failed else ""))
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
49
scripts/surfer/fetch_earnings.py
Normal file
49
scripts/surfer/fetch_earnings.py
Normal file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch Nasdaq earnings calendar (date + symbol + surprise%) for all weekdays in the DBEQ range,
|
||||
for a proper PEAD test. Free, no key. Cached per date; gentle pacing + backoff.
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
OUT = "data/surfer/earnings"
|
||||
START = datetime.date(2023, 3, 28)
|
||||
END = datetime.date(2026, 5, 31)
|
||||
|
||||
|
||||
def get(u):
|
||||
for a in range(4):
|
||||
try:
|
||||
req = urllib.request.Request(u, headers={"User-Agent": "Mozilla/5.0", "Accept": "application/json"})
|
||||
return json.loads(urllib.request.urlopen(req, timeout=25).read())
|
||||
except Exception:
|
||||
if a == 3:
|
||||
return None
|
||||
time.sleep(4 * (a + 1))
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
d = START
|
||||
n = fail = 0
|
||||
while d <= END:
|
||||
if d.weekday() < 5:
|
||||
f = f"{OUT}/{d}.json"
|
||||
if not os.path.exists(f):
|
||||
r = get(f"https://api.nasdaq.com/api/calendar/earnings?date={d}")
|
||||
if r is None:
|
||||
fail += 1
|
||||
else:
|
||||
rows = (r.get("data") or {}).get("rows") or []
|
||||
json.dump([(x.get("symbol"), x.get("surprise")) for x in rows], open(f, "w"))
|
||||
n += 1
|
||||
time.sleep(1.3)
|
||||
d += datetime.timedelta(days=1)
|
||||
print(f"DONE: fetched {n} new days, {fail} failed; cache dir {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
33
scripts/surfer/fetch_equities.py
Normal file
33
scripts/surfer/fetch_equities.py
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch all US equities daily OHLCV (DBEQ.BASIC) for the small/mid-cap factor test.
|
||||
|
||||
Budget-capped, get_cost-gated. Saves DBN to data/surfer/dbeq_ohlcv1d.dbn.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import databento as db
|
||||
|
||||
CAP = 70.0
|
||||
DS, SCH = "DBEQ.BASIC", "ohlcv-1d"
|
||||
START, END = "2023-03-28", "2026-06-05"
|
||||
OUT = "data/surfer/dbeq_ohlcv1d.dbn"
|
||||
|
||||
|
||||
def main():
|
||||
c = db.Historical(os.environ["DATABENTO_API_KEY"])
|
||||
if os.path.exists(OUT):
|
||||
print("already downloaded"); return 0
|
||||
cost = c.metadata.get_cost(dataset=DS, symbols=["ALL_SYMBOLS"], schema=SCH, start=START, end=END)
|
||||
print(f"get_cost=${cost:.2f} cap=${CAP:.2f}")
|
||||
if cost > CAP:
|
||||
print("ABORT over cap"); return 1
|
||||
data = c.timeseries.get_range(dataset=DS, symbols=["ALL_SYMBOLS"], schema=SCH, start=START, end=END)
|
||||
os.makedirs("data/surfer", exist_ok=True)
|
||||
data.to_file(OUT)
|
||||
print(f"saved {OUT}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
81
scripts/surfer/fetch_es_1m.py
Normal file
81
scripts/surfer/fetch_es_1m.py
Normal file
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch full-history ES front-month OHLCV-1m (continuous .c.0), year-chunked, BUDGET-CAPPED.
|
||||
|
||||
get_cost-gated (aborts over cap, no download); year chunks with quarter fallback on 504.
|
||||
Saves per-chunk DBN to data/surfer/es1m/ (gitignored). Key from env, never printed.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import databento as db
|
||||
|
||||
CAP_USD = 25.00
|
||||
DS = "GLBX.MDP3"
|
||||
SCHEMA = "ohlcv-1m"
|
||||
SYM = "ES.c.0"
|
||||
STYPE = "continuous"
|
||||
OUT_DIR = "data/surfer/es1m"
|
||||
|
||||
|
||||
def fetch(client, start, end, out):
|
||||
data = client.timeseries.get_range(dataset=DS, symbols=[SYM], schema=SCHEMA,
|
||||
start=start, end=end, stype_in=STYPE)
|
||||
data.to_file(out)
|
||||
return sum(1 for _ in data)
|
||||
|
||||
|
||||
def main():
|
||||
key = os.environ.get("DATABENTO_API_KEY")
|
||||
if not key:
|
||||
print("DATABENTO_API_KEY not set"); return 2
|
||||
client = db.Historical(key)
|
||||
cost = client.metadata.get_cost(dataset=DS, symbols=[SYM], schema=SCHEMA,
|
||||
start="2010-06-06", end="2026-06-05", stype_in=STYPE)
|
||||
print(f"aggregate get_cost=${cost:.4f} cap=${CAP_USD:.2f}")
|
||||
if cost > CAP_USD:
|
||||
print("ABORT: over cap — nothing downloaded."); return 1
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
total = 0
|
||||
for year in range(2010, 2027):
|
||||
y0, y1 = f"{year}-01-01", f"{year+1}-01-01"
|
||||
if year == 2010:
|
||||
y0 = "2010-06-06"
|
||||
if year == 2026:
|
||||
y1 = "2026-06-05"
|
||||
out = f"{OUT_DIR}/ES_{year}.dbn"
|
||||
if os.path.exists(out):
|
||||
print(f" {year}: exists, skip"); continue
|
||||
ok = False
|
||||
for attempt in range(1, 3):
|
||||
try:
|
||||
n = fetch(client, y0, y1, out); total += n
|
||||
print(f" {year}: {n:,} recs -> {out}")
|
||||
ok = True; break
|
||||
except Exception as e:
|
||||
print(f" {year}: attempt {attempt} {type(e).__name__}; retry")
|
||||
time.sleep(3)
|
||||
if not ok: # fall back to quarter chunks
|
||||
print(f" {year}: year failed → quarter fallback")
|
||||
for q, (m0, m1) in enumerate([("01-01", "04-01"), ("04-01", "07-01"),
|
||||
("07-01", "10-01"), ("10-01", "12-31")], 1):
|
||||
qs, qe = f"{year}-{m0}", f"{year}-{m1}"
|
||||
if year == 2010 and q == 1:
|
||||
qs = "2010-06-06"
|
||||
if year == 2026 and q >= 3:
|
||||
continue
|
||||
qout = f"{OUT_DIR}/ES_{year}_q{q}.dbn"
|
||||
if os.path.exists(qout):
|
||||
continue
|
||||
try:
|
||||
n = fetch(client, qs, qe, qout); total += n
|
||||
print(f" {year} q{q}: {n:,} -> {qout}")
|
||||
except Exception as e:
|
||||
print(f" {year} q{q}: FAILED {type(e).__name__}")
|
||||
time.sleep(1)
|
||||
print(f"DONE: {total:,} records into {OUT_DIR}/")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
35
scripts/surfer/fetch_more_futures.py
Normal file
35
scripts/surfer/fetch_more_futures.py
Normal file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Expand the daily futures universe (16y ohlcv-1d, parent) for a diversified multi-asset test.
|
||||
|
||||
Budget-capped, get_cost-gated. Saves to data/surfer/<root>.dbn alongside the existing 22 roots.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import databento as db
|
||||
|
||||
CAP = 20.0
|
||||
DS = "GLBX.MDP3"
|
||||
START, END = "2010-06-06", "2026-06-05"
|
||||
ADD = ["6S", "6N", "6M", "HO", "PL", "PA", "UB", "ZL", "ZM", "ZO", "KE", "ZR", "LE", "HE", "GF", "EMD", "NKD"]
|
||||
|
||||
|
||||
def main():
|
||||
c = db.Historical(os.environ["DATABENTO_API_KEY"])
|
||||
todo = [r for r in ADD if not os.path.exists(f"data/surfer/{r}.dbn")]
|
||||
cost = sum(c.metadata.get_cost(dataset=DS, symbols=[r + ".FUT"], schema="ohlcv-1d",
|
||||
start=START, end=END, stype_in="parent") for r in todo)
|
||||
print(f"to fetch: {todo}\naggregate get_cost=${cost:.2f} cap=${CAP:.2f}")
|
||||
if cost > CAP:
|
||||
print("ABORT over cap"); return 1
|
||||
for r in todo:
|
||||
data = c.timeseries.get_range(dataset=DS, symbols=[r + ".FUT"], schema="ohlcv-1d",
|
||||
start=START, end=END, stype_in="parent")
|
||||
data.to_file(f"data/surfer/{r}.dbn")
|
||||
print(f" {r}: saved")
|
||||
print(f"DONE: {len(todo)} roots")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
49
scripts/surfer/fetch_xsp.py
Normal file
49
scripts/surfer/fetch_xsp.py
Normal file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch XSP (mini-SPX) options daily OHLCV for the equity-index VRP test. HARD-CAPPED.
|
||||
|
||||
get_cost-gated: prints the exact cost and ABORTS if above CAP — no surprise charges.
|
||||
Single dataset/schema/symbol/window. ohlcv-1d only (NOT definition).
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import databento as db
|
||||
|
||||
CAP = 70.00 # hard cap — abort if cost exceeds this
|
||||
DS, SCH, SYM = "OPRA.PILLAR", "ohlcv-1d", "XSP.OPT"
|
||||
START, END = "2013-04-01", "2026-06-05"
|
||||
OUT = "data/surfer/xsp_ohlcv1d.dbn"
|
||||
|
||||
|
||||
def main():
|
||||
c = db.Historical(os.environ["DATABENTO_API_KEY"])
|
||||
if os.path.exists(OUT):
|
||||
print(f"already downloaded: {OUT}"); return 0
|
||||
cost = c.metadata.get_cost(dataset=DS, symbols=[SYM], schema=SCH, start=START, end=END, stype_in="parent")
|
||||
gb = c.metadata.get_billable_size(dataset=DS, symbols=[SYM], schema=SCH, start=START, end=END, stype_in="parent") / 1e9
|
||||
print(f"EXACT get_cost = ${cost:.2f} ({gb:.2f} GB) CAP = ${CAP:.2f}")
|
||||
if cost > CAP:
|
||||
print(f"ABORT: ${cost:.2f} exceeds cap ${CAP:.2f} — NOTHING downloaded.")
|
||||
return 1
|
||||
print(f"under cap by ${CAP - cost:.2f} -> downloading {SYM} {SCH} year-chunked (same total cost)")
|
||||
os.makedirs("data/surfer/xsp", exist_ok=True)
|
||||
for yr in range(2013, 2027):
|
||||
ys = f"{yr}-01-01" if yr > 2013 else "2013-04-01"
|
||||
ye = f"{yr+1}-01-01" if yr < 2026 else "2026-06-05"
|
||||
out = f"data/surfer/xsp/xsp_{yr}.dbn"
|
||||
if os.path.exists(out):
|
||||
print(f" {yr}: exists"); continue
|
||||
for attempt in range(3):
|
||||
try:
|
||||
d = c.timeseries.get_range(dataset=DS, symbols=[SYM], schema=SCH, start=ys, end=ye, stype_in="parent")
|
||||
d.to_file(out)
|
||||
print(f" {yr}: saved ({os.path.getsize(out)/1e6:.1f} MB)")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f" {yr}: attempt {attempt+1} {type(e).__name__}; retry")
|
||||
print(f"DONE -> data/surfer/xsp/")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
90
scripts/surfer/fetch_xvenue_hist.py
Normal file
90
scripts/surfer/fetch_xvenue_hist.py
Normal file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch historical funding (Binance + Bybit) for liquid coins on both, to BACKTEST the cross-venue
|
||||
funding arb instead of waiting for live paper-forward. Aggregates 8h settlements to daily per venue.
|
||||
Cached. Saves data/surfer/xvenue/panel.json = {coin: {date: [binance_daily, bybit_daily]}}.
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
OUT = "data/surfer/xvenue"
|
||||
NCOINS = 60
|
||||
DAYS = 330
|
||||
|
||||
|
||||
def get(u, post=None):
|
||||
data = json.dumps(post).encode() if post else None
|
||||
h = {"User-Agent": "Mozilla/5.0", "Accept": "application/json"}
|
||||
for a in range(4):
|
||||
try:
|
||||
return json.loads(urllib.request.urlopen(urllib.request.Request(u, data=data, headers=h), timeout=25).read())
|
||||
except Exception:
|
||||
if a == 3:
|
||||
return None
|
||||
time.sleep(3 * (a + 1))
|
||||
return None
|
||||
|
||||
|
||||
def base(s):
|
||||
return s[:-4] if s.endswith("USDT") else s
|
||||
|
||||
|
||||
def day_of(ms):
|
||||
return datetime.datetime.utcfromtimestamp(int(ms) / 1000).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def binance_hist(coin):
|
||||
r = get(f"https://fapi.binance.com/fapi/v1/fundingRate?symbol={coin}USDT&limit=1000")
|
||||
d = {}
|
||||
for x in (r or []):
|
||||
d.setdefault(day_of(x["fundingTime"]), 0.0)
|
||||
d[day_of(x["fundingTime"])] += float(x["fundingRate"])
|
||||
return d
|
||||
|
||||
|
||||
def bybit_hist(coin):
|
||||
out, end = {}, None
|
||||
for _ in range(6):
|
||||
u = f"https://api.bybit.com/v5/market/funding/history?category=linear&symbol={coin}USDT&limit=200"
|
||||
if end:
|
||||
u += f"&endTime={end}"
|
||||
r = get(u)
|
||||
lst = ((r or {}).get("result") or {}).get("list") or []
|
||||
if not lst:
|
||||
break
|
||||
for x in lst:
|
||||
t = x["fundingRateTimestamp"]
|
||||
out.setdefault(day_of(t), 0.0)
|
||||
out[day_of(t)] += float(x["fundingRate"])
|
||||
end = int(lst[-1]["fundingRateTimestamp"]) - 1
|
||||
time.sleep(0.3)
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
binv = {base(x["symbol"]): float(x["quoteVolume"]) for x in get("https://fapi.binance.com/fapi/v1/ticker/24hr") if x["symbol"].endswith("USDT")}
|
||||
byr = get("https://api.bybit.com/v5/market/tickers?category=linear")["result"]["list"]
|
||||
bybv = {base(x["symbol"]): float(x.get("turnover24h", 0)) for x in byr if x["symbol"].endswith("USDT")}
|
||||
coins = sorted([c for c in binv if c in bybv and binv[c] > 10e6 and bybv[c] > 10e6], key=lambda c: -binv[c])[:NCOINS]
|
||||
print(f"universe: {len(coins)} coins on both venues >$10M/day")
|
||||
panel = {}
|
||||
for i, c in enumerate(coins):
|
||||
cf = f"{OUT}/{c}.json"
|
||||
if os.path.exists(cf):
|
||||
panel[c] = json.load(open(cf)); continue
|
||||
bn = binance_hist(c); by = bybit_hist(c)
|
||||
days = sorted(set(bn) & set(by))
|
||||
panel[c] = {d: [bn[d], by[d]] for d in days}
|
||||
json.dump(panel[c], open(cf, "w"))
|
||||
if i % 10 == 0:
|
||||
print(f" {i+1}/{len(coins)} {c}: {len(panel[c])} common days")
|
||||
time.sleep(0.4)
|
||||
json.dump(panel, open(f"{OUT}/panel.json", "w"))
|
||||
print(f"DONE: {len(panel)} coins -> {OUT}/panel.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
120
scripts/surfer/fetch_xvenue_hist2.py
Normal file
120
scripts/surfer/fetch_xvenue_hist2.py
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deep multi-venue funding history (Binance + OKX + Hyperliquid, ~1yr) for a confident cross-venue
|
||||
backtest. Binance & HL are deep (~330d); OKX ~3mo. Aggregate to daily per venue. Cache per coin.
|
||||
Saves data/surfer/xvenue/panel2.json = {coin: {date: {bn, okx, hl}}}.
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
OUT = "data/surfer/xvenue2"
|
||||
NCOINS = 35
|
||||
DAYS = 330
|
||||
|
||||
|
||||
def get(u, post=None):
|
||||
data = json.dumps(post).encode() if post else None
|
||||
h = {"User-Agent": "Mozilla/5.0", "Accept": "application/json"}
|
||||
if post:
|
||||
h["Content-Type"] = "application/json"
|
||||
for a in range(4):
|
||||
try:
|
||||
return json.loads(urllib.request.urlopen(urllib.request.Request(u, data=data, headers=h), timeout=25).read())
|
||||
except Exception:
|
||||
if a == 3:
|
||||
return None
|
||||
time.sleep(3 * (a + 1))
|
||||
return None
|
||||
|
||||
|
||||
def base(s):
|
||||
return s[:-4] if s.endswith("USDT") else s
|
||||
|
||||
|
||||
def day_of(ms):
|
||||
return datetime.datetime.utcfromtimestamp(int(ms) / 1000).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def binance_hist(coin, now_ms):
|
||||
out, start = {}, now_ms - DAYS * 86400000
|
||||
for _ in range(8): # paginate back full DAYS window
|
||||
r = get(f"https://fapi.binance.com/fapi/v1/fundingRate?symbol={coin}USDT&startTime={start}&limit=1000")
|
||||
if not r:
|
||||
break
|
||||
for x in r:
|
||||
k = day_of(x["fundingTime"]); out[k] = out.get(k, 0.0) + float(x["fundingRate"])
|
||||
last = int(r[-1]["fundingTime"])
|
||||
if last <= start or len(r) < 2:
|
||||
break
|
||||
start = last + 1
|
||||
return out
|
||||
|
||||
|
||||
def okx_hist(coin):
|
||||
out, after = {}, None
|
||||
for _ in range(6):
|
||||
u = f"https://www.okx.com/api/v5/public/funding-rate-history?instId={coin}-USDT-SWAP&limit=100"
|
||||
if after:
|
||||
u += f"&after={after}"
|
||||
r = get(u); data = (r or {}).get("data") or []
|
||||
if not data:
|
||||
break
|
||||
for x in data:
|
||||
k = day_of(x["fundingTime"]); out[k] = out.get(k, 0.0) + float(x["fundingRate"])
|
||||
after = data[-1]["fundingTime"]; time.sleep(0.2)
|
||||
return out
|
||||
|
||||
|
||||
def hl_hist(coin, now_ms):
|
||||
out, start = {}, now_ms - DAYS * 86400000
|
||||
for _ in range(30):
|
||||
r = get("https://api.hyperliquid.xyz/info", post={"type": "fundingHistory", "coin": coin, "startTime": start})
|
||||
if not r:
|
||||
break
|
||||
for x in r:
|
||||
k = day_of(x["time"]); out[k] = out.get(k, 0.0) + float(x["fundingRate"])
|
||||
last = int(r[-1]["time"])
|
||||
if last <= start or len(r) < 2:
|
||||
break
|
||||
start = last + 1
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
now_ms = int(time.time() * 1000)
|
||||
binv = {base(x["symbol"]): float(x["quoteVolume"]) for x in get("https://fapi.binance.com/fapi/v1/ticker/24hr") if x["symbol"].endswith("USDT")}
|
||||
hlmeta = get("https://api.hyperliquid.xyz/info", post={"type": "metaAndAssetCtxs"})
|
||||
hlcoins = {u["name"] for u in hlmeta[0]["universe"]}
|
||||
coins = sorted([c for c in binv if c in hlcoins and binv[c] > 10e6], key=lambda c: -binv[c])[:NCOINS]
|
||||
print(f"universe: {len(coins)} coins (Binance>$10M & on Hyperliquid)")
|
||||
panel = {}
|
||||
for i, c in enumerate(coins):
|
||||
cf = f"{OUT}/{c}.json"
|
||||
if os.path.exists(cf):
|
||||
panel[c] = json.load(open(cf)); continue
|
||||
bn = binance_hist(c, now_ms); okx = okx_hist(c); hl = hl_hist(c, now_ms)
|
||||
days = sorted(set(bn) | set(okx) | set(hl))
|
||||
rec = {}
|
||||
for d in days:
|
||||
v = {}
|
||||
if d in bn:
|
||||
v["bn"] = bn[d]
|
||||
if d in okx:
|
||||
v["okx"] = okx[d]
|
||||
if d in hl:
|
||||
v["hl"] = hl[d]
|
||||
if len(v) >= 2:
|
||||
rec[d] = v
|
||||
panel[c] = rec
|
||||
json.dump(rec, open(cf, "w"))
|
||||
print(f" {i+1}/{len(coins)} {c}: {len(rec)} days w/ >=2 venues (bn{len(bn)} okx{len(okx)} hl{len(hl)})")
|
||||
time.sleep(0.3)
|
||||
json.dump(panel, open(f"{OUT}/panel2.json", "w"))
|
||||
print(f"DONE: {len(panel)} coins -> {OUT}/panel2.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
215
scripts/surfer/floor_experiment.py
Normal file
215
scripts/surfer/floor_experiment.py
Normal file
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Surfer Phase 0 — diversified-trend FLOOR + validation, on the GPU (PyTorch).
|
||||
|
||||
Loads per-root parent OHLCV-1d (data/surfer/*.dbn), builds ratio-adjusted continuous
|
||||
front-month series (volume-roll), then runs the floor + the anti-overfit validation
|
||||
ENTIRELY on the GPU (torch.cuda): TSMOM(1/3/12mo) → inverse-vol size → vol-target →
|
||||
net-of-cost daily portfolio returns; CPCV(5th-pct OOS Sharpe), Deflated Sharpe, PBO,
|
||||
IS↔OOS rank-consistency. Prints the PASS/FAIL verdict.
|
||||
|
||||
Data-loading/continuous-assembly is CPU (I/O staging, like mapped-pinned upload); ALL
|
||||
strategy + statistics compute is on the GPU. Verdict gates per the spec §5.
|
||||
"""
|
||||
import glob
|
||||
import math
|
||||
from itertools import combinations
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else None
|
||||
TICK_NA = float("nan")
|
||||
|
||||
|
||||
# ---------- data loading + continuous assembly (CPU staging) ----------
|
||||
_MONTHS = "FGHJKMNQUVXZ"
|
||||
|
||||
def load_continuous(path):
|
||||
"""Roll-neutralized continuous daily series from a root's parent OHLCV-1d.
|
||||
Front = max-volume OUTRIGHT per day; daily return only when the front contract is
|
||||
unchanged (roll-day returns ZEROED — no ratio-adjust, no fabricated roll jumps).
|
||||
Returns (day_int[N], adj_close[N]) where adj_close = exp(cumsum(clean_return))."""
|
||||
import re
|
||||
import databento as db
|
||||
root = path.split("/")[-1][:-4]
|
||||
pat = re.compile("^" + re.escape(root) + "[" + _MONTHS + r"]\d{1,2}$") # outrights only (e.g. RBZ5)
|
||||
df = db.DBNStore.from_file(path).to_df().reset_index()
|
||||
df = df[df["close"] > 0].copy()
|
||||
df = df[df["symbol"].astype(str).str.match(pat)]
|
||||
if df.empty:
|
||||
raise ValueError("no outright contracts after filter")
|
||||
df["day"] = (df["ts_event"].astype("int64") // (86_400 * 10**9))
|
||||
# (day, instrument) -> close lookup, for proper roll-day return of the HELD contract
|
||||
cl = {(int(d), int(i)): float(c)
|
||||
for d, i, c in zip(df["day"], df["instrument_id"], df["close"])}
|
||||
idx = df.groupby("day")["volume"].idxmax()
|
||||
f = df.loc[idx, ["day", "instrument_id", "close"]].sort_values("day")
|
||||
days = f["day"].to_numpy(np.int64)
|
||||
inst = f["instrument_id"].to_numpy(np.int64)
|
||||
close = f["close"].to_numpy(np.float64)
|
||||
lr = np.zeros(len(days))
|
||||
for t in range(1, len(days)):
|
||||
a, b = int(inst[t - 1]), int(inst[t])
|
||||
if a == b:
|
||||
lr[t] = np.log(close[t] / close[t - 1])
|
||||
else: # roll: use the HELD (old front 'a') contract's own return through the roll day
|
||||
ca_t = cl.get((int(days[t]), a))
|
||||
if ca_t is not None and ca_t > 0:
|
||||
lr[t] = np.log(ca_t / close[t - 1])
|
||||
# else: 'a' not trading on day t → leave 0 (rare)
|
||||
return days, np.exp(np.cumsum(lr))
|
||||
|
||||
|
||||
def build_panel():
|
||||
paths = sorted(glob.glob("data/surfer/*.dbn"))
|
||||
series = {}
|
||||
for p in paths:
|
||||
root = p.split("/")[-1][:-4]
|
||||
try:
|
||||
d, c = load_continuous(p)
|
||||
if len(d) > 300:
|
||||
series[root] = (d, c)
|
||||
except Exception as e:
|
||||
print(f" skip {root}: {type(e).__name__} {e}")
|
||||
roots = sorted(series)
|
||||
alldays = sorted(set().union(*[set(series[r][0].tolist()) for r in roots]))
|
||||
didx = {d: i for i, d in enumerate(alldays)}
|
||||
C = np.full((len(alldays), len(roots)), np.nan)
|
||||
for j, r in enumerate(roots):
|
||||
d, c = series[r]
|
||||
for dd, cc in zip(d.tolist(), c.tolist()):
|
||||
C[didx[dd], j] = cc
|
||||
return roots, np.array(alldays), C
|
||||
|
||||
|
||||
# ---------- floor + validation (GPU) ----------
|
||||
def sharpe(x): # x: [..., T] torch; Sharpe over last dim, NaN-aware
|
||||
m = torch.nanmean(x, dim=-1)
|
||||
v = torch.nanmean((x - m.unsqueeze(-1)) ** 2, dim=-1).clamp_min(1e-18).sqrt()
|
||||
return m / v * math.sqrt(252)
|
||||
|
||||
|
||||
def run(roots, days, C):
|
||||
Ct = torch.tensor(C, device=DEV, dtype=torch.float64) # [T, N]
|
||||
logC = torch.log(Ct)
|
||||
R = torch.zeros_like(logC); R[1:] = logC[1:] - logC[:-1] # daily log returns
|
||||
R = torch.nan_to_num(R, nan=0.0)
|
||||
T, N = R.shape
|
||||
|
||||
# EWMA vol (per root), halflife 21d
|
||||
a = 1 - math.exp(math.log(0.5) / 21)
|
||||
v = torch.zeros_like(R); v[0] = R[0] ** 2
|
||||
for t in range(1, T):
|
||||
v[t] = a * R[t] ** 2 + (1 - a) * v[t - 1]
|
||||
sig_vol = (v * 252).clamp_min(1e-12).sqrt() # annualized vol [T,N]
|
||||
|
||||
# CONTINUOUS vol-normalized TSMOM (Baz et al / AQR): tanh of the trend t-stat,
|
||||
# averaged over 1/3/12mo lookbacks (252 skips last 21d). Strength in [-1,1] →
|
||||
# multiplied by inverse-vol sizing below (no double vol-counting; sign-floor superseded).
|
||||
import os
|
||||
mode = os.environ.get("FLOOR_SIGNAL", "sign") # "sign" (canonical MOP/AQR) or "cont"
|
||||
s = torch.zeros_like(R)
|
||||
for L, skip in [(21, 0), (63, 0), (252, 21)]:
|
||||
raw = torch.zeros_like(R)
|
||||
for t in range(L + skip, T):
|
||||
e = t - skip
|
||||
raw[t] = logC[e] - logC[e - L] # trend log-return over L
|
||||
if mode == "cont":
|
||||
norm = raw / (sig_vol * math.sqrt(L / 252.0)).clamp_min(1e-6) # ≈ trend Sharpe
|
||||
s = s + torch.tanh(norm)
|
||||
else:
|
||||
s = s + torch.sign(raw)
|
||||
s = (s / 3.0)
|
||||
s = torch.nan_to_num(s, nan=0.0)
|
||||
|
||||
# inverse-vol target weights, vol-targeted to 10% annual
|
||||
# No-trade band on the continuous signal (turnover control; standard CTA practice).
|
||||
# Pre-registered band = 0.10 in [-1,1] signal units: hold position until target moves > band.
|
||||
band = 0.10
|
||||
held = torch.zeros_like(s)
|
||||
held[0] = s[0]
|
||||
for t in range(1, T):
|
||||
move = (s[t] - held[t - 1]).abs() > band
|
||||
held[t] = torch.where(move, s[t], held[t - 1])
|
||||
risk_budget = 0.10 / math.sqrt(N)
|
||||
w = held * (risk_budget / sig_vol.clamp_min(1e-6))
|
||||
w = torch.nan_to_num(w, nan=0.0)
|
||||
# daily portfolio return (lag weights), net of cost
|
||||
port = (w[:-1] * R[1:]).sum(dim=1) # [T-1]
|
||||
turn = (w[1:] - w[:-1]).abs().sum(dim=1)
|
||||
cost1 = torch.nan_to_num(turn * (2.0 / 1e4), nan=0.0) # ~2bp round-trip on weight turnover
|
||||
|
||||
def voltarget(x): # 10% annual, trailing-63d realized
|
||||
sc = torch.ones_like(x)
|
||||
for t in range(63, len(x)):
|
||||
rv = x[t-63:t].std() * math.sqrt(252)
|
||||
sc[t] = (0.10 / rv.clamp_min(1e-6))
|
||||
return x * sc.clamp(0, 5)
|
||||
|
||||
net = voltarget(port - cost1)
|
||||
net2x = voltarget(port - 2 * cost1) # SV cost stress (2x)
|
||||
full_sr = float(sharpe(net))
|
||||
full_sr_2x = float(sharpe(net2x))
|
||||
# CPCV: 10 blocks, leave-2-out test → 45 paths
|
||||
n = len(net); nb = 10; k = 2
|
||||
blocks = [torch.arange(i*n//nb, (i+1)*n//nb, device=DEV) for i in range(nb)]
|
||||
oos_sr = []
|
||||
for combo in combinations(range(nb), k):
|
||||
idx = torch.cat([blocks[b] for b in combo])
|
||||
oos_sr.append(float(sharpe(net[idx])))
|
||||
oos_sr = np.array(oos_sr)
|
||||
oos_5pct = float(np.percentile(oos_sr, 5))
|
||||
|
||||
# IS(<2024)/OOS(>=2024) split by day epoch (days are UTC-day ints)
|
||||
split_day = 19723 # 2024-01-01 in days since epoch
|
||||
is_mask = days[1:] < split_day
|
||||
oos_mask = ~is_mask
|
||||
sr_is = float(sharpe(net[torch.tensor(is_mask, device=DEV)]))
|
||||
sr_oos = float(sharpe(net[torch.tensor(oos_mask, device=DEV)])) if oos_mask.sum() > 30 else float("nan")
|
||||
|
||||
# Deflated Sharpe — HONEST n_trials = trend-floor variants tried (sign + continuous ≈ 3),
|
||||
# NOT the 64 unrelated RL-microstructure commits.
|
||||
n_trials = 3
|
||||
sr_daily = full_sr / math.sqrt(252)
|
||||
sr0 = (1/math.sqrt(n)) * ((1-0.5772)*_ppf(1-1.0/n_trials) + 0.5772*_ppf(1-1.0/(n_trials*math.e)))
|
||||
dsr = _ncdf((sr_daily - sr0) * math.sqrt(n - 1))
|
||||
med = float(np.median(oos_sr))
|
||||
|
||||
print("\n================ SURFER PHASE 0 — FLOOR VERDICT (GPU, continuous TSMOM + no-trade band) ================")
|
||||
print(f"device={DEV} roots={len(roots)} days={n} span≈{n/252:.1f}y")
|
||||
print(f"full-sample Sharpe (net) = {full_sr:+.3f} (2x-cost: {full_sr_2x:+.3f})")
|
||||
print(f"IS(<2024) / OOS(>=2024) Sharpe = {sr_is:+.3f} / {sr_oos:+.3f}")
|
||||
print(f"CPCV 45 paths: median={med:+.3f} 5th-pct={oos_5pct:+.3f}")
|
||||
print(f"Deflated Sharpe (n_trials={n_trials}) = {dsr:.3f}")
|
||||
print("---- DEVELOP-grade (is there a real edge worth building on?) ----")
|
||||
d1, d2, d3 = med > 0, (sr_is > 0 and sr_oos > 0), full_sr_2x > 0
|
||||
print(f" D1 CPCV median > 0 : {'PASS' if d1 else 'FAIL'} ({med:+.3f})")
|
||||
print(f" D2 IS & OOS both positive : {'PASS' if d2 else 'FAIL'} ({sr_is:+.2f}/{sr_oos:+.2f})")
|
||||
print(f" D3 survives 2x cost : {'PASS' if d3 else 'FAIL'} ({full_sr_2x:+.3f})")
|
||||
print("---- DEPLOY-grade (bet real money?) ----")
|
||||
p1, p3 = oos_5pct > 0, dsr > 0.95
|
||||
print(f" P1 CPCV 5th-pct OOS > 0 : {'PASS' if p1 else 'FAIL'} ({oos_5pct:+.3f})")
|
||||
print(f" P3 Deflated Sharpe > 0.95 : {'PASS' if p3 else 'FAIL'} ({dsr:.3f})")
|
||||
print(f"==> DEVELOP: {'PASS → real edge; build the ML regime overlay (must beat this floor OOS)' if (d1 and d2 and d3) else 'FAIL → no developable edge'}")
|
||||
print(f"==> DEPLOY : {'PASS' if (p1 and p3) else 'NOT YET (expected for a bare floor; ML overlay + tuning target this)'}")
|
||||
|
||||
|
||||
def _ppf(p): # inverse normal cdf (Acklam approx)
|
||||
from statistics import NormalDist
|
||||
return NormalDist().inv_cdf(p)
|
||||
def _ncdf(x):
|
||||
from statistics import NormalDist
|
||||
return NormalDist().cdf(x)
|
||||
|
||||
|
||||
def main():
|
||||
if DEV is None:
|
||||
raise SystemExit("CUDA not available to torch")
|
||||
roots, days, C = build_panel()
|
||||
if not roots:
|
||||
raise SystemExit("no data in data/surfer/*.dbn yet")
|
||||
run(roots, days, C)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
80
scripts/surfer/fng_gate.py
Normal file
80
scripts/surfer/fng_gate.py
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Phase A (non-price) — Fear & Greed sentiment gate (market-timing signal).
|
||||
|
||||
Does crypto sentiment (alternative.me Fear & Greed, daily since 2018) predict forward
|
||||
crypto-market returns? Contrarian hypothesis: extreme fear -> buy, extreme greed -> sell.
|
||||
Market = equal-weight return over the crypto_pit universe. Tests contrarian-level,
|
||||
extreme-only, and sentiment-change signals -> forward 1/7/30d returns, IC + timing-strategy
|
||||
Sharpe (net 5bp), per-year, OOS. A market-timing sleeve is directional -> diversifies the
|
||||
market-neutral momentum book if it works.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import pit_sweep # noqa: E402
|
||||
from signal_sweep import sharpe_t # noqa: E402
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
DAY = 86_400
|
||||
|
||||
|
||||
def fng():
|
||||
cache = "data/surfer/fng.json"
|
||||
if os.path.exists(cache):
|
||||
return json.load(open(cache))
|
||||
d = json.load(urllib.request.urlopen(urllib.request.Request(
|
||||
"https://api.alternative.me/fng/?limit=0&format=json", headers={"User-Agent": "curl/8"}), timeout=30))["data"]
|
||||
out = {int(r["timestamp"]) // DAY: float(r["value"]) for r in d}
|
||||
os.makedirs("data/surfer", exist_ok=True); json.dump(out, open(cache, "w"))
|
||||
return {int(k): v for k, v in out.items()}
|
||||
|
||||
|
||||
def main():
|
||||
fg = fng()
|
||||
syms, days, close, qv, fund = pit_sweep.load()
|
||||
R = np.zeros_like(close); R[1:] = np.log(close)[1:] - np.log(close)[:-1]; R = np.where(np.isfinite(R), R, 0.0)
|
||||
mkt = {int(days[t]): float(np.nanmean(R[t][np.isfinite(close[t])])) for t in range(len(days))} # EW market ret day t
|
||||
common = sorted(set(fg) & set(mkt))
|
||||
f = np.array([fg[d] for d in common]); r = np.array([mkt[d] for d in common])
|
||||
cum = np.concatenate([[0], np.cumsum(r)])
|
||||
year = (1970 + np.array(common) / 365.25).astype(int)
|
||||
n = len(common); split = int(0.7 * n)
|
||||
|
||||
def fwd(h):
|
||||
out = np.full(n, np.nan)
|
||||
out[:n - h] = cum[h + 1:n + 1] - cum[1:n - h + 1] # sum of mkt returns t+1..t+h
|
||||
return out
|
||||
|
||||
sigs = {
|
||||
"contrarian_level": -(f - 50),
|
||||
"extreme_only": np.where(f < 25, 1.0, np.where(f > 75, -1.0, 0.0)),
|
||||
"sentiment_chg": np.concatenate([[np.nan] * 7, f[7:] - f[:-7]]), # rising sentiment (momentum)
|
||||
}
|
||||
print(f"\n===== FEAR&GREED GATE — market-timing, {n} days ({common[0]}..{common[-1]}), cost 5bp =====")
|
||||
print(f"{'signal':>16} " + " ".join(f"h={h}:IC" for h in [1, 7, 30]) + " timing(h=7): full/IS/OOS/peryr")
|
||||
for nm, s in sigs.items():
|
||||
ics = []
|
||||
for h in [1, 7, 30]:
|
||||
fw = fwd(h); m = np.isfinite(s) & np.isfinite(fw)
|
||||
ics.append(float(np.corrcoef(s[m], fw[m])[0, 1]) if m.sum() > 100 else float("nan"))
|
||||
# timing strategy at h=7 (weekly hold): position = sign(s) lagged, daily mkt return, ~weekly turnover
|
||||
pos = np.sign(s); pnl = np.full(n, np.nan)
|
||||
pnl[1:] = pos[:-1] * r[1:]
|
||||
turn = np.abs(np.diff(np.nan_to_num(pos)))
|
||||
pnl[1:] -= turn * 5e-4
|
||||
T = lambda x: torch.tensor(x[np.isfinite(x)], device=DEV, dtype=torch.float64)
|
||||
full = sharpe_t(T(pnl)); isr = sharpe_t(T(pnl[:split])); oos = sharpe_t(T(pnl[split:]))
|
||||
py = " ".join(f"{y}:{sharpe_t(T(pnl[year==y])):+.1f}" for y in range(2019, 2027) if (year == y).sum() > 60)
|
||||
print(f"{nm:>16} " + " ".join(f"{ic:+.3f}" for ic in ics) + f" {full:+.2f}/{isr:+.2f}/{oos:+.2f} {py}")
|
||||
print("\nVERDICT: contrarian IC>0 (fear->up) + timing OOS Sharpe>0 net = real sentiment signal (directional sleeve).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
16
scripts/surfer/fxhnt_forward_cron.sh
Executable file
16
scripts/surfer/fxhnt_forward_cron.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# fxhnt combined-book daily forward-validation.
|
||||
# Refresh the data, then book the new day's paper-NAV into the frozen forward track.
|
||||
# - crypto X-sec momentum sleeve (alpha): free Binance fetch, advances daily
|
||||
# - futures trend sleeve (hedge): Databento ohlcv, cost-capped in fetch_daily.py
|
||||
# - fxhnt forward-track: books only days AFTER inception (genuine out-of-sample-in-time)
|
||||
set -u
|
||||
cd /home/jgrusewski/Work/foxhunt || exit 1
|
||||
[ -f "$HOME/.secrets" ] && . "$HOME/.secrets" # DATABENTO_API_KEY (gitignored, never printed)
|
||||
echo "===== $(date -u '+%Y-%m-%d %H:%M UTC') ====="
|
||||
python3 scripts/surfer/fetch_crypto_pit.py 2>&1 | tail -1 # daily, free (Binance) — alpha sleeve
|
||||
if [ "$(date -u +%u)" -eq 7 ]; then # futures only on Sundays — cost control
|
||||
echo "[sunday] refreshing futures (Databento)"
|
||||
python3 scripts/surfer/fetch_daily.py 2>&1 | tail -2
|
||||
fi
|
||||
/home/jgrusewski/.local/bin/fxhnt forward-track 2>&1 | grep -vE "BentoWarning|Warning|RuntimeWarning|divide|invalid value|stddev"
|
||||
113
scripts/surfer/growth_discipline_backtest.py
Normal file
113
scripts/surfer/growth_discipline_backtest.py
Normal file
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Growth-discipline multi-premia backtest: high-equity (QQQ) core + trend crisis-alpha sleeve +
|
||||
diversified book, mechanically rebalanced, optional cheap-futures leverage. vs QQQ / book / 70-30.
|
||||
Also tests adding VRP (PUTW put-write) as a sleeve. Honest: execution-alpha (behavior gap, tax-loss
|
||||
harvest) is real but counterfactual/account-dependent -> NOT in the backtest; noted as +1-3%/yr on top.
|
||||
|
||||
python3 growth_discipline_backtest.py [--fin 0.03]
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from multistrat_paper import book_series # noqa: E402
|
||||
|
||||
|
||||
def yh(s):
|
||||
try:
|
||||
r = json.loads(urllib.request.urlopen(urllib.request.Request(
|
||||
f"https://query1.finance.yahoo.com/v8/finance/chart/{s}?interval=1d&range=25y",
|
||||
headers={"User-Agent": "Mozilla/5.0"}), timeout=30).read())["chart"]["result"][0]
|
||||
a = r["indicators"].get("adjclose", [{}])[0].get("adjclose") or r["indicators"]["quote"][0]["close"]
|
||||
return {datetime.datetime.utcfromtimestamp(t).strftime("%Y-%m-%d"): float(c) for t, c in zip(r["timestamp"], a) if c}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
FIN = float(sys.argv[sys.argv.index("--fin") + 1]) if "--fin" in sys.argv else 0.03
|
||||
|
||||
|
||||
def build(syms):
|
||||
data = {s: yh(s) for s in syms}
|
||||
common = sorted(set.intersection(*[set(data[s]) for s in syms]))
|
||||
yrs = (datetime.date.fromisoformat(common[-1]) - datetime.date.fromisoformat(common[0])).days / 365.25
|
||||
T = len(common)
|
||||
P = {s: np.array([data[s][d] for d in common]) for s in syms}
|
||||
R = {s: np.concatenate([[0], P[s][1:] / P[s][:-1] - 1]) for s in syms}
|
||||
return common, yrs, T, P, R
|
||||
|
||||
|
||||
def trend_sleeve(P, R, syms, T):
|
||||
"""12-1 TS-momentum long/short on syms, equal-risk = managed-futures proxy."""
|
||||
Pm = np.column_stack([P[s] for s in syms]); Rm = np.column_stack([R[s] for s in syms])
|
||||
lc = np.log(Pm); sg = np.zeros((T, len(syms))); sg[252:] = np.sign(lc[252:] - lc[:-252])
|
||||
vol = np.full((T, len(syms)), np.nan)
|
||||
for t in range(63, T):
|
||||
vol[t] = Rm[t - 63:t].std(0)
|
||||
iv = 1 / np.where(vol > 0, vol, np.nan); w = np.nan_to_num(sg * iv)
|
||||
g = np.abs(w).sum(1, keepdims=True); g[g == 0] = 1; w = w / g
|
||||
tr = np.zeros(T); tr[1:] = np.sum(w[:-1] * Rm[1:], axis=1); return tr
|
||||
|
||||
|
||||
def report(title, cands, yrs):
|
||||
def stats(r):
|
||||
r = np.nan_to_num(r); eq = np.cumprod(1 + r); c = eq[-1] ** (1 / yrs) - 1
|
||||
v = r.std() * math.sqrt(252); dd = float((eq / np.maximum.accumulate(eq) - 1).min())
|
||||
sh = (r.mean() * 252 - FIN) / v if v > 0 else 0
|
||||
return c, v, sh, dd
|
||||
def fv(c, P0=360000, pmt=8000, years=20):
|
||||
rm = c / 12; N = years * 12; gg = (1 + rm) ** N; return P0 * gg + pmt * ((gg - 1) / rm)
|
||||
print(f"\n=== {title} ===")
|
||||
print(f" {'strategy':>30} | CAGR | vol | Shrp* | maxDD | $360k+$8k/mo 20y")
|
||||
for nm, r in cands:
|
||||
c, v, sh, dd = stats(r)
|
||||
print(f" {nm:>30} | {100*c:>4.1f}% | {100*v:>3.0f}% | {sh:>+4.2f} | {100*dd:>+4.0f}% | ${fv(c):>13,.0f}")
|
||||
|
||||
|
||||
def main():
|
||||
# A) long history 2006-2026
|
||||
common, yrs, T, P, R = build(["QQQ", "SPY", "IEF", "GLD", "DBC"])
|
||||
tr = trend_sleeve(P, R, ["SPY", "IEF", "GLD", "DBC"], T)
|
||||
R4 = np.column_stack([R[s] for s in ["SPY", "IEF", "GLD", "DBC"]])
|
||||
book, _, _ = book_series(np.column_stack([R4, tr]))
|
||||
qqq = R["QQQ"]
|
||||
def lev(r, L): return L * r - (L - 1) * FIN / 252
|
||||
gd70 = 0.70 * qqq + 0.15 * tr + 0.15 * book
|
||||
gd60 = 0.60 * qqq + 0.20 * tr + 0.20 * book
|
||||
report(f"A) Long history {common[0]}..{common[-1]} ({yrs:.0f}y), fin {100*FIN:.0f}%", [
|
||||
("QQQ buy-hold", qqq),
|
||||
("book (diversified)", book),
|
||||
("70/30 QQQ+book", 0.7 * qqq + 0.3 * book),
|
||||
("GD-70 (70Q/15trend/15book)", gd70),
|
||||
("GD-60 (60Q/20trend/20book)", gd60),
|
||||
("GD-70 @1.2x cheap lev", lev(gd70, 1.2)),
|
||||
("GD-70 @1.3x cheap lev", lev(gd70, 1.3)),
|
||||
], yrs)
|
||||
print(" *Sharpe = excess over financing. (Execution-alpha — behavior gap ~1-2%/yr, tax-loss")
|
||||
print(" harvest ~0.5-1.5%/yr taxable — is REAL but counterfactual, NOT in these numbers: add on top.)")
|
||||
|
||||
# B) recent window incl VRP (PUTW) + real managed-futures (DBMF)
|
||||
common2, yrs2, T2, P2, R2 = build(["QQQ", "SPY", "IEF", "GLD", "DBC", "PUTW", "DBMF"])
|
||||
tr2 = trend_sleeve(P2, R2, ["SPY", "IEF", "GLD", "DBC"], T2)
|
||||
book2, _, _ = book_series(np.column_stack([np.column_stack([R2[s] for s in ["SPY", "IEF", "GLD", "DBC"]]), tr2]))
|
||||
q2 = R2["QQQ"]; putw = R2["PUTW"]; dbmf = R2["DBMF"]
|
||||
base = 0.70 * q2 + 0.15 * dbmf + 0.15 * book2 # GD with REAL managed-futures ETF
|
||||
with_vrp = 0.60 * q2 + 0.15 * dbmf + 0.15 * book2 + 0.10 * putw
|
||||
report(f"B) Recent {common2[0]}..{common2[-1]} ({yrs2:.0f}y) — does VRP add?", [
|
||||
("QQQ buy-hold", q2),
|
||||
("GD (70Q/15DBMF/15book)", base),
|
||||
("GD + 10% VRP(PUTW)", with_vrp),
|
||||
("PUTW alone (VRP)", putw),
|
||||
("DBMF alone (trend)", dbmf),
|
||||
], yrs2)
|
||||
print(" carry: no clean liquid retail ETF (DBV-type funds are tiny/closed) -> not testable cleanly here.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
82
scripts/surfer/growth_discipline_paper.py
Normal file
82
scripts/surfer/growth_discipline_paper.py
Normal file
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""growth-discipline — forward-tracker (NAV simulation), works exactly like multistrat_paper.py.
|
||||
|
||||
Strategy: GD = w·QQQ (growth core) + (1-w)·multi-strat book (diversified low-dd sleeve), continuously
|
||||
rebalanced. Runs ALONGSIDE the live IBKR-paper book to compare forward: more CAGR, deeper drawdown.
|
||||
No capital, no orders — pure NAV tracking (the live book is the IBKR-paper one). Cron daily.
|
||||
|
||||
python3 growth_discipline_paper.py status cum return + ann-Sharpe + maxDD + current weights
|
||||
python3 growth_discipline_paper.py run book new trading days, persist (cron)
|
||||
python3 growth_discipline_paper.py weights [USD] today's $ split (QQQ vs book)
|
||||
|
||||
Env: GD_EQUITY_WEIGHT (default 0.70). Caveat: backtest GD-70 ~13%/yr CAGR, -38% maxDD (2006-26);
|
||||
execution-alpha (behavior gap, tax-harvest) is on top, not modeled here.
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from multistrat_paper import build, book_series, yhist # noqa: E402
|
||||
|
||||
_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
STATE = os.path.join(_REPO, "data/surfer/gd_paper_state.json")
|
||||
WEQ = float(os.environ.get("GD_EQUITY_WEIGHT", "0.70"))
|
||||
|
||||
|
||||
def build_gd():
|
||||
"""GD daily returns aligned to the book's trading dates."""
|
||||
dates, R = build() # book instruments (SPY/IEF/GLD/PDBC/DBMF/BTC)
|
||||
book, _, _ = book_series(R)
|
||||
qp = yhist("QQQ") # {date: adjclose}
|
||||
q = np.array([qp.get(d, np.nan) for d in dates])
|
||||
qr = np.zeros(len(dates))
|
||||
qr[1:] = np.where(np.isfinite(q[1:]) & np.isfinite(q[:-1]), q[1:] / q[:-1] - 1, 0.0)
|
||||
gd = WEQ * qr + (1 - WEQ) * book
|
||||
return dates, gd
|
||||
|
||||
|
||||
def load_state():
|
||||
if os.path.exists(STATE):
|
||||
return json.load(open(STATE))
|
||||
return {"last_date": "", "days": 0, "sum_r": 0.0, "sumsq_r": 0.0, "equity": 1.0, "peak": 1.0, "max_dd": 0.0}
|
||||
|
||||
|
||||
def main():
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "status"
|
||||
if cmd in ("run", "paper"):
|
||||
st = load_state(); dates, gd = build_gd()
|
||||
os.makedirs(os.path.dirname(STATE), exist_ok=True)
|
||||
if not st["last_date"]:
|
||||
st["last_date"] = dates[-1]; json.dump(st, open(STATE, "w"))
|
||||
print(f"GD forward tracking initialized from {dates[-1]} (w_QQQ={WEQ})"); return
|
||||
booked = 0
|
||||
for i in range(1, len(dates)):
|
||||
if dates[i] <= st["last_date"]:
|
||||
continue
|
||||
r = float(gd[i])
|
||||
st["days"] += 1; st["sum_r"] += r; st["sumsq_r"] += r * r
|
||||
st["equity"] *= (1 + r); st["peak"] = max(st["peak"], st["equity"])
|
||||
st["max_dd"] = min(st["max_dd"], st["equity"] / st["peak"] - 1); st["last_date"] = dates[i]; booked += 1
|
||||
json.dump(st, open(STATE, "w"))
|
||||
print(f"{datetime.date.today()} GD booked {booked} day(s) -> day {st['days']} (through {st['last_date']}) cum {100*(st['equity']-1):+.2f}%")
|
||||
elif cmd == "weights":
|
||||
cap = float(sys.argv[2]) if len(sys.argv) > 2 else 35000.0
|
||||
print(f"GD target split for ${cap:,.0f} (continuously rebalanced):")
|
||||
print(f" {'QQQ (growth core)':>22}: {100*WEQ:>5.1f}% ${cap*WEQ:>10,.0f}")
|
||||
print(f" {'multi-strat book':>22}: {100*(1-WEQ):>5.1f}% ${cap*(1-WEQ):>10,.0f}")
|
||||
else:
|
||||
st = load_state()
|
||||
n = st["days"]; m = (st["sum_r"] / n) if n else 0; var = (st["sumsq_r"] / n - m * m) if n > 1 else 0
|
||||
sh = (m * 252) / (math.sqrt(max(var, 1e-12)) * math.sqrt(252)) if n > 1 and var > 0 else float("nan")
|
||||
print(f"growth-discipline paper (w_QQQ={WEQ}) — day {n} (through {st['last_date'] or 'n/a'})")
|
||||
print(f" cum {100*(st['equity']-1):+.2f}% ann-Sharpe {sh:+.2f} maxDD {100*st['max_dd']:+.1f}%")
|
||||
print(f" (compare vs the live IBKR-paper book; GD = higher CAGR, deeper drawdown by design)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
101
scripts/surfer/growth_fusion_backtest.py
Normal file
101
scripts/surfer/growth_fusion_backtest.py
Normal file
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""FUSION backtest: regime-adaptive QQQ <-> crisis-alpha rotation (the synthesis of all the ideas).
|
||||
|
||||
QQQ core whose weight is driven by edge-decay-TRUST (trailing risk-adjusted health, EMA-smoothed,
|
||||
resurrection-capable) — NOT vol-normalized (so it keeps QQQ's CAGR in calm regimes). When QQQ's trust
|
||||
decays (sustained downtrend/crisis) it ROTATES into the crisis-alpha sleeve (trend / diversified book,
|
||||
which RISES in crises) instead of to cash; resurrects back to QQQ as it heals. Plus a light drawdown
|
||||
circuit-breaker as a tail backstop. Honest test: vs QQQ / book / static GD, full + IS/OOS split +
|
||||
turnover (the whipsaw cost). Verdict: fusion must beat static GD on CAGR-per-drawdown (Calmar), esp OOS.
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from multistrat_paper import book_series # noqa: E402
|
||||
|
||||
|
||||
def yh(s):
|
||||
r = json.loads(urllib.request.urlopen(urllib.request.Request(
|
||||
f"https://query1.finance.yahoo.com/v8/finance/chart/{s}?interval=1d&range=25y",
|
||||
headers={"User-Agent": "Mozilla/5.0"}), timeout=30).read())["chart"]["result"][0]
|
||||
a = r["indicators"].get("adjclose", [{}])[0].get("adjclose") or r["indicators"]["quote"][0]["close"]
|
||||
return {datetime.datetime.utcfromtimestamp(t).strftime("%Y-%m-%d"): float(c) for t, c in zip(r["timestamp"], a) if c}
|
||||
|
||||
|
||||
def edge_decay_trust(ret, win=126, ema=0.94):
|
||||
"""trailing-Sharpe health -> theta in [0.1, 1], EMA-smoothed, resurrection-capable."""
|
||||
T = len(ret); th = 1.0; out = np.ones(T)
|
||||
for t in range(win, T):
|
||||
seg = ret[t - win:t]; sr = seg.mean() / (seg.std() + 1e-9) * math.sqrt(252)
|
||||
target = float(np.clip((sr + 0.5) / 1.0, 0.10, 1.0))
|
||||
th = ema * th + (1 - ema) * target; out[t] = th
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
syms = ["QQQ", "SPY", "IEF", "GLD", "DBC"]
|
||||
data = {s: yh(s) for s in syms}
|
||||
common = sorted(set.intersection(*[set(data[s]) for s in syms])); T = len(common)
|
||||
yrs = (datetime.date.fromisoformat(common[-1]) - datetime.date.fromisoformat(common[0])).days / 365.25
|
||||
P = {s: np.array([data[s][d] for d in common]) for s in syms}
|
||||
R = {s: np.concatenate([[0], P[s][1:] / P[s][:-1] - 1]) for s in syms}
|
||||
qqq = R["QQQ"]
|
||||
R4 = np.column_stack([R[s] for s in ["SPY", "IEF", "GLD", "DBC"]]); P4 = np.column_stack([P[s] for s in ["SPY", "IEF", "GLD", "DBC"]])
|
||||
# trend sleeve (12-1 TS-mom, equal-risk = managed-futures proxy) + diversified book
|
||||
lc = np.log(P4); sg = np.zeros((T, 4)); sg[252:] = np.sign(lc[252:] - lc[:-252])
|
||||
vol = np.full((T, 4), np.nan)
|
||||
for t in range(63, T):
|
||||
vol[t] = R4[t - 63:t].std(0)
|
||||
iv = 1 / np.where(vol > 0, vol, np.nan); w = np.nan_to_num(sg * iv); g = np.abs(w).sum(1, keepdims=True); g[g == 0] = 1; w = w / g
|
||||
trend = np.zeros(T); trend[1:] = np.sum(w[:-1] * R4[1:], axis=1)
|
||||
book, _, _ = book_series(np.column_stack([R4, trend]))
|
||||
|
||||
# FUSION: w_qqq = trust(QQQ); rotate (1-w) into the crisis-alpha sleeve; + tail drawdown-CB
|
||||
theta = edge_decay_trust(qqq)
|
||||
def fusion(sleeve, dd_deadband=0.15):
|
||||
wq = theta.copy(); ws = 1 - wq
|
||||
r = np.zeros(T); eq = pk = 1.0; turn = 0.0
|
||||
for t in range(252, T - 1):
|
||||
base = wq[t] * qqq[t + 1] + ws[t] * sleeve[t + 1]
|
||||
dd = eq / pk - 1
|
||||
cb = float(np.clip(1 - 3 * max(0.0, -dd - dd_deadband), 0.5, 1.0)) # tail backstop only (>15% dd)
|
||||
r[t + 1] = cb * base; eq *= (1 + r[t + 1]); pk = max(pk, eq)
|
||||
turn += abs(wq[t] - wq[t - 1])
|
||||
return r, turn / yrs # annualized one-way turnover of the QQQ leg
|
||||
fus_trend, turn_t = fusion(trend)
|
||||
fus_book, turn_b = fusion(book)
|
||||
|
||||
static_gd = 0.7 * qqq + 0.3 * book
|
||||
|
||||
def stats(r, lo=0, hi=None):
|
||||
seg = r[lo:hi] if hi else r[lo:]; seg = np.nan_to_num(seg)
|
||||
n = (seg != 0).sum(); y = n / 252 if n else 1
|
||||
eq = np.cumprod(1 + seg); cagr = eq[-1] ** (1 / y) - 1 if y > 0 else 0
|
||||
v = seg.std() * math.sqrt(252); dd = float((eq / np.maximum.accumulate(eq) - 1).min())
|
||||
sh = (seg.mean() * 252 - 0.03) / v if v > 0 else 0
|
||||
calmar = cagr / abs(dd) if dd < 0 else 0
|
||||
return cagr, v, sh, dd, calmar
|
||||
sp = int(0.6 * T)
|
||||
print(f"FUSION BACKTEST — {common[0]}..{common[-1]} ({yrs:.0f}y), OOS split @ {common[sp]}")
|
||||
print(f" {'strategy':>26} | seg | CAGR | maxDD | Sharpe* | Calmar | turn/yr")
|
||||
cands = [("QQQ buy-hold", qqq, 0), ("book (diversified)", book, 0), ("static GD 70/30", static_gd, 0),
|
||||
("FUSION QQQ<->trend", fus_trend, turn_t), ("FUSION QQQ<->book", fus_book, turn_b)]
|
||||
for nm, r, turn in cands:
|
||||
for seg, lab in [((0, None), "ALL"), ((0, sp), "IS "), ((sp, None), "OOS")]:
|
||||
c, v, sh, dd, cal = stats(r, seg[0], seg[1])
|
||||
tt = f"{turn:.1f}x" if (lab == "ALL" and turn) else ""
|
||||
print(f" {nm if lab=='ALL' else '':>26} | {lab} | {100*c:>4.1f}% | {100*dd:>+4.0f}% | {sh:>+5.2f} | {cal:>5.2f} | {tt}")
|
||||
print(" *Sharpe excess over financing. Calmar = CAGR/|maxDD| (higher=better frontier).")
|
||||
print(" VERDICT: FUSION beats static GD on OOS Calmar = regime-rotation shifts the frontier out (worth it).")
|
||||
print(" If not = whipsaw eats it; keep the simpler static GD.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
134
scripts/surfer/ibkr_paper_book.py
Normal file
134
scripts/surfer/ibkr_paper_book.py
Normal file
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the adaptive multi-strat book on IBKR PAPER trading (realistic fills, no capital).
|
||||
|
||||
Gets target weights from the harness (Yahoo data), connects to your IB Gateway/TWS paper account,
|
||||
and rebalances the ETF book on it. Crypto sleeve -> IBIT (Bitcoin ETF: in-brokerage, no crypto-
|
||||
exchange counterparty tail). Default DRY-RUN (shows intended orders); pass `rebalance` to place them.
|
||||
|
||||
SETUP (you do this — your credentials, never me):
|
||||
1. Create a free IBKR paper-trading account (or use your live account's paper login).
|
||||
2. Run IB Gateway (or TWS) and log in to the PAPER account.
|
||||
3. In Gateway/TWS: API settings -> enable "ActiveX and Socket Clients", note the port
|
||||
(IB Gateway paper = 4002, TWS paper = 7497). Add 127.0.0.1 to trusted IPs.
|
||||
4. Then: python3 scripts/surfer/ibkr_paper_book.py status (test the connection)
|
||||
python3 scripts/surfer/ibkr_paper_book.py dry (show intended orders)
|
||||
python3 scripts/surfer/ibkr_paper_book.py rebalance (place orders on PAPER)
|
||||
Override port: ... <mode> --port 7497
|
||||
|
||||
Sizing uses Yahoo last-close (consistent with the book); IBKR fills at market. Hysteresis: only
|
||||
trade a name if its target $ drifts > 3% of NLV. Run weekly/monthly via cron once validated.
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from multistrat_paper import build, book_series, INSTR # noqa: E402
|
||||
|
||||
TICKER = {"equity": "SPY", "bond": "IEF", "gold": "GLD", "commod": "PDBC", "trend": "DBMF", "crypto": "IBIT"}
|
||||
HYST = 0.03 # only trade if target weight drifts > 3% of NLV
|
||||
|
||||
|
||||
def yhist_last(sym):
|
||||
res = json.loads(urllib.request.urlopen(urllib.request.Request(
|
||||
f"https://query1.finance.yahoo.com/v8/finance/chart/{sym}?interval=1d&range=5d",
|
||||
headers={"User-Agent": "Mozilla/5.0"}), timeout=30).read())["chart"]["result"][0]
|
||||
c = [x for x in res["indicators"]["quote"][0]["close"] if x is not None]
|
||||
return float(c[-1])
|
||||
|
||||
|
||||
def target_book():
|
||||
"""{ticker: weight} from the adaptive harness (Yahoo data)."""
|
||||
dates, R = build()
|
||||
book, w, L = book_series(R)
|
||||
return {TICKER[nm]: float(w[j] * L) for j, (_, nm) in enumerate(INSTR)}
|
||||
|
||||
|
||||
def main():
|
||||
mode = sys.argv[1] if len(sys.argv) > 1 else "dry"
|
||||
port = int(sys.argv[sys.argv.index("--port") + 1]) if "--port" in sys.argv else 4002
|
||||
|
||||
tw = target_book()
|
||||
px = {t: yhist_last(t if t != "IBIT" else "IBIT") for t in tw} # IBIT trades on Yahoo too
|
||||
print(f"target weights (adaptive book, {datetime.date.today()}):")
|
||||
for t, w in tw.items():
|
||||
print(f" {t:>6}: {100*w:>5.1f}% (px ~${px[t]:.2f})")
|
||||
|
||||
if mode == "target":
|
||||
return
|
||||
|
||||
from ib_async import IB, Stock, MarketOrder
|
||||
ib = IB()
|
||||
try:
|
||||
ib.connect("127.0.0.1", port, clientId=7, timeout=15)
|
||||
except Exception as e:
|
||||
print(f"\nCONNECT FAILED on 127.0.0.1:{port} — is IB Gateway/TWS running + logged in to PAPER, API enabled?")
|
||||
print(f" ({type(e).__name__}: {str(e)[:80]}) | try --port 7497 for TWS paper")
|
||||
return
|
||||
accts = ib.managedAccounts()
|
||||
acct = accts[0] if accts else "?"
|
||||
is_paper = acct.startswith("DU") # IBKR convention: DU* = paper/demo, U* = live
|
||||
nlv = next((float(v.value) for v in ib.accountSummary() if v.tag == "NetLiquidation"), 0.0)
|
||||
cash = next((float(v.value) for v in ib.accountSummary() if v.tag == "TotalCashValue"), 0.0)
|
||||
pos = {p.contract.symbol: p.position for p in ib.positions()}
|
||||
print(f"\nIBKR account: {acct} [{'PAPER ✅' if is_paper else 'LIVE ⚠️ — NOT paper!'}] NLV ${nlv:,.0f} cash ${cash:,.0f} | positions: {pos or 'none'}")
|
||||
if nlv <= 0:
|
||||
print(" (no NLV — check the paper account is funded with paper cash)"); ib.disconnect(); return
|
||||
|
||||
if mode == "mini":
|
||||
if not is_paper:
|
||||
print(" ABORT: account is NOT a paper account (no DU prefix). Refusing to place a test order."); ib.disconnect(); return
|
||||
from ib_async import Stock, MarketOrder
|
||||
c = Stock("SPY", "SMART", "USD"); ib.qualifyContracts(c)
|
||||
print("\nplacing MINI test order: BUY 1 SPY (market) on PAPER...")
|
||||
o = MarketOrder("BUY", 1); o.account = acct
|
||||
tr = ib.placeOrder(c, o)
|
||||
for _ in range(20):
|
||||
ib.sleep(1)
|
||||
if tr.orderStatus.status in ("Filled", "Cancelled", "ApiCancelled", "Inactive"):
|
||||
break
|
||||
fills = [f"{f.execution.shares}@${f.execution.price}" for f in tr.fills]
|
||||
print(f" order status: {tr.orderStatus.status} filled {tr.orderStatus.filled}/1 avgPx ${tr.orderStatus.avgFillPrice or 0:.2f} fills={fills or 'none'}")
|
||||
ib.sleep(1)
|
||||
newpos = {p.contract.symbol: p.position for p in ib.positions()}
|
||||
print(f" positions now: {newpos or 'none'}")
|
||||
print(f" → VALIDATED: this is the {'PAPER' if is_paper else 'LIVE'} env, order routing works." if tr.orderStatus.status == "Filled"
|
||||
else " → order not filled yet (market may be closed — it'll fill at next open; status shows it routed).")
|
||||
ib.disconnect(); return
|
||||
|
||||
orders = []
|
||||
for t, w in tw.items():
|
||||
tgt_sh = nlv * w / px[t]
|
||||
cur = pos.get(t, 0.0)
|
||||
delta = tgt_sh - cur
|
||||
# entry from zero uses a small floor (0.5% NLV); rebalancing an existing position uses full hysteresis
|
||||
thresh = (0.005 if cur == 0 else HYST) * nlv
|
||||
if abs(delta * px[t]) > thresh:
|
||||
orders.append((t, "BUY" if delta > 0 else "SELL", round(abs(delta), 4)))
|
||||
print(f"\nintended orders (hysteresis {int(HYST*100)}% of NLV):")
|
||||
for t, side, qty in orders:
|
||||
print(f" {side:>4} {qty:>9.4f} {t} (~${qty*px[t]:,.0f})")
|
||||
if not orders:
|
||||
print(" none — book already within hysteresis band.")
|
||||
|
||||
if mode == "rebalance" and orders:
|
||||
print("\nPLACING on PAPER...")
|
||||
for t, side, qty in orders:
|
||||
c = Stock(t, "SMART", "USD"); ib.qualifyContracts(c)
|
||||
o = MarketOrder(side, qty); o.account = ib.managedAccounts()[0]
|
||||
tr = ib.placeOrder(c, o); ib.sleep(1)
|
||||
print(f" {side} {qty} {t}: {tr.orderStatus.status}")
|
||||
ib.sleep(3)
|
||||
print("done — check fills in Gateway/TWS.")
|
||||
elif orders:
|
||||
print("\n(dry-run — pass 'rebalance' to actually place these on the paper account.)")
|
||||
ib.disconnect()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
163
scripts/surfer/mft_4h_harden.py
Normal file
163
scripts/surfer/mft_4h_harden.py
Normal file
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Harden the 4h MFT trend edge — kill the inflation sources before trusting it.
|
||||
|
||||
The mft_horizon_sweep found a 4h selective trend edge (OOS net +5.9t, t+7.4). But that t is
|
||||
OVERSTATED: overlapping 4h holds across 5-min entries are not independent. This re-tests honestly:
|
||||
|
||||
1. ONE TRADE PER SESSION — the single highest-conviction morning setup, ~4h hold, flat-by-close.
|
||||
Non-overlapping ⇒ trades are ~independent across sessions ⇒ honest t-stat. Also the realistic strat.
|
||||
2. IS-DERIVED conviction cutoff — set on the in-sample sessions, applied to OOS (no pooled-threshold leak).
|
||||
3. REAL RTH session — ET 09:30–16:00 (not UTC-midnight); entry window = first 2.5h so 4h fits before close.
|
||||
4. PER-YEAR breakdown — is the edge in every year, or one regime?
|
||||
5. Cost robustness (1 and 2 ticks) + multiple-testing context.
|
||||
|
||||
Pre-registered: dir=sign(ret over H bars), conviction=|t-stat|=|ret_H|/(vol·√H), vol=trailing 12-bar.
|
||||
Entry = highest-conviction valid bar in 09:30-12:00, exit = +H bars (same session), cost 1 (and 2) ticks.
|
||||
Trade the session only if its best conviction ≥ IS cutoff (selectivity sweep). H=48 (4h) primary; 36/60 robustness.
|
||||
"""
|
||||
import glob
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
TICK = 0.25
|
||||
BAR_NS = 5 * 60 * 10**9
|
||||
HOLDS = [36, 48, 60] # 3h, 4h (primary), 5h
|
||||
ENTRY_CUTOFF_MIN = 12 * 60 # latest entry 12:00 ET so a 4h hold fits before 16:00
|
||||
RTH_LO, RTH_HI = 9 * 60 + 30, 16 * 60
|
||||
|
||||
|
||||
def load_es_5min():
|
||||
import databento as db
|
||||
ts_all, c_all = [], []
|
||||
for p in sorted(glob.glob("data/surfer/es1m/*.dbn")):
|
||||
try:
|
||||
arr = db.DBNStore.from_file(p).to_ndarray()
|
||||
except Exception:
|
||||
continue
|
||||
if len(arr) == 0 or "close" not in arr.dtype.names:
|
||||
continue
|
||||
ts = arr["ts_event"].astype(np.int64)
|
||||
c = arr["close"].astype(np.float64) / 1e9
|
||||
m = c > 0
|
||||
ts_all.append(ts[m]); c_all.append(c[m])
|
||||
ts = np.concatenate(ts_all); c = np.concatenate(c_all)
|
||||
_u, ui = np.unique(ts, return_index=True); ts, c = ts[ui], c[ui]
|
||||
o = np.argsort(ts, kind="stable"); ts, c = ts[o], c[o]
|
||||
b5 = ts // BAR_NS
|
||||
_, first = np.unique(b5, return_index=True)
|
||||
last = np.append(first[1:] - 1, len(b5) - 1)
|
||||
return ts[last], c[last]
|
||||
|
||||
|
||||
def rth_sessions(ts5, close5):
|
||||
"""Restrict to ET RTH 09:30-16:00, return (close, session_id, minute_of_day_ET, year) RTH-only, sorted."""
|
||||
idx = pd.to_datetime(ts5, utc=True).tz_convert("America/New_York")
|
||||
mins = (idx.hour * 60 + idx.minute).to_numpy()
|
||||
rth = (mins >= RTH_LO) & (mins < RTH_HI)
|
||||
et_date = idx.strftime("%Y-%m-%d").to_numpy()
|
||||
year = idx.year.to_numpy()
|
||||
close = close5[rth]; sess_str = et_date[rth]; mod = mins[rth]; yr = year[rth]
|
||||
sid = pd.factorize(sess_str)[0] # contiguous session ids (data already time-sorted)
|
||||
return close, sid, mod, yr
|
||||
|
||||
|
||||
def per_session_trades(close, sid, mod, yr, H, cost):
|
||||
"""For each RTH session pick the single highest-conviction entry in 09:30-12:00, hold H bars to a
|
||||
same-session exit. Returns arrays over sessions that HAVE a valid candidate:
|
||||
(best_conv, net_ticks, year, sess_order)."""
|
||||
lc = np.log(close)
|
||||
n = len(lc)
|
||||
# trailing per-bar vol (12-bar) and H-bar trend, only valid within the same session
|
||||
r1 = np.zeros(n); r1[1:] = lc[1:] - lc[:-1]
|
||||
same1 = np.zeros(n, bool); same1[1:] = sid[1:] == sid[:-1]
|
||||
r1 = np.where(same1, r1, 0.0)
|
||||
# rolling 12-bar std via cumsum
|
||||
w = 12
|
||||
cs = np.concatenate([[0.0], np.cumsum(r1)]); cs2 = np.concatenate([[0.0], np.cumsum(r1 * r1)])
|
||||
vol = np.full(n, np.nan)
|
||||
s = cs[w:] - cs[:-w]; s2 = cs2[w:] - cs2[:-w]
|
||||
vol[w - 1:] = np.sqrt(np.maximum(s2 / w - (s / w) ** 2, 0.0))
|
||||
# trailing H-bar trend over the RTH-contiguous series (overnight gap collapsed — conservative:
|
||||
# the overnight move, where much drift lives, is excluded from the signal). No same-session
|
||||
# requirement on the lookback (a 4h trailing trend can't fit in the morning entry window otherwise).
|
||||
retH = np.full(n, np.nan); retH[H:] = lc[H:] - lc[:-H]
|
||||
tstat = retH / np.maximum(vol * math.sqrt(H), 1e-9)
|
||||
# forward H-bar move (ticks), exit MUST be same session (flat-by-close)
|
||||
fwd = np.full(n, np.nan); fwd[:-H] = (close[H:] - close[:-H]) / TICK
|
||||
sameH_fwd = np.zeros(n, bool); sameH_fwd[:-H] = sid[:-H] == sid[H:]
|
||||
entry_ok = (mod <= ENTRY_CUTOFF_MIN) & sameH_fwd & np.isfinite(tstat) & np.isfinite(fwd) & (vol > 0)
|
||||
direction = np.sign(retH)
|
||||
net = direction * fwd - cost
|
||||
conv = np.abs(tstat)
|
||||
|
||||
out_conv, out_net, out_yr, out_ord = [], [], [], []
|
||||
# group by session, pick max-conviction valid entry
|
||||
order = np.argsort(sid, kind="stable")
|
||||
sid_s = sid[order]
|
||||
bounds = np.append(np.append(0, np.nonzero(np.diff(sid_s))[0] + 1), len(sid_s))
|
||||
for b in range(len(bounds) - 1):
|
||||
rows = order[bounds[b]:bounds[b + 1]]
|
||||
rows = rows[entry_ok[rows] & (direction[rows] != 0)]
|
||||
if len(rows) == 0:
|
||||
continue
|
||||
best = rows[np.argmax(conv[rows])]
|
||||
out_conv.append(conv[best]); out_net.append(net[best])
|
||||
out_yr.append(yr[best]); out_ord.append(sid[best])
|
||||
return (np.array(out_conv), np.array(out_net), np.array(out_yr), np.array(out_ord))
|
||||
|
||||
|
||||
def stats(net):
|
||||
n = len(net)
|
||||
if n < 20:
|
||||
return n, float("nan"), float("nan"), float("nan"), float("nan")
|
||||
m = float(net.mean()); sd = float(net.std()) + 1e-12
|
||||
t = m / (sd / math.sqrt(n))
|
||||
hit = 100.0 * float((net > 0).mean())
|
||||
ann_sharpe = (m / sd) * math.sqrt(252.0) # ~1 trade/session ⇒ ~252 trades/yr
|
||||
return n, m, hit, t, ann_sharpe
|
||||
|
||||
|
||||
def run():
|
||||
ts5, close5 = load_es_5min()
|
||||
close, sid, mod, yr = rth_sessions(ts5, close5)
|
||||
n_sess = int(sid.max()) + 1
|
||||
print("\n================ 4h MFT HARDENING (RTH, 1 trade/session, IS-cutoff, per-year) ================")
|
||||
print(f"RTH 5-min bars={len(close)} sessions={n_sess} years={int(yr.min())}-{int(yr.max())}")
|
||||
print("honest non-overlapping t-stat (1 trade/session); multiple-testing bar ≈ |t|>2.3 for ~10 configs\n")
|
||||
|
||||
for cost in (1.0, 2.0):
|
||||
print(f"================= COST = {cost:.0f} tick round-trip =================")
|
||||
for H in HOLDS:
|
||||
conv, net, tyr, sord = per_session_trades(close, sid, mod, yr, H, cost)
|
||||
if len(conv) < 50:
|
||||
print(f" H={H}: too few sessions with a candidate ({len(conv)})"); continue
|
||||
split = np.quantile(sord, 0.70) # first 70% of sessions = IS
|
||||
is_m = sord < split; oos_m = ~is_m
|
||||
hh = H * 5 / 60.0
|
||||
print(f" --- H={H} ({hh:.0f}h hold) --- (IS sessions {int(is_m.sum())} / OOS {int(oos_m.sum())})")
|
||||
print(f" {'select':>8} {'cutoff':>7} {'n_OOS':>6} {'net_t':>7} {'hit%':>6} {'t':>6} {'annSR':>6} {'trd/yr':>7}")
|
||||
for sel in (1.00, 0.50, 0.25, 0.10): # trade top-sel% of IS-conviction days
|
||||
cutoff = np.quantile(conv[is_m], 1.0 - sel) if sel < 1.0 else -np.inf
|
||||
take = oos_m & (conv >= cutoff)
|
||||
n, m, hit, t, sr = stats(net[take])
|
||||
oos_years = (sord[oos_m].max() - split) / n_sess * (int(yr.max()) - int(yr.min()) + 1)
|
||||
trd_yr = n / max(oos_years, 1e-9)
|
||||
cuts = f"{cutoff:.2f}" if np.isfinite(cutoff) else "all"
|
||||
print(f" {sel:>8.2f} {cuts:>7} {n:>6} {m:>+7.2f} {hit:>6.1f} {t:>+6.2f} {sr:>+6.2f} {trd_yr:>7.1f}")
|
||||
# per-year at the pre-registered selectivity = top 25% of IS-conviction days
|
||||
cutoff = np.quantile(conv[is_m], 0.75)
|
||||
print(f" per-year (sel=0.25, cost={cost:.0f}, OOS+IS shown; IS years are not OOS):")
|
||||
for y in range(int(yr.min()), int(yr.max()) + 1):
|
||||
ym = (tyr == y) & (conv >= cutoff)
|
||||
if int(ym.sum()) < 10:
|
||||
continue
|
||||
n, m, hit, t, sr = stats(net[ym])
|
||||
flag = "OOS" if (np.median(sord[tyr == y]) >= split) else "is "
|
||||
print(f" {y} [{flag}] n={n:>4} net={m:>+6.2f}t hit={hit:>5.1f}% t={t:>+5.2f}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
114
scripts/surfer/mft_harden_v2.py
Normal file
114
scripts/surfer/mft_harden_v2.py
Normal file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apples-to-apples hardening of the sweep's OWN 4h edge — same 24h-continuous signal, but kill the
|
||||
statistical inflation: ONE non-overlapping trade per UTC-day, IS-derived conviction cutoff, per-year.
|
||||
|
||||
The sweep (mft_horizon_sweep) used a 24h-continuous 5-min grid, day = UTC-day, hold = 48 bars = 4h of
|
||||
clock time, top-decile conviction over HEAVILY OVERLAPPING entries → t+7.4 (inflated by overlap). Here:
|
||||
same grid + signal, but max-conviction single entry per UTC-day (non-overlapping ⇒ honest t), threshold
|
||||
fit on IS, reported per-year. cost 1 and 2 ticks. PASS: OOS net>0 & hit>50% & honest t≥2, stable per-year.
|
||||
"""
|
||||
import glob
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
|
||||
TICK = 0.25
|
||||
BAR_NS = 5 * 60 * 10**9
|
||||
DAY_NS = 86_400 * 10**9
|
||||
HOLDS = [48] # 4h (the sweep's standout); same grid as the sweep
|
||||
|
||||
|
||||
def load_es_5min():
|
||||
import databento as db
|
||||
ts_all, c_all = [], []
|
||||
for p in sorted(glob.glob("data/surfer/es1m/*.dbn")):
|
||||
try:
|
||||
arr = db.DBNStore.from_file(p).to_ndarray()
|
||||
except Exception:
|
||||
continue
|
||||
if len(arr) == 0 or "close" not in arr.dtype.names:
|
||||
continue
|
||||
ts = arr["ts_event"].astype(np.int64); c = arr["close"].astype(np.float64) / 1e9
|
||||
m = c > 0; ts_all.append(ts[m]); c_all.append(c[m])
|
||||
ts = np.concatenate(ts_all); c = np.concatenate(c_all)
|
||||
_u, ui = np.unique(ts, return_index=True); ts, c = ts[ui], c[ui]
|
||||
o = np.argsort(ts, kind="stable"); ts, c = ts[o], c[o]
|
||||
b5 = ts // BAR_NS
|
||||
_, first = np.unique(b5, return_index=True)
|
||||
last = np.append(first[1:] - 1, len(b5) - 1)
|
||||
return ts[last], c[last]
|
||||
|
||||
|
||||
def per_day_trades(close, day, yr, H, cost):
|
||||
lc = np.log(close); n = len(lc)
|
||||
r1 = np.zeros(n); r1[1:] = lc[1:] - lc[:-1]
|
||||
same1 = np.zeros(n, bool); same1[1:] = day[1:] == day[:-1]
|
||||
r1 = np.where(same1, r1, 0.0)
|
||||
w = 12
|
||||
cs = np.concatenate([[0.0], np.cumsum(r1)]); cs2 = np.concatenate([[0.0], np.cumsum(r1 * r1)])
|
||||
vol = np.full(n, np.nan); s = cs[w:] - cs[:-w]; s2 = cs2[w:] - cs2[:-w]
|
||||
vol[w - 1:] = np.sqrt(np.maximum(s2 / w - (s / w) ** 2, 0.0))
|
||||
retH = np.full(n, np.nan); retH[H:] = lc[H:] - lc[:-H]
|
||||
sameH_back = np.zeros(n, bool); sameH_back[H:] = day[H:] == day[:-H] # like the sweep: sameday(H)
|
||||
tstat = retH / np.maximum(vol * math.sqrt(H), 1e-9)
|
||||
fwd = np.full(n, np.nan); fwd[:-H] = (close[H:] - close[:-H]) / TICK
|
||||
sameH_fwd = np.zeros(n, bool); sameH_fwd[:-H] = day[:-H] == day[H:]
|
||||
ok = sameH_fwd & sameH_back & np.isfinite(tstat) & np.isfinite(fwd) & (vol > 0)
|
||||
direction = np.sign(retH); net = direction * fwd - cost; conv = np.abs(tstat)
|
||||
|
||||
oc, on, oy, od = [], [], [], []
|
||||
order = np.argsort(day, kind="stable"); ds = day[order]
|
||||
bounds = np.append(np.append(0, np.nonzero(np.diff(ds))[0] + 1), len(ds))
|
||||
for b in range(len(bounds) - 1):
|
||||
rows = order[bounds[b]:bounds[b + 1]]
|
||||
rows = rows[ok[rows] & (direction[rows] != 0)]
|
||||
if len(rows) == 0:
|
||||
continue
|
||||
best = rows[np.argmax(conv[rows])]
|
||||
oc.append(conv[best]); on.append(net[best]); oy.append(yr[best]); od.append(day[best])
|
||||
return np.array(oc), np.array(on), np.array(oy), np.array(od)
|
||||
|
||||
|
||||
def stats(net):
|
||||
n = len(net)
|
||||
if n < 20:
|
||||
return n, float("nan"), float("nan"), float("nan"), float("nan")
|
||||
m = float(net.mean()); sd = float(net.std()) + 1e-12
|
||||
return n, m, 100.0 * float((net > 0).mean()), m / (sd / math.sqrt(n)), (m / sd) * math.sqrt(252.0)
|
||||
|
||||
|
||||
def run():
|
||||
ts5, close5 = load_es_5min()
|
||||
day = (ts5 // DAY_NS).astype(np.int64)
|
||||
yr = (1970 + (ts5 // (365.25 * DAY_NS))).astype(int) # approx calendar year from epoch days
|
||||
print("\n============ APPLES-TO-APPLES 4h HARDENING (24h grid, 1 trade/UTC-day, IS-cutoff) ============")
|
||||
print(f"5-min bars={len(close5)} days={int(day.max()-day.min())} multiple-testing bar ≈ |t|>2.3\n")
|
||||
for cost in (1.0, 2.0):
|
||||
for H in HOLDS:
|
||||
conv, net, tyr, dd = per_day_trades(close5, day, yr, H, cost)
|
||||
if len(conv) < 50:
|
||||
print(f"cost={cost:.0f} H={H}: too few ({len(conv)})"); continue
|
||||
split = np.quantile(dd, 0.70); is_m = dd < split; oos_m = ~is_m
|
||||
print(f"=== cost={cost:.0f} tick, H={H} (4h), 1 trade/day (IS days {int(is_m.sum())} / OOS {int(oos_m.sum())}) ===")
|
||||
print(f" {'select':>7} {'cutoff':>7} {'n_OOS':>6} {'net_t':>7} {'hit%':>6} {'t':>6} {'annSR':>6} {'trd/yr':>7}")
|
||||
for sel in (1.00, 0.50, 0.25, 0.10):
|
||||
cut = np.quantile(conv[is_m], 1.0 - sel) if sel < 1.0 else -np.inf
|
||||
take = oos_m & (conv >= cut)
|
||||
n, m, hit, t, sr = stats(net[take])
|
||||
oosyrs = max((int(tyr[oos_m].max()) - int(tyr[oos_m].min()) + 1), 1)
|
||||
cs = f"{cut:.2f}" if np.isfinite(cut) else "all"
|
||||
print(f" {sel:>7.2f} {cs:>7} {n:>6} {m:>+7.2f} {hit:>6.1f} {t:>+6.2f} {sr:>+6.2f} {n/oosyrs:>7.1f}")
|
||||
cut = np.quantile(conv[is_m], 0.75)
|
||||
print(f" per-year (sel=0.25; IS years are not OOS):")
|
||||
for y in range(int(tyr.min()), int(tyr.max()) + 1):
|
||||
ym = (tyr == y) & (conv >= cut)
|
||||
if int(ym.sum()) < 10:
|
||||
continue
|
||||
n, m, hit, t, sr = stats(net[ym])
|
||||
flag = "OOS" if np.median(dd[tyr == y]) >= split else "is "
|
||||
print(f" {y} [{flag}] n={n:>4} net={m:>+6.2f}t hit={hit:>5.1f}% t={t:>+5.2f}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
154
scripts/surfer/mft_horizon_sweep.py
Normal file
154
scripts/surfer/mft_horizon_sweep.py
Normal file
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""MFT horizon sweep — does a SLOWER (lower-turnover) intraday trend edge clear costs?
|
||||
|
||||
Prong A (mft_quality_test) showed: at 15-min holds the net ≈ -1 tick = the cost itself (gross ≈ 0).
|
||||
This sweeps the hold horizon UP (15m → full session) on clean ES OHLCV-1m. As the hold grows the
|
||||
forward move (in ticks) grows ~√time while the 1-tick cost stays fixed, so cost/gross shrinks — this
|
||||
isolates the question the user posed: keep costs low by trading SLOWER. If gross edge grows with
|
||||
horizon (trend persistence), there's an MFT sweet spot; if gross stays ~0 everywhere, the price signal
|
||||
is dead regardless of speed.
|
||||
|
||||
Pre-registered (NOT tuned): trend-follow, lookback = hold = H bars (5-min each); direction = sign(ret_H);
|
||||
conviction = |t-stat| = |ret_H| / (per-bar vol · √H); entry close[t], exit close[t+H], intraday only
|
||||
(same-day, flat-by-close); cost = 1.0 tick round-trip (crossing). Quality curve by conviction decile,
|
||||
IS(first 70%) / OOS(last 30%). PASS bar: OOS top-decile net > 0 ticks & hit > 50% & |t| ≥ 2, at a
|
||||
horizon with low trades/day (genuinely MFT).
|
||||
"""
|
||||
import glob
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else None
|
||||
TICK = 0.25
|
||||
COST_TICKS = 1.0
|
||||
BAR_NS = 5 * 60 * 10**9
|
||||
DAY_NS = 86_400 * 10**9
|
||||
HOLDS = [3, 12, 24, 48, 78] # 15m, 1h, 2h, 4h, ~full session (5-min bars)
|
||||
|
||||
|
||||
def load_es_5min():
|
||||
import databento as db
|
||||
es1m = sorted(glob.glob("data/surfer/es1m/*.dbn"))
|
||||
ts_all, c_all = [], []
|
||||
if es1m:
|
||||
for p in es1m:
|
||||
try:
|
||||
arr = db.DBNStore.from_file(p).to_ndarray()
|
||||
except Exception:
|
||||
continue
|
||||
if len(arr) == 0 or "close" not in arr.dtype.names:
|
||||
continue
|
||||
ts = arr["ts_event"].astype(np.int64)
|
||||
c = arr["close"].astype(np.float64) / 1e9
|
||||
m = c > 0
|
||||
ts_all.append(ts[m]); c_all.append(c[m])
|
||||
else:
|
||||
for p in sorted(glob.glob("test_data/futures-baseline/ES.FUT/*.dbn.zst")):
|
||||
try:
|
||||
df = db.DBNStore.from_file(p).to_df().reset_index()
|
||||
except Exception:
|
||||
continue
|
||||
if df.empty or "close" not in df.columns:
|
||||
continue
|
||||
df = df[df["close"] > 0]
|
||||
if df.empty:
|
||||
continue
|
||||
dom = df["instrument_id"].value_counts().idxmax()
|
||||
df = df[df["instrument_id"] == dom].sort_values("ts_event")
|
||||
ts_all.append(df["ts_event"].astype("int64").to_numpy())
|
||||
c_all.append(df["close"].to_numpy(np.float64))
|
||||
ts = np.concatenate(ts_all); c = np.concatenate(c_all)
|
||||
_u, _ui = np.unique(ts, return_index=True)
|
||||
ts, c = ts[_ui], c[_ui]
|
||||
o = np.argsort(ts, kind="stable"); ts, c = ts[o], c[o]
|
||||
b5 = ts // BAR_NS
|
||||
_, first = np.unique(b5, return_index=True)
|
||||
last = np.append(first[1:] - 1, len(b5) - 1)
|
||||
return ts[last], c[last]
|
||||
|
||||
|
||||
def rolling_std(x, w):
|
||||
z = torch.zeros(1, dtype=x.dtype, device=x.device)
|
||||
cs = torch.cat([z, torch.cumsum(x, 0)])
|
||||
cs2 = torch.cat([z, torch.cumsum(x * x, 0)])
|
||||
s = cs[w:] - cs[:-w]
|
||||
s2 = cs2[w:] - cs2[:-w]
|
||||
mean = s / w
|
||||
var = (s2 / w - mean * mean).clamp_min(0.0)
|
||||
out = torch.full_like(x, float("nan"))
|
||||
out[w - 1:] = var.sqrt()
|
||||
return out
|
||||
|
||||
|
||||
def run():
|
||||
ts5, close5 = load_es_5min()
|
||||
day = ts5 // DAY_NS
|
||||
n_days = int(day.max() - day.min())
|
||||
lc = torch.tensor(np.log(close5), device=DEV, dtype=torch.float64)
|
||||
px = torch.tensor(close5, device=DEV, dtype=torch.float64)
|
||||
dy = torch.tensor(day, device=DEV)
|
||||
T = len(lc)
|
||||
t = torch.arange(T, device=DEV)
|
||||
split = int(0.7 * T)
|
||||
|
||||
def sameday(lag):
|
||||
m = torch.zeros(T, dtype=torch.bool, device=DEV)
|
||||
m[lag:] = dy[lag:] == dy[:-lag]
|
||||
return m
|
||||
|
||||
r1 = torch.zeros_like(lc); r1[1:] = lc[1:] - lc[:-1]
|
||||
r1 = torch.where(sameday(1), r1, torch.zeros_like(r1))
|
||||
vol1 = rolling_std(r1, 12) # per-bar 5-min vol (fixed 1h window, pre-registered)
|
||||
|
||||
print("\n================ MFT HORIZON SWEEP (GPU, intraday trend, flat-by-close) ================")
|
||||
print(f"device={DEV} 5-min bars={T} span days≈{n_days} cost={COST_TICKS} tick round-trip")
|
||||
print("trend-follow: dir=sign(ret_H), conv=|ret_H|/(vol·√H). PASS: OOS top-decile net>0 & hit>50% & |t|≥2\n")
|
||||
print(f"{'hold':>6} {'top-q':>6} {'n_OOS':>7} {'gross_t':>8} {'net_t':>8} {'cost/gr':>8} {'hit%':>6} {'t-stat':>7} {'trd/day':>8}")
|
||||
print("-" * 78)
|
||||
|
||||
for H in HOLDS:
|
||||
retH = torch.full_like(lc, float("nan")); retH[H:] = lc[H:] - lc[:-H]
|
||||
tstat = retH / (vol1 * math.sqrt(H)).clamp_min(1e-9)
|
||||
fwd_ticks = torch.full_like(px, float("nan"))
|
||||
fwd_ticks[:-H] = (px[H:] - px[:-H]) / TICK
|
||||
fwd_valid = torch.zeros(T, dtype=torch.bool, device=DEV)
|
||||
fwd_valid[:-H] = dy[H:] == dy[:-H] # exit same day (flat-by-close)
|
||||
valid = fwd_valid & sameday(H) & torch.isfinite(tstat) & torch.isfinite(vol1) & (vol1 > 0)
|
||||
direction = torch.sign(retH)
|
||||
net = direction * fwd_ticks - COST_TICKS
|
||||
gross = direction * fwd_ticks
|
||||
cand = valid & (direction != 0)
|
||||
idx = torch.nonzero(cand, as_tuple=True)[0]
|
||||
if len(idx) < 100:
|
||||
print(f"{H:>6} (too few candidates: {len(idx)})"); continue
|
||||
conv = tstat.abs()[idx]; tt = t[idx]
|
||||
order = torch.argsort(conv, descending=True)
|
||||
hold_hours = H * 5 / 60.0
|
||||
for q in [0.05, 0.10, 1.00]:
|
||||
k = max(int(q * len(idx)), 1)
|
||||
sel = idx[order[:k]]
|
||||
oos = sel[tt[order[:k]] >= split]
|
||||
no = int(len(oos))
|
||||
if no < 20:
|
||||
print(f"{H:>6} {q:>6.2f} (OOS n<20)"); continue
|
||||
g = gross[oos]; nt = net[oos]
|
||||
gm = float(g.mean()); nm = float(nt.mean())
|
||||
tval = nm / (float(nt.std()) / math.sqrt(no) + 1e-12)
|
||||
hit = 100.0 * float((nt > 0).float().mean())
|
||||
cost_gr = (COST_TICKS / gm * 100.0) if gm > 0 else float("nan")
|
||||
trd_day = no / (0.3 * n_days) # OOS is last 30% of days
|
||||
tag = f"{H}({hold_hours:.1f}h)" if q == 0.05 else ""
|
||||
print(f"{tag:>6} {q:>6.2f} {no:>7} {gm:>+8.3f} {nm:>+8.3f} {cost_gr:>7.0f}% {hit:>6.1f} {tval:>+7.2f} {trd_day:>8.2f}")
|
||||
print("-" * 78)
|
||||
|
||||
|
||||
def main():
|
||||
if DEV is None:
|
||||
raise SystemExit("CUDA not available")
|
||||
run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
176
scripts/surfer/mft_quality_test.py
Normal file
176
scripts/surfer/mft_quality_test.py
Normal file
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Surfer MFT quality test — regime-adaptive, selective, intraday (GPU).
|
||||
|
||||
Tests the ACTUAL surfer thesis (not daily CTA): ~5-min decisions, ~15-min holds,
|
||||
flat-by-close, regime-ADAPTIVE (ride trend / fade range / stand aside in chop),
|
||||
and QUALITY over quantity (only the top-conviction waves). Intraday + flat-by-close
|
||||
⇒ no overnight, no roll-gap problem.
|
||||
|
||||
Data: clean ES OHLCV-1m (test_data/futures-baseline/ES.FUT, front-month per quarter),
|
||||
resampled to 5-min. Signal/regime/backtest/stats on the GPU (torch.cuda); load=CPU staging.
|
||||
|
||||
Pre-registered (NOT tuned), to bound overfit on a selective/small-sample test:
|
||||
vol = trailing 12-bar (1h) std of 5-min returns
|
||||
trend t = ret(12 bars) / (vol·√12) # 1h trend t-stat
|
||||
TREND if |t| > 1.0 → trade WITH ret(12) # ride the wave
|
||||
RANGE if |t| < 0.5 → trade AGAINST ret(3) # fade small chop toward mean
|
||||
else (0.5..1.0) → STAND ASIDE (no trade)
|
||||
conviction = |t| (trend) or |ret3|/(vol·√3) (range)
|
||||
entry=close[t], exit=close[t+3] (15 min), cost = 1.0 tick round-trip (crossing).
|
||||
QUALITY curve: rank candidate trades by conviction, take the top q%, report per-trade
|
||||
net edge (ticks), hit-rate, count — IS(first 70%) and OOS(last 30%).
|
||||
"""
|
||||
import glob
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else None
|
||||
TICK = 0.25
|
||||
COST_TICKS = 1.0
|
||||
BAR_NS = 5 * 60 * 10**9
|
||||
DAY_NS = 86_400 * 10**9
|
||||
|
||||
|
||||
def load_es_5min():
|
||||
import databento as db
|
||||
# Prefer the full-history continuous front-month pull (data/surfer/es1m/), else fall back
|
||||
# to the local ~2y parent OHLCV-1m (futures-baseline/ES, per-quarter front-month pick).
|
||||
es1m = sorted(glob.glob("data/surfer/es1m/*.dbn"))
|
||||
ts_all, c_all = [], []
|
||||
if es1m:
|
||||
for p in es1m: # to_ndarray = light (to_df OOMs at 20GB)
|
||||
try:
|
||||
arr = db.DBNStore.from_file(p).to_ndarray()
|
||||
except Exception:
|
||||
continue
|
||||
if len(arr) == 0 or "close" not in arr.dtype.names:
|
||||
continue
|
||||
ts = arr["ts_event"].astype(np.int64)
|
||||
c = arr["close"].astype(np.float64) / 1e9 # raw 1e9 fixed-point → price
|
||||
m = c > 0
|
||||
ts_all.append(ts[m]); c_all.append(c[m])
|
||||
else:
|
||||
for p in sorted(glob.glob("test_data/futures-baseline/ES.FUT/*.dbn.zst")):
|
||||
try:
|
||||
df = db.DBNStore.from_file(p).to_df().reset_index()
|
||||
except Exception:
|
||||
continue
|
||||
if df.empty or "close" not in df.columns:
|
||||
continue
|
||||
df = df[df["close"] > 0]
|
||||
if df.empty:
|
||||
continue
|
||||
dom = df["instrument_id"].value_counts().idxmax() # front month for the quarter
|
||||
df = df[df["instrument_id"] == dom].sort_values("ts_event")
|
||||
ts_all.append(df["ts_event"].astype("int64").to_numpy())
|
||||
c_all.append(df["close"].to_numpy(np.float64))
|
||||
ts = np.concatenate(ts_all); c = np.concatenate(c_all)
|
||||
_u, _ui = np.unique(ts, return_index=True) # dedup any overlapping ts at chunk seams
|
||||
ts, c = ts[_ui], c[_ui]
|
||||
o = np.argsort(ts, kind="stable"); ts, c = ts[o], c[o]
|
||||
b5 = ts // BAR_NS # 5-min bin id
|
||||
_, first = np.unique(b5, return_index=True)
|
||||
last = np.append(first[1:] - 1, len(b5) - 1) # last 1-min close in each 5-min bin
|
||||
return ts[last], c[last]
|
||||
|
||||
|
||||
def rolling_std(x, w): # trailing-w std at each t (NaN first w-1); O(T) memory (cumsum, no unfold)
|
||||
z = torch.zeros(1, dtype=x.dtype, device=x.device)
|
||||
cs = torch.cat([z, torch.cumsum(x, 0)])
|
||||
cs2 = torch.cat([z, torch.cumsum(x * x, 0)])
|
||||
s = cs[w:] - cs[:-w]
|
||||
s2 = cs2[w:] - cs2[:-w]
|
||||
mean = s / w
|
||||
var = (s2 / w - mean * mean).clamp_min(0.0)
|
||||
out = torch.full_like(x, float("nan"))
|
||||
out[w - 1:] = var.sqrt()
|
||||
return out
|
||||
|
||||
|
||||
def run():
|
||||
ts5, close5 = load_es_5min()
|
||||
day = ts5 // DAY_NS
|
||||
lc = torch.tensor(np.log(close5), device=DEV, dtype=torch.float64)
|
||||
px = torch.tensor(close5, device=DEV, dtype=torch.float64)
|
||||
dy = torch.tensor(day, device=DEV)
|
||||
T = len(lc)
|
||||
t = torch.arange(T, device=DEV)
|
||||
|
||||
def sameday(lag): # day[i]==day[i-lag]
|
||||
m = torch.zeros(T, dtype=torch.bool, device=DEV)
|
||||
m[lag:] = dy[lag:] == dy[:-lag]
|
||||
return m
|
||||
|
||||
r1 = torch.zeros_like(lc); r1[1:] = lc[1:] - lc[:-1]
|
||||
r1 = torch.where(sameday(1), r1, torch.zeros_like(r1)) # zero overnight returns
|
||||
vol = rolling_std(r1, 12) * math.sqrt(1.0) # per-bar vol (5-min)
|
||||
ret12 = torch.full_like(lc, float("nan")); ret12[12:] = lc[12:] - lc[:-12]
|
||||
ret3 = torch.full_like(lc, float("nan")); ret3[3:] = lc[3:] - lc[:-3]
|
||||
tstat = ret12 / (vol * math.sqrt(12)).clamp_min(1e-9)
|
||||
|
||||
# forward 15-min (3 bars) PRICE move in ticks, intraday only
|
||||
fwd_ticks = torch.full_like(px, float("nan"))
|
||||
fwd_ticks[:-3] = (px[3:] - px[:-3]) / TICK
|
||||
fwd_valid = torch.zeros(T, dtype=torch.bool, device=DEV)
|
||||
fwd_valid[:-3] = dy[3:] == dy[:-3]
|
||||
|
||||
valid = fwd_valid & sameday(12) & torch.isfinite(tstat) & torch.isfinite(vol) & (vol > 0)
|
||||
|
||||
def evaluate(name, direction, conviction, candidate):
|
||||
cand = candidate & valid & torch.isfinite(direction) & (direction != 0)
|
||||
# net per-trade edge in ticks (enter close[t], exit close[t+3], 1-tick round-trip cost)
|
||||
net = direction * fwd_ticks - COST_TICKS
|
||||
idx = torch.nonzero(cand, as_tuple=True)[0]
|
||||
if len(idx) < 50:
|
||||
print(f" [{name}] too few candidates ({len(idx)})"); return
|
||||
conv = conviction[idx]; netc = net[idx]; tt = t[idx]
|
||||
split = int(0.7 * T)
|
||||
is_m = tt < split; oos_m = ~is_m
|
||||
print(f" [{name}] candidates={len(idx)} (IS {int(is_m.sum())} / OOS {int(oos_m.sum())})")
|
||||
print(f" {'top-q':>6} {'n_OOS':>6} {'net_t/trade_OOS':>15} {'t-stat':>7} {'hit%_OOS':>9} {'IS':>8}")
|
||||
order = torch.argsort(conv, descending=True)
|
||||
for q in [0.05, 0.10, 0.25, 0.50, 1.00]:
|
||||
k = max(int(q * len(idx)), 1)
|
||||
sel = order[:k]
|
||||
sm = oos_m[sel]; im = is_m[sel]
|
||||
no = int(sm.sum())
|
||||
if no < 10:
|
||||
print(f" {q:>6.2f} (OOS n<10)"); continue
|
||||
vals = netc[sel][sm]
|
||||
net_oos = float(vals.mean())
|
||||
tstat = net_oos / (float(vals.std()) / math.sqrt(no) + 1e-12)
|
||||
hit_oos = float((vals > 0).float().mean())
|
||||
net_is = float(netc[sel][im].mean()) if int(im.sum()) > 0 else float("nan")
|
||||
print(f" {q:>6.2f} {no:>6} {net_oos:>+15.3f} {tstat:>+7.2f} {100*hit_oos:>8.1f}% {net_is:>+8.3f}")
|
||||
|
||||
print("\n================ SURFER MFT QUALITY TEST (GPU, intraday flat-by-close) ================")
|
||||
print(f"device={DEV} 5-min bars={T} span days≈{int((day.max()-day.min()))} cost={COST_TICKS} tick round-trip")
|
||||
print("net_t/trade = mean per-trade edge in TICKS after cost; quality bar: OOS top-decile > 0 & hit>50%\n")
|
||||
|
||||
# regime-adaptive direction + conviction
|
||||
trend = tstat.abs() > 1.0
|
||||
rang = tstat.abs() < 0.5
|
||||
dir_adapt = torch.zeros_like(tstat)
|
||||
dir_adapt = torch.where(trend, torch.sign(ret12), dir_adapt) # ride
|
||||
dir_adapt = torch.where(rang, -torch.sign(ret3), dir_adapt) # fade
|
||||
conv_adapt = torch.where(trend, tstat.abs(),
|
||||
torch.where(rang, (ret3.abs() / (vol * math.sqrt(3)).clamp_min(1e-9)),
|
||||
torch.zeros_like(tstat)))
|
||||
evaluate("REGIME-ADAPTIVE (ride trend / fade range / aside)", dir_adapt, conv_adapt, (trend | rang))
|
||||
|
||||
# baselines for contrast
|
||||
evaluate("naive MOMENTUM always", torch.sign(ret12), tstat.abs(), torch.ones(T, dtype=torch.bool, device=DEV))
|
||||
evaluate("naive MEAN-REVERSION always", -torch.sign(ret3), (ret3.abs()/(vol*math.sqrt(3)).clamp_min(1e-9)),
|
||||
torch.ones(T, dtype=torch.bool, device=DEV))
|
||||
|
||||
|
||||
def main():
|
||||
if DEV is None:
|
||||
raise SystemExit("CUDA not available")
|
||||
run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
105
scripts/surfer/micro_gate.py
Normal file
105
scripts/surfer/micro_gate.py
Normal file
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Phase A — minute-horizon crypto signal gate (does deep ML have anything to extract?).
|
||||
|
||||
Before training Mamba2/CfC/TLOB, prove there's cost-surviving predictability at the minute
|
||||
horizon. Free Binance 1m klines carry an OFI proxy (takerBuyQuote/quote). Test per-coin
|
||||
short-horizon features -> forward returns, pooled, net of taker cost. If even simple features
|
||||
show net edge, deep sequence models can plausibly extract more; if not, same wall as ES.
|
||||
|
||||
Features (info up to t): r1,r5,r15 (momentum/reversal), ofi (taker imbalance), ofi5 (smoothed),
|
||||
vol_z. Targets: forward log-return over h in {1,5,15} min. Cost: 5bp round-trip taker.
|
||||
Reports per-feature IC + best simple strategy per-trade net edge (bps) vs cost. GPU compute.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
OUT = "data/surfer/crypto1m"
|
||||
SYMS = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "XRPUSDT", "BNBUSDT", "DOGEUSDT",
|
||||
"ADAUSDT", "AVAXUSDT", "LINKUSDT", "LTCUSDT", "SUIUSDT", "NEARUSDT"]
|
||||
DAYS = 60
|
||||
COST_BP = 5.0
|
||||
|
||||
|
||||
def _get(u):
|
||||
return json.load(urllib.request.urlopen(urllib.request.Request(u, headers={"User-Agent": "curl/8"}), timeout=30))
|
||||
|
||||
|
||||
def fetch(sym):
|
||||
cache = f"{OUT}/{sym}.npz"
|
||||
if os.path.exists(cache):
|
||||
d = np.load(cache); return d["close"], d["qv"], d["tbq"]
|
||||
end = int(time.time() * 1000); start = end - DAYS * 86_400_000
|
||||
rows = []
|
||||
cur = start
|
||||
for _ in range(200):
|
||||
k = _get(f"https://fapi.binance.com/fapi/v1/klines?symbol={sym}&interval=1m&startTime={cur}&limit=1500")
|
||||
if not k:
|
||||
break
|
||||
rows += k
|
||||
cur = k[-1][0] + 1
|
||||
if len(k) < 1500 or cur >= end:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
d = {int(r[0]): (float(r[4]), float(r[7]), float(r[10])) for r in rows} # close, quoteVol, takerBuyQuote
|
||||
ts = sorted(d)
|
||||
close = np.array([d[t][0] for t in ts]); qv = np.array([d[t][1] for t in ts]); tbq = np.array([d[t][2] for t in ts])
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
np.savez(cache, close=close, qv=qv, tbq=tbq)
|
||||
return close, qv, tbq
|
||||
|
||||
|
||||
def roll_mean(x, w):
|
||||
c = np.concatenate([[0], np.cumsum(x)])
|
||||
out = np.full_like(x, np.nan); out[w - 1:] = (c[w:] - c[:-w]) / w; return out
|
||||
|
||||
|
||||
def main():
|
||||
feats_all = {k: [] for k in ["r1", "r5", "r15", "ofi", "ofi5", "vol_z"]}
|
||||
fwd_all = {h: [] for h in [1, 5, 15]}
|
||||
print(f"fetching 1m klines ({len(SYMS)} perps, {DAYS}d)...")
|
||||
for s in SYMS:
|
||||
close, qv, tbq = fetch(s)
|
||||
lc = np.log(close)
|
||||
r1 = np.full_like(lc, np.nan); r1[1:] = lc[1:] - lc[:-1]
|
||||
r5 = np.full_like(lc, np.nan); r5[5:] = lc[5:] - lc[:-5]
|
||||
r15 = np.full_like(lc, np.nan); r15[15:] = lc[15:] - lc[:-15]
|
||||
ofi = np.where(qv > 0, 2 * tbq / qv - 1.0, 0.0) # taker buy imbalance in [-1,1]
|
||||
ofi5 = roll_mean(ofi, 5)
|
||||
vz = (qv - roll_mean(qv, 30)) / (roll_mean(qv, 30) + 1e-9)
|
||||
feats = {"r1": r1, "r5": r5, "r15": r15, "ofi": ofi, "ofi5": ofi5, "vol_z": vz}
|
||||
for k in feats_all:
|
||||
feats_all[k].append(feats[k])
|
||||
for h in fwd_all:
|
||||
fwd = np.full_like(lc, np.nan); fwd[:-h] = lc[h:] - lc[:-h]
|
||||
fwd_all[h].append(fwd)
|
||||
F = {k: np.concatenate(v) for k, v in feats_all.items()}
|
||||
FW = {h: np.concatenate(v) for h, v in fwd_all.items()}
|
||||
|
||||
print(f"\n===== MINUTE-HORIZON GATE — {len(SYMS)} perps, {DAYS}d, cost={COST_BP}bp round-trip =====")
|
||||
print("IC = corr(feature_t, forward-return); per-trade NET = mean(sign(feat)*fwd) - cost, in bps")
|
||||
print(f"{'feature':>8} " + " ".join(f"h={h}:IC/netbp" for h in [1, 5, 15]))
|
||||
for fk, fv in F.items():
|
||||
cells = []
|
||||
for h, fw in FW.items():
|
||||
m = np.isfinite(fv) & np.isfinite(fw)
|
||||
a, b = fv[m], fw[m]
|
||||
ic = float(np.corrcoef(a, b)[0, 1]) if len(a) > 1000 else float("nan")
|
||||
net_bp = (float(np.mean(np.sign(a) * b)) - COST_BP / 1e4) * 1e4
|
||||
cells.append(f"{ic:+.3f}/{net_bp:+.1f}")
|
||||
print(f"{fk:>8} " + " ".join(cells))
|
||||
print("\nVERDICT: any feature with positive per-trade NET bps (edge > cost) => minute-horizon signal")
|
||||
print("exists for Mamba2/CfC/TLOB to extract -> build GPU model POC. All negative => same wall as ES.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
71
scripts/surfer/momentum_robustness.py
Normal file
71
scripts/surfer/momentum_robustness.py
Normal file
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stress-test the one survivor: crypto cross-sectional momentum.
|
||||
|
||||
Three robustness probes on the broad-but-clean universe (funding-cov>0.90, >900d):
|
||||
1. LOOKBACK SWEEP — is the edge smooth across 5..120d (real factor) or a single spike (overfit)?
|
||||
2. COIN-BOOTSTRAP — resample WHICH coins are in the cross-section 200× (survivorship proxy):
|
||||
does momentum stay positive regardless of which coins survived?
|
||||
3. VOL-SCALED — does risk-adjusting the momentum signal (ret/vol) help?
|
||||
Validation reused from the sweep (full/IS/OOS Sharpe, CPCV, Deflated Sharpe).
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import crypto_sweep as cs # noqa: E402
|
||||
from signal_sweep import xs_weights, pnl_w, validate, sharpe_t # noqa: E402
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
cs.MIN_FUNDCOV, cs.MIN_DAYS = 0.90, 900
|
||||
|
||||
|
||||
def trailing(lc, L):
|
||||
out = np.full_like(lc, np.nan); out[L:] = lc[L:] - lc[:-L]; return out
|
||||
|
||||
|
||||
def main():
|
||||
syms, days, close, fund = cs.load_crypto()
|
||||
T, N = close.shape
|
||||
lc = np.log(close)
|
||||
R = np.zeros((T, N)); R[1:] = lc[1:] - lc[:-1]; R = np.where(np.isfinite(R), R, 0.0)
|
||||
Reff = R - np.where(np.isfinite(fund), fund, 0.0)
|
||||
vol30 = np.full_like(lc, np.nan)
|
||||
for t in range(30, T):
|
||||
vol30[t] = np.nanstd(R[t - 30:t], axis=0)
|
||||
|
||||
print(f"\n===== MOMENTUM ROBUSTNESS — {N} clean coins, {T}d =====")
|
||||
print("\n[1] LOOKBACK SWEEP (XS momentum, ~10bp cost)")
|
||||
print(f"{'lookback':>9} {'full':>6} {'IS':>6} {'OOS':>6} {'CPCVmed':>8} {'DSR':>5}")
|
||||
for L in [5, 10, 20, 30, 45, 60, 90, 120]:
|
||||
pnl = pnl_w(xs_weights(trailing(lc, L)), Reff, cost_bp=10)
|
||||
v = validate(pnl, days, 8)
|
||||
print(f"{L:>9} {v['full']:>+6.2f} {v['is_']:>+6.2f} {v['oos']:>+6.2f} {v['med']:>+8.2f} {v['dsr']:>5.2f}")
|
||||
|
||||
print("\n[2] VOL-SCALED vs RAW (30d)")
|
||||
for nm, sg in [("raw_mom30", trailing(lc, 30)), ("volscaled_mom30", trailing(lc, 30) / np.where(vol30 > 0, vol30, np.nan))]:
|
||||
v = validate(pnl_w(xs_weights(sg), Reff, cost_bp=10), days, 8)
|
||||
print(f" {nm:>16}: full {v['full']:>+.2f} OOS {v['oos']:>+.2f} CPCVmed {v['med']:>+.2f}")
|
||||
|
||||
print("\n[3] COIN-BOOTSTRAP — mom_30 on 200 random half-universes (survivorship proxy)")
|
||||
rng = np.random.default_rng(42)
|
||||
sig30 = trailing(lc, 30)
|
||||
sh = []
|
||||
for _ in range(200):
|
||||
cols = rng.choice(N, size=max(N // 2, 10), replace=False)
|
||||
sub = np.full((T, N), np.nan); sub[:, cols] = sig30[:, cols]
|
||||
pnl = pnl_w(xs_weights(sub), Reff[:, :], cost_bp=10) # weights already zero outside cols
|
||||
s = sharpe_t(torch.tensor(pnl, device=DEV, dtype=torch.float64))
|
||||
if not math.isnan(s):
|
||||
sh.append(s)
|
||||
sh = np.array(sh)
|
||||
print(f" draws={len(sh)} mean Sharpe {sh.mean():+.2f} median {np.median(sh):+.2f} "
|
||||
f"5th-pct {np.percentile(sh,5):+.2f} frac>0 {np.mean(sh>0):.2f}")
|
||||
print("\nVERDICT: smooth lookback curve + bootstrap frac>0 near 1.0 + 5th-pct>0 => robust real factor.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
101
scripts/surfer/multiasset_futures.py
Normal file
101
scripts/surfer/multiasset_futures.py
Normal file
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Multi-asset futures test: does breadth (39 roots) unlock an edge, and is a CTA trend book
|
||||
a genuine DIVERSIFIER to the crypto momentum book?
|
||||
|
||||
(A) XS momentum (was null on 22 roots) + TS trend-following (directional CTA) on the 39-asset
|
||||
daily futures panel — deflated, per-year, realistic cost.
|
||||
(B) The prize: correlation of the futures TS-trend book to the crypto XS-momentum book (overlap
|
||||
2019-2026) + the combined two-market book. Low correlation + both positive = real diversification.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from signal_sweep import load_panel, build_returns, xs_weights, pnl_w, validate, sharpe_t # noqa: E402
|
||||
import pit_sweep # noqa: E402
|
||||
from surfer_poc import compute_weights, CFG # noqa: E402
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
|
||||
|
||||
def roll(fn, X, L):
|
||||
out = np.full_like(X, np.nan)
|
||||
for t in range(L, len(X)):
|
||||
out[t] = fn(X[t - L:t], axis=0)
|
||||
return out
|
||||
|
||||
|
||||
def trailing(lc, L):
|
||||
out = np.full_like(lc, np.nan); out[L:] = lc[L:] - lc[:-L]; return out
|
||||
|
||||
|
||||
def main():
|
||||
roots, days, close, op, inst, weekday = load_panel()
|
||||
lc, R, ov, intr = build_returns(close, op, inst)
|
||||
T, N = close.shape
|
||||
vol60 = roll(np.std, R, 60)
|
||||
invvol = np.where(vol60 > 0, 1.0 / vol60, 0.0)
|
||||
year = (1970 + days / 365.25).astype(int)
|
||||
yrs = list(range(2011, 2027))
|
||||
|
||||
def wdir(sig): # directional unit-gross (TS trend, net exposure)
|
||||
s = np.nan_to_num(sig)
|
||||
g = np.sum(np.abs(s), axis=1, keepdims=True); g[g == 0] = 1
|
||||
return s / g
|
||||
|
||||
def peryear(p, yy):
|
||||
return [sharpe_t(torch.tensor(p[yy[1:] == y], device=DEV, dtype=torch.float64))
|
||||
if (yy[1:] == y).sum() > 30 else float("nan") for y in yrs]
|
||||
|
||||
print(f"\n===== (A) MULTI-ASSET FUTURES — {N} roots, {T} days =====")
|
||||
print(f"{'signal':>16} {'full':>6} {'IS':>6} {'OOS':>6} {'CPCVmed':>8} {'DSR':>5}")
|
||||
sigs = {}
|
||||
for L in [20, 60, 120]:
|
||||
sigs[f"XS_mom_{L}"] = pnl_w(xs_weights(trailing(lc, L)), R, cost_bp=1.5)
|
||||
for L in [60, 120, 250]:
|
||||
sigs[f"TS_trend_{L}"] = pnl_w(wdir(np.sign(trailing(lc, L)) * invvol), R, cost_bp=1.5)
|
||||
NT = 40 # cumulative-session deflation (honest)
|
||||
best_ts, best_ts_pnl, best_med = None, None, -9
|
||||
for nm, p in sigs.items():
|
||||
v = validate(p, days, NT)
|
||||
print(f"{nm:>16} {v['full']:>+6.2f} {v['is_']:>+6.2f} {v['oos']:>+6.2f} {v['med']:>+8.2f} {v['dsr']:>5.2f}")
|
||||
if nm.startswith("TS_trend") and v["med"] > best_med:
|
||||
best_ts, best_ts_pnl, best_med = nm, p, v["med"]
|
||||
print(f" futures TS-trend per-year ({best_ts}): " + " ".join(f"{x:+.2f}" if not math.isnan(x) else " n/a" for x in peryear(best_ts_pnl, year)))
|
||||
|
||||
# ---------- (B) DIVERSIFIER: futures trend vs crypto momentum ----------
|
||||
syms, cdays, cclose, cqv, cfund = pit_sweep.load()
|
||||
cR = np.zeros_like(cclose); cR[1:] = np.log(cclose)[1:] - np.log(cclose)[:-1]
|
||||
cR = np.where(np.isfinite(cR), cR, 0.0)
|
||||
cfund = np.where(np.isfinite(cfund), cfund, 0.0)
|
||||
cw, _ = compute_weights(cclose, cqv, cdays, CFG)
|
||||
cReff = cR - cfund
|
||||
crypto_book = np.sum(cw[:-1] * cReff[1:], axis=1) # crypto momentum daily PnL (cdays[1:])
|
||||
fut_book = best_ts_pnl # futures trend daily PnL (days[1:])
|
||||
|
||||
# align on common epoch-days
|
||||
fd, cd = days[1:], cdays[1:]
|
||||
fmap = {int(d): fut_book[i] for i, d in enumerate(fd)}
|
||||
cmap = {int(d): crypto_book[i] for i, d in enumerate(cd)}
|
||||
common = sorted(set(fmap) & set(cmap))
|
||||
fa = np.array([fmap[d] for d in common]); ca = np.array([cmap[d] for d in common])
|
||||
m = np.isfinite(fa) & np.isfinite(ca)
|
||||
corr = float(np.corrcoef(fa[m], ca[m])[0, 1])
|
||||
sf, sc = float(np.std(fa[m])), float(np.std(ca[m]))
|
||||
comb = ((1 / sf) * fa[m] + (1 / sc) * ca[m]) / (1 / sf + 1 / sc)
|
||||
cyear = (1970 + np.array(common) / 365.25).astype(int)[m]
|
||||
print(f"\n===== (B) DIVERSIFIER CHECK — futures-trend vs crypto-momentum (overlap {len(fa[m])} days) =====")
|
||||
print(f"futures-trend Sharpe {sharpe_t(torch.tensor(fa[m],device=DEV,dtype=torch.float64)):+.2f} "
|
||||
f"crypto-momentum Sharpe {sharpe_t(torch.tensor(ca[m],device=DEV,dtype=torch.float64)):+.2f}")
|
||||
print(f"CORRELATION = {corr:+.2f} combined-book Sharpe = {sharpe_t(torch.tensor(comb,device=DEV,dtype=torch.float64)):+.2f}")
|
||||
print("combined per-year: " + " ".join(f"{y}:{sharpe_t(torch.tensor(comb[cyear==y],device=DEV,dtype=torch.float64)):+.2f}"
|
||||
for y in range(2019, 2027) if (cyear == y).sum() > 30))
|
||||
print("\nVERDICT: low |corr| + combined Sharpe > each alone = genuine cross-market diversification.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
123
scripts/surfer/multisignal_combine.py
Normal file
123
scripts/surfer/multisignal_combine.py
Normal file
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The quant-fund method: combine MANY weak cross-sectional signals via a combiner ladder.
|
||||
|
||||
Assemble ~13 weak signals on the liquid US-equity universe, then run a ladder of combiners
|
||||
from dumb to fancy and ask the disciplined question: does COMBINING beat the BEST SINGLE signal
|
||||
OOS, and does the nonlinear ML beat the LINEAR combiner OOS (or just overfit the 3.2y)?
|
||||
best-single -> equal-weight -> IC-weighted -> ridge(IS-fit) -> gradient-boost(IS-fit)
|
||||
Strict IS/OOS split, realistic illiquidity-scaled cost, weekly rebalance. Reject if the
|
||||
combination doesn't beat best-single + equal-weight OOS after deflation.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from equity_factor_gate import load, roll, trailing # noqa: E402
|
||||
from signal_sweep import xs_weights, validate, sharpe_t # noqa: E402
|
||||
from sklearn.linear_model import Ridge # noqa: E402
|
||||
from sklearn.ensemble import HistGradientBoostingRegressor # noqa: E402
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
DV_FLOOR, TOPK = 5e6, 1000
|
||||
|
||||
|
||||
def zc(x):
|
||||
mu = np.nanmean(x, axis=1, keepdims=True); sd = np.nanstd(x, axis=1, keepdims=True)
|
||||
return np.nan_to_num((x - mu) / np.where(sd > 0, sd, 1))
|
||||
|
||||
|
||||
def main():
|
||||
insts, days, close, dvol = load()
|
||||
T, N = close.shape
|
||||
lc = np.log(close)
|
||||
R = np.zeros((T, N)); R[1:] = lc[1:] - lc[:-1]; R = np.where(np.isfinite(R), R, 0.0)
|
||||
dv30 = roll(np.mean, np.nan_to_num(dvol), 30)
|
||||
vol63 = roll(np.std, R, 63)
|
||||
year = (1970 + days / 365.25).astype(int)
|
||||
|
||||
univ = np.zeros((T, N), bool)
|
||||
for t in range(T):
|
||||
e = np.where((dv30[t] > DV_FLOOR) & np.isfinite(close[t]))[0]
|
||||
if len(e) > 50:
|
||||
univ[t, e[np.argsort(-dv30[t, e])[:TOPK]]] = True
|
||||
|
||||
rmax252 = roll(np.max, lc, 252)
|
||||
amih = roll(np.mean, np.abs(R) / np.maximum(np.nan_to_num(dvol), 1.0), 21)
|
||||
# ~13 weak cross-sectional signals (each z-scored per day inside the universe)
|
||||
raw = {
|
||||
"mom_21": trailing(lc, 21), "mom_63": trailing(lc, 63), "mom_126": trailing(lc, 126),
|
||||
"mom_252": trailing(lc, 252), "rev_5": -trailing(lc, 5), "rev_10": -trailing(lc, 10),
|
||||
"lowvol": -vol63, "amihud": amih, "max20": -roll(np.max, R, 20),
|
||||
"accel": trailing(lc, 10) - trailing(lc, 63), "hi52": lc - rmax252,
|
||||
"vol_chg": roll(np.std, R, 21) - vol63, "skew63": -roll(lambda a, axis: ((a - a.mean(axis))**3).mean(axis) / (a.std(axis)**3 + 1e-9), R, 63),
|
||||
}
|
||||
names = list(raw)
|
||||
sig = np.stack([np.where(univ, v, np.nan) for v in raw.values()], axis=2) # [T,N,K]
|
||||
sigz = np.stack([zc(np.where(univ, v, np.nan)) for v in raw.values()], axis=2)
|
||||
K = len(names)
|
||||
fwd = np.full((T, N), np.nan); fwd[:-1] = R[1:] # next-day return
|
||||
split = int(0.65 * T)
|
||||
|
||||
# pooled IS dataset for fitted combiners
|
||||
ist = np.zeros(T, bool); ist[:split] = True
|
||||
Xall = sigz.reshape(T * N, K)
|
||||
yall = fwd.reshape(T * N)
|
||||
rowok = np.isfinite(yall) & np.isfinite(Xall).all(1) & np.repeat(univ.reshape(T * N), 1)
|
||||
isrow = rowok & np.repeat(ist[:, None], N, 1).reshape(T * N)
|
||||
Xis, yis = Xall[isrow], yall[isrow]
|
||||
print(f"signals K={K}, universe/day~{int(univ.sum(1).mean())}, IS obs={len(yis):,}")
|
||||
|
||||
ridge = Ridge(alpha=10.0).fit(Xis, yis)
|
||||
gb = HistGradientBoostingRegressor(max_depth=3, max_iter=120, learning_rate=0.05,
|
||||
l2_regularization=1.0, min_samples_leaf=200).fit(Xis, yis)
|
||||
comp_ridge = (sigz.reshape(T * N, K) @ ridge.coef_).reshape(T, N)
|
||||
Xpred = np.nan_to_num(sigz.reshape(T * N, K))
|
||||
comp_gb = gb.predict(Xpred).reshape(T, N)
|
||||
# IC-weighted (trailing 60d IC per signal, causal)
|
||||
comp_ic = np.zeros((T, N))
|
||||
for t in range(60, T):
|
||||
w = []
|
||||
for k in range(K):
|
||||
a = sigz[t - 60:t, :, k].reshape(-1); b = fwd[t - 60:t].reshape(-1)
|
||||
m = np.isfinite(a) & np.isfinite(b)
|
||||
w.append(np.corrcoef(a[m], b[m])[0, 1] if m.sum() > 200 else 0.0)
|
||||
comp_ic[t] = np.nan_to_num(sigz[t] @ np.nan_to_num(np.array(w)))
|
||||
comp_eq = np.nansum(sigz, axis=2)
|
||||
|
||||
rt_cost = np.clip(40.0 / np.sqrt(np.maximum(dv30, 1.0) / 1e6), 3.0, 60.0) / 1e4
|
||||
|
||||
def book(comp):
|
||||
s = comp.copy(); s[~univ] = np.nan
|
||||
w = xs_weights(s)
|
||||
a = 2.0 / 6
|
||||
for t in range(1, T):
|
||||
w[t] = a * w[t] + (1 - a) * w[t - 1]
|
||||
wh = w.copy(); last = 0
|
||||
for t in range(T):
|
||||
if t % 5 == 0:
|
||||
last = t
|
||||
wh[t] = w[last]
|
||||
g = np.sum(wh[:-1] * R[1:], axis=1) - np.sum(np.abs(wh[1:] - wh[:-1]) * rt_cost[1:], axis=1)
|
||||
return g
|
||||
|
||||
T_ = lambda x: torch.tensor(np.asarray(x)[np.isfinite(np.asarray(x))], device=DEV, dtype=torch.float64)
|
||||
NT = K + 5
|
||||
print(f"\n===== MULTI-SIGNAL COMBINATION (liquid equities, net cost, deflate N={NT}) =====")
|
||||
# best single
|
||||
singles = {nm: validate(book(sigz[:, :, k]), days, NT) for k, nm in enumerate(names)}
|
||||
bs = max(singles.items(), key=lambda kv: kv[1]["oos"])
|
||||
print(f"BEST SINGLE signal: {bs[0]} OOS {bs[1]['oos']:+.2f} full {bs[1]['full']:+.2f} DSR {bs[1]['dsr']:.2f}")
|
||||
print(f"{'combiner':>14} {'full':>6} {'IS':>6} {'OOS':>6} {'CPCVmed':>8} {'DSR':>5}")
|
||||
for nm, comp in [("equal", comp_eq), ("IC-weighted", comp_ic), ("ridge(IS)", comp_ridge), ("gradboost(IS)", comp_gb)]:
|
||||
v = validate(book(comp), days, NT)
|
||||
print(f"{nm:>14} {v['full']:>+6.2f} {v['is_']:>+6.2f} {v['oos']:>+6.2f} {v['med']:>+8.2f} {v['dsr']:>5.2f}")
|
||||
print("\nVERDICT: combination OOS > best-single OOS => combining works. gradboost OOS > ridge OOS => ML adds;")
|
||||
print("gradboost < ridge => ML overfits 3.2y (linear is the real-quant answer). DSR>0.5 = deploy-grade.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
109
scripts/surfer/multisignal_walkforward.py
Normal file
109
scripts/surfer/multisignal_walkforward.py
Normal file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Leak-free WALK-FORWARD validation of the multi-signal combiner — the decisive test.
|
||||
|
||||
The single IS/OOS gradboost result (OOS +1.14, DSR 0.62) is overfit-suspect (one short OOS
|
||||
window, high-capacity model, only fitted combiners work). Real test: re-fit the combiner on an
|
||||
EXPANDING window and predict ONLY the next block forward (never peeking). Concatenate the
|
||||
out-of-sample predictions, build the book on the OOS period only, and compare gradboost vs ridge
|
||||
vs equal vs best-single — all leak-free. If gradboost still wins OOS, it's real; if it collapses,
|
||||
it was memorization.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from equity_factor_gate import load, roll, trailing # noqa: E402
|
||||
from signal_sweep import xs_weights, validate, sharpe_t # noqa: E402
|
||||
from sklearn.linear_model import Ridge # noqa: E402
|
||||
from sklearn.ensemble import HistGradientBoostingRegressor # noqa: E402
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
DV_FLOOR, TOPK = 5e6, 1000
|
||||
|
||||
|
||||
def zc(x):
|
||||
mu = np.nanmean(x, axis=1, keepdims=True); sd = np.nanstd(x, axis=1, keepdims=True)
|
||||
return np.nan_to_num((x - mu) / np.where(sd > 0, sd, 1))
|
||||
|
||||
|
||||
def main():
|
||||
insts, days, close, dvol = load()
|
||||
T, N = close.shape
|
||||
lc = np.log(close)
|
||||
R = np.zeros((T, N)); R[1:] = lc[1:] - lc[:-1]; R = np.where(np.isfinite(R), R, 0.0)
|
||||
dv30 = roll(np.mean, np.nan_to_num(dvol), 30)
|
||||
vol63 = roll(np.std, R, 63)
|
||||
year = (1970 + days / 365.25).astype(int)
|
||||
univ = np.zeros((T, N), bool)
|
||||
for t in range(T):
|
||||
e = np.where((dv30[t] > DV_FLOOR) & np.isfinite(close[t]))[0]
|
||||
if len(e) > 50:
|
||||
univ[t, e[np.argsort(-dv30[t, e])[:TOPK]]] = True
|
||||
rmax252 = roll(np.max, lc, 252)
|
||||
amih = roll(np.mean, np.abs(R) / np.maximum(np.nan_to_num(dvol), 1.0), 21)
|
||||
raw = {"mom_21": trailing(lc, 21), "mom_63": trailing(lc, 63), "mom_126": trailing(lc, 126),
|
||||
"mom_252": trailing(lc, 252), "rev_5": -trailing(lc, 5), "rev_10": -trailing(lc, 10),
|
||||
"lowvol": -vol63, "amihud": amih, "max20": -roll(np.max, R, 20),
|
||||
"accel": trailing(lc, 10) - trailing(lc, 63), "hi52": lc - rmax252,
|
||||
"vol_chg": roll(np.std, R, 21) - vol63}
|
||||
names = list(raw); K = len(names)
|
||||
sigz = np.stack([zc(np.where(univ, v, np.nan)) for v in raw.values()], axis=2) # [T,N,K] causal
|
||||
fwd = np.full((T, N), np.nan); fwd[:-1] = R[1:]
|
||||
rt_cost = np.clip(40.0 / np.sqrt(np.maximum(dv30, 1.0) / 1e6), 3.0, 60.0) / 1e4
|
||||
|
||||
INIT, STEP = int(0.45 * T), 42 # ~1.4y initial, refit every ~2mo
|
||||
comp_gb = np.full((T, N), np.nan); comp_ri = np.full((T, N), np.nan)
|
||||
Xall = sigz.reshape(T * N, K); yall = fwd.reshape(T * N); uflat = univ.reshape(T * N)
|
||||
for s in range(INIT, T, STEP):
|
||||
e = min(s + STEP, T)
|
||||
tr = np.zeros(T, bool); tr[:s] = True
|
||||
rows = np.repeat(tr[:, None], N, 1).reshape(T * N) & uflat & np.isfinite(yall) & np.isfinite(Xall).all(1)
|
||||
Xtr, ytr = Xall[rows], yall[rows]
|
||||
if len(ytr) < 5000:
|
||||
continue
|
||||
ri = Ridge(alpha=10.0).fit(Xtr, ytr)
|
||||
gb = HistGradientBoostingRegressor(max_depth=3, max_iter=120, learning_rate=0.05,
|
||||
l2_regularization=1.0, min_samples_leaf=200).fit(Xtr, ytr)
|
||||
blk = sigz[s:e].reshape((e - s) * N, K)
|
||||
comp_ri[s:e] = (np.nan_to_num(blk) @ ri.coef_).reshape(e - s, N)
|
||||
comp_gb[s:e] = gb.predict(np.nan_to_num(blk)).reshape(e - s, N)
|
||||
|
||||
def book(comp):
|
||||
c = comp.copy(); c[~univ] = np.nan
|
||||
w = xs_weights(c)
|
||||
a = 2.0 / 6
|
||||
for t in range(1, T):
|
||||
w[t] = a * w[t] + (1 - a) * w[t - 1]
|
||||
wh = w.copy(); last = 0
|
||||
for t in range(T):
|
||||
if t % 5 == 0:
|
||||
last = t
|
||||
wh[t] = w[last]
|
||||
return np.sum(wh[:-1] * R[1:], axis=1) - np.sum(np.abs(wh[1:] - wh[:-1]) * rt_cost[1:], axis=1)
|
||||
|
||||
# OOS period = [INIT:] only
|
||||
oos = np.zeros(T - 1, bool); oos[INIT:] = True
|
||||
T_ = lambda x: torch.tensor(np.asarray(x)[INIT:][np.isfinite(np.asarray(x)[INIT:])], device=DEV, dtype=torch.float64)
|
||||
yr = year[1:]
|
||||
eqp = book(np.nansum(sigz, axis=2))
|
||||
best = max(range(K), key=lambda k: sharpe_t(T_(book(sigz[:, :, k]))))
|
||||
print(f"\n===== WALK-FORWARD (leak-free) MULTI-SIGNAL COMBINE — OOS only ({int(oos.sum())} days) =====")
|
||||
print(f"K={K} signals, refit every {STEP}d on expanding window")
|
||||
def line(nm, p):
|
||||
po = np.asarray(p)
|
||||
v = validate(po[INIT:], days, K + 4)
|
||||
print(f"{nm:>18} OOS Sharpe {sharpe_t(T_(p)):+.2f} CPCVmed {v['med']:+.2f} DSR {v['dsr']:.2f}")
|
||||
line(f"best-single({names[best]})", book(sigz[:, :, best]))
|
||||
line("equal-weight", eqp)
|
||||
line("ridge WALK-FWD", comp_ri)
|
||||
line("gradboost WALK-FWD", comp_gb)
|
||||
print("\nVERDICT: gradboost WF OOS > ridge WF and > best-single => the ML combination is REAL (leak-free).")
|
||||
print("If gradboost WF collapses to ~ridge or below => the single-split +1.14 was memorization.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
104
scripts/surfer/multistrat.py
Normal file
104
scripts/surfer/multistrat.py
Normal file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Nano multi-strat: operate like a hedge fund. Combine uncorrelated premia (equity, bond, gold,
|
||||
commodity, trend, crypto) risk-parity-weighted + vol-targeted; show the correlation matrix (the
|
||||
diversification engine), combined Sharpe vs each piece alone, and how leverage scales the return.
|
||||
|
||||
Not market-neutral — it's the diversified-premia + leverage (All-Weather/alt-risk-premia) model.
|
||||
The point: each stream is modest, but uncorrelated streams combine to a higher Sharpe, then leverage
|
||||
turns modest Sharpe into real return. Risk management = the engine's actual job."""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from signal_sweep import load_panel, build_returns # noqa: E402
|
||||
import pit_sweep # noqa: E402
|
||||
|
||||
TV = 0.10
|
||||
|
||||
|
||||
def vt(r, tv=TV, win=63, cap=4.0):
|
||||
out = np.zeros_like(r)
|
||||
for t in range(win, len(r)):
|
||||
rv = np.std(r[t - win:t]) * math.sqrt(252)
|
||||
out[t] = r[t] * min(cap, tv / (rv + 1e-9))
|
||||
return out
|
||||
|
||||
|
||||
def stats(r):
|
||||
r = r[np.isfinite(r)]
|
||||
if len(r) < 50 or r.std() == 0:
|
||||
return (float("nan"),) * 4
|
||||
ann = r.mean() * 252; vol = r.std() * math.sqrt(252)
|
||||
eq = np.cumprod(1 + r); dd = float((eq / np.maximum.accumulate(eq) - 1).min())
|
||||
return ann, vol, ann / vol, dd
|
||||
|
||||
|
||||
def run(streams, days, year, label):
|
||||
names = list(streams)
|
||||
M = np.column_stack([vt(streams[n]) for n in names]) # each vol-normalized to 10%
|
||||
T = M.shape[0]
|
||||
print(f"\n===== {label} ({T} days) =====")
|
||||
print(" standalone Sharpe: " + " ".join(f"{n}:{stats(streams[n])[2]:+.2f}" for n in names))
|
||||
# correlation matrix of the (vol-normalized) streams
|
||||
C = np.corrcoef(np.nan_to_num(M).T)
|
||||
print(" correlation matrix:")
|
||||
print(" " + " ".join(f"{n[:5]:>6}" for n in names))
|
||||
for i, n in enumerate(names):
|
||||
print(f" {n[:5]:>5} " + " ".join(f"{C[i, j]:>+6.2f}" for j in range(len(names))))
|
||||
avg_corr = (C.sum() - len(names)) / (len(names) ** 2 - len(names))
|
||||
# combined: equal-risk-weight then vol-target the bundle
|
||||
combo = vt(np.nanmean(M, axis=1))
|
||||
a, v, sh, dd = stats(combo)
|
||||
best = max(stats(streams[n])[2] for n in names)
|
||||
print(f" avg pairwise corr: {avg_corr:+.2f} (low = diversification works)")
|
||||
print(f" COMBINED (risk-parity + vol-target 10%): Sharpe {sh:+.2f} ann {100*a:+.1f}% maxDD {100*dd:+.1f}%")
|
||||
print(f" vs best single stream Sharpe {best:+.2f} -> diversification lift {sh-best:+.2f}")
|
||||
print(f" per-year Sharpe: " + " ".join(f"{y}:{stats(combo[year == y])[2]:+.1f}" for y in sorted(set(year)) if (year == y).sum() > 100))
|
||||
print(f" LEVERAGE scaling (same Sharpe {sh:+.2f}): "
|
||||
+ " ".join(f"{lev}x->{100*a*lev:+.0f}%/yr@{int(100*v*lev)}%vol" for lev in (1, 2, 3)))
|
||||
return combo
|
||||
|
||||
|
||||
def main():
|
||||
roots, days, close, op, inst = load_panel()[:5]
|
||||
lc, R, ov, intr = build_returns(close, op, inst)
|
||||
T, N = close.shape
|
||||
year = (1970 + days / 365.25).astype(int)
|
||||
|
||||
def C(r):
|
||||
return roots.index(r)
|
||||
|
||||
# trend: diversified long/short TS-momentum across all futures, vol-targeted
|
||||
vol63 = np.full_like(lc, np.nan)
|
||||
for t in range(63, T):
|
||||
vol63[t] = np.nanstd(R[t - 63:t], axis=0)
|
||||
iv = 1.0 / np.where(vol63 > 0, vol63, np.nan)
|
||||
tsig = np.full_like(lc, np.nan); tsig[252:] = np.sign(lc[252:] - lc[:-252])
|
||||
avail = np.isfinite(close) & np.isfinite(vol63) & (vol63 > 0) & np.isfinite(tsig)
|
||||
w = np.where(avail, tsig * iv, 0.0); g = np.abs(w).sum(1, keepdims=True); g[g == 0] = 1; w = w / g
|
||||
trend = np.zeros(T); trend[1:] = np.sum(w[:-1] * R[1:], axis=1)
|
||||
|
||||
fut = {"equity": R[:, C("ES")], "bond": R[:, C("ZN")], "gold": R[:, C("GC")],
|
||||
"commod": R[:, C("CL")], "trend": trend}
|
||||
run(fut, days, year, "TRADITIONAL 5-stream (2010-2026)")
|
||||
|
||||
# +crypto: align BTC daily returns to futures days
|
||||
syms, cdays, cc, _, _ = pit_sweep.load()
|
||||
j = syms.index("BTCUSDT"); lcb = np.log(cc[:, j])
|
||||
btc = {int(cdays[t]): (lcb[t] - lcb[t - 1]) for t in range(1, len(cdays)) if np.isfinite(lcb[t]) and np.isfinite(lcb[t - 1])}
|
||||
mask = np.array([int(d) in btc for d in days])
|
||||
idx = np.where(mask)[0]
|
||||
if len(idx) > 300:
|
||||
sub = {k: v[idx] for k, v in fut.items()}
|
||||
sub["crypto"] = np.array([btc[int(days[i])] for i in idx])
|
||||
run(sub, days[idx], (1970 + days[idx] / 365.25).astype(int), "6-stream +CRYPTO (2019-2026)")
|
||||
print("\nVERDICT: combined Sharpe > best single (diversification real) + leverage scales modest Sharpe")
|
||||
print("to real return = the hedge-fund operating model. Honest: this is alt-risk-premia (~0.5-1.0 live),")
|
||||
print("levered; NOT market-neutral (long beta falls in everything-down); leverage adds tail + financing.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
123
scripts/surfer/multistrat_book.py
Normal file
123
scripts/surfer/multistrat_book.py
Normal file
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deployable nano multi-strat book with 2x leverage + the foxhunt risk-management layer.
|
||||
|
||||
Streams (risk-parity, vol-normalized): equity, bond, gold, commodity, trend, crypto.
|
||||
RISK LAYER (the foxhunt principles applied to the book):
|
||||
1. vol-target -> scale exposure to TARGET_VOL on trailing realized vol
|
||||
2. drawdown CB -> de-lever at -15% (1x) / -25% (0.5x) / -35% (0.25x) [CMDP circuit-breaker]
|
||||
3. corr de-risk -> when avg cross-stream corr spikes (diversification breaking in a crisis), cut leverage
|
||||
4. Kelly cap -> leverage <= Kelly fraction (mean/var), never over-lever a degrading book
|
||||
L[t] = min(MAXLEV, L_vol, L_kelly) * dd_mult * corr_mult, applied to yesterday's signal.
|
||||
Financing cost charged on the borrowed (>1x) portion. Backtest risk-managed-2x vs naive-2x; emit
|
||||
live target weights for a given capital. ETF mapping for deployment in the verdict.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from signal_sweep import load_panel, build_returns # noqa: E402
|
||||
import pit_sweep # noqa: E402
|
||||
|
||||
TARGET_VOL = 0.10
|
||||
MAXLEV = 2.0
|
||||
FIN = 0.06 # 6%/yr financing on borrowed portion
|
||||
DD1, DD2, DD3 = -0.15, -0.25, -0.35
|
||||
CORR_HI = 0.45 # avg pairwise corr above which to de-risk
|
||||
|
||||
|
||||
def volnorm(r, tv=TARGET_VOL, win=63):
|
||||
out = np.zeros_like(r)
|
||||
for t in range(win, len(r)):
|
||||
rv = np.std(r[t - win:t]) * math.sqrt(252)
|
||||
out[t] = r[t] * min(5.0, tv / (rv + 1e-9))
|
||||
return out
|
||||
|
||||
|
||||
def stats(r):
|
||||
r = r[np.isfinite(r)]
|
||||
ann = r.mean() * 252; vol = r.std() * math.sqrt(252)
|
||||
eq = np.cumprod(1 + r); dd = float((eq / np.maximum.accumulate(eq) - 1).min())
|
||||
return ann, vol, (ann / vol if vol > 0 else float("nan")), dd
|
||||
|
||||
|
||||
def build_streams():
|
||||
roots, days, close, op, inst = load_panel()[:5]
|
||||
lc, R, ov, intr = build_returns(close, op, inst)
|
||||
T, N = close.shape
|
||||
vol63 = np.full_like(lc, np.nan)
|
||||
for t in range(63, T):
|
||||
vol63[t] = np.nanstd(R[t - 63:t], axis=0)
|
||||
iv = 1.0 / np.where(vol63 > 0, vol63, np.nan)
|
||||
tsig = np.full_like(lc, np.nan); tsig[252:] = np.sign(lc[252:] - lc[:-252])
|
||||
avail = np.isfinite(close) & np.isfinite(vol63) & (vol63 > 0) & np.isfinite(tsig)
|
||||
w = np.where(avail, tsig * iv, 0.0); g = np.abs(w).sum(1, keepdims=True); g[g == 0] = 1; w = w / g
|
||||
trend = np.zeros(T); trend[1:] = np.sum(w[:-1] * R[1:], axis=1)
|
||||
c = {r: roots.index(r) for r in ("ES", "ZN", "GC", "CL")}
|
||||
fut = {"equity": R[:, c["ES"]], "bond": R[:, c["ZN"]], "gold": R[:, c["GC"]], "commod": R[:, c["CL"]], "trend": trend}
|
||||
syms, cdays, cc, _, _ = pit_sweep.load()
|
||||
j = syms.index("BTCUSDT"); lcb = np.log(cc[:, j])
|
||||
btc = {int(cdays[t]): (lcb[t] - lcb[t - 1]) for t in range(1, len(cdays)) if np.isfinite(lcb[t]) and np.isfinite(lcb[t - 1])}
|
||||
idx = np.array([i for i, d in enumerate(days) if int(d) in btc])
|
||||
streams = {k: v[idx] for k, v in fut.items()}
|
||||
streams["crypto"] = np.array([btc[int(days[i])] for i in idx])
|
||||
return streams, days[idx], (1970 + days[idx] / 365.25).astype(int)
|
||||
|
||||
|
||||
def risk_managed(M, maxlev, with_risk=True):
|
||||
"""M: [T, S] vol-normalized stream returns. Returns book daily returns under the risk layer."""
|
||||
base = np.nanmean(M, axis=1) # risk-parity combination
|
||||
T = len(base)
|
||||
out = np.zeros(T); eq = 1.0; peak = 1.0
|
||||
for t in range(63, T - 1):
|
||||
rv = np.std(base[t - 63:t]) * math.sqrt(252)
|
||||
L = min(maxlev, TARGET_VOL / (rv + 1e-9)) # (1) vol-target, capped at maxlev
|
||||
if with_risk:
|
||||
mu = base[t - 63:t].mean() * 252; var = (base[t - 63:t].std() ** 2) * 252
|
||||
L = min(L, max(0.0, mu / (var + 1e-9))) # (4) Kelly cap
|
||||
dd = eq / peak - 1.0 # (2) drawdown circuit-breaker
|
||||
ddm = 1.0 if dd > DD1 else (0.5 if dd > DD2 else (0.25 if dd > DD3 else 0.0))
|
||||
cm = np.corrcoef(np.nan_to_num(M[t - 63:t]).T) # (3) correlation de-risk
|
||||
ac = (cm.sum() - cm.shape[0]) / (cm.shape[0] ** 2 - cm.shape[0])
|
||||
corrm = 1.0 if ac < CORR_HI else max(0.4, 1 - (ac - CORR_HI) * 2)
|
||||
L *= ddm * corrm
|
||||
L = max(0.0, min(L, maxlev))
|
||||
r = L * base[t + 1] - FIN * max(L - 1.0, 0.0) / 252 # financing on borrowed portion
|
||||
out[t + 1] = r
|
||||
eq *= (1 + r); peak = max(peak, eq)
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
streams, days, year = build_streams()
|
||||
names = list(streams)
|
||||
M = np.column_stack([volnorm(streams[n]) for n in names])
|
||||
print(f"DEPLOYABLE MULTI-STRAT BOOK — {len(names)} streams, {M.shape[0]} days ({names})")
|
||||
for lab, wr, lev in [("risk-managed 2x", True, 2.0), ("NAIVE 2x (no risk layer)", False, 2.0),
|
||||
("risk-managed 1x", True, 1.0)]:
|
||||
r = risk_managed(M, lev, wr)
|
||||
a, v, sh, dd = stats(r)
|
||||
print(f" {lab:>26}: Sharpe {sh:+.2f} ann {100*a:+.1f}% vol {100*v:.0f}% maxDD {100*dd:+.1f}%")
|
||||
rm = risk_managed(M, 2.0, True)
|
||||
print(f" per-year Sharpe (risk-managed 2x): " + " ".join(f"{y}:{stats(rm[year == y])[2]:+.1f}" for y in sorted(set(year)) if (year == y).sum() > 150))
|
||||
|
||||
# live sizing: current leverage + target $ per stream for capital
|
||||
base = np.nanmean(M, axis=1); t = len(base) - 1
|
||||
rv = np.std(base[t - 63:t]) * math.sqrt(252)
|
||||
Lv = min(MAXLEV, TARGET_VOL / (rv + 1e-9))
|
||||
cap = 35000
|
||||
print(f"\n LIVE SIZING (today): vol-target leverage {Lv:.2f}x -> deploy ${cap*Lv:,.0f} gross on ${cap:,.0f}")
|
||||
per = cap * Lv / len(names)
|
||||
etf = {"equity": "SPY", "bond": "IEF", "gold": "GLD", "commod": "PDBC", "trend": "DBMF", "crypto": "BTC(spot)"}
|
||||
for n in names:
|
||||
print(f" {n:>7} ({etf[n]:>9}): ${per:,.0f}")
|
||||
print("\n VERDICT: risk-managed 2x should keep maxDD bounded (~-20-30%) vs naive 2x (~-40-50%) at higher")
|
||||
print(" return than 1x -> the hedge-fund model at retail. Deploy via the ETFs above (2x Reg-T margin) +")
|
||||
print(" small crypto sleeve. Same ~0.7-Sharpe book; leverage+risk-layer = the fund. Honest: still beta,")
|
||||
print(" drawdowns real, financing drags, single-window crypto sleeve -> size crypto small.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
85
scripts/surfer/multistrat_book_v2.py
Normal file
85
scripts/surfer/multistrat_book_v2.py
Normal file
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Multi-strat book v2 — upgraded with foxhunt risk-stack IDEAS (not crude reimplementation):
|
||||
|
||||
- PER-STREAM EDGE-DECAY TRUST (Page-Hinkley-style decay detection -> continuous theta in [0,1])
|
||||
that down-weights a stream whose premium is degrading and re-weights it when it recovers
|
||||
(resurrection discipline) -> ADAPTIVE allocation vs static risk-parity.
|
||||
[pearl_edge_decay_detection_is_a_missing_abstraction_layer + dead_signal_resurrection_discipline]
|
||||
- KELLY-fraction leverage cap [pearl_position_sizing_missing_adaptation_layer]
|
||||
- CMDP drawdown circuit-breaker [pearl_cmdp_consec_loss_counter]
|
||||
- correlation-spike de-risk (diversification breaks in crises)
|
||||
Compares static-risk-parity vs edge-decay-adaptive, both risk-managed 2x. Does the foxhunt idea help?
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from multistrat_book import build_streams, volnorm, stats, TARGET_VOL, FIN, DD1, DD2, DD3, CORR_HI # noqa: E402
|
||||
|
||||
|
||||
def edge_decay_trust(stream, win=126, ema=0.94):
|
||||
"""Page-Hinkley-style per-stream edge health -> theta in [0.1,1]. Detects decay (trailing
|
||||
risk-adj return falling), floors at 0.1 so a dead stream can RESURRECT when it recovers."""
|
||||
T = len(stream); theta = np.ones(T)
|
||||
th = 1.0
|
||||
for t in range(win, T):
|
||||
seg = stream[t - win:t]
|
||||
sr = seg.mean() / (seg.std() + 1e-9) * math.sqrt(252)
|
||||
target = float(np.clip((sr - (-0.5)) / (0.5 - (-0.5)), 0.1, 1.0)) # SR>0.5 ->1, <-0.5 ->0.1
|
||||
th = ema * th + (1 - ema) * target # smooth (asymmetric-ish via EMA)
|
||||
theta[t] = th
|
||||
return theta
|
||||
|
||||
|
||||
def risk_layer(combo, M, maxlev, with_risk=True):
|
||||
T = len(combo); out = np.zeros(T); eq = 1.0; peak = 1.0
|
||||
for t in range(63, T - 1):
|
||||
rv = np.std(combo[t - 63:t]) * math.sqrt(252)
|
||||
L = min(maxlev, TARGET_VOL / (rv + 1e-9))
|
||||
if with_risk:
|
||||
mu = combo[t - 63:t].mean() * 252; var = (combo[t - 63:t].std() ** 2) * 252
|
||||
L = min(L, max(0.0, mu / (var + 1e-9)))
|
||||
dd = eq / peak - 1.0
|
||||
ddm = 1.0 if dd > DD1 else (0.5 if dd > DD2 else (0.25 if dd > DD3 else 0.0))
|
||||
cm = np.corrcoef(np.nan_to_num(M[t - 63:t]).T)
|
||||
ac = (cm.sum() - cm.shape[0]) / (cm.shape[0] ** 2 - cm.shape[0])
|
||||
corrm = 1.0 if ac < CORR_HI else max(0.4, 1 - (ac - CORR_HI) * 2)
|
||||
L *= ddm * corrm
|
||||
L = max(0.0, min(L, maxlev))
|
||||
r = L * combo[t + 1] - FIN * max(L - 1.0, 0.0) / 252
|
||||
out[t + 1] = r; eq *= (1 + r); peak = max(peak, eq)
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
streams, days, year = build_streams()
|
||||
names = list(streams)
|
||||
M = np.column_stack([volnorm(streams[n]) for n in names])
|
||||
T = M.shape[0]
|
||||
# edge-decay trust per stream
|
||||
Theta = np.column_stack([edge_decay_trust(M[:, i]) for i in range(len(names))])
|
||||
static = np.nanmean(M, axis=1) # equal risk-parity
|
||||
tw = Theta / np.maximum(Theta.sum(1, keepdims=True), 1e-9) # trust-weighted
|
||||
adaptive = np.nansum(tw * np.nan_to_num(M), axis=1)
|
||||
|
||||
print(f"MULTI-STRAT v2 (foxhunt edge-decay-adaptive) — {len(names)} streams, {T} days")
|
||||
print(f" streams: {names}")
|
||||
for lab, combo in [("static risk-parity", static), ("edge-decay ADAPTIVE", adaptive)]:
|
||||
for ln, lev, wr in [("2x risk-managed", 2.0, True), ("naive 2x", 2.0, False)]:
|
||||
r = risk_layer(combo, M, lev, wr); a, v, sh, dd = stats(r)
|
||||
print(f" {lab:>20} | {ln:>16}: Sharpe {sh:+.2f} ann {100*a:+.1f}% maxDD {100*dd:+.1f}%")
|
||||
# show what the trust layer is doing (avg theta per stream + recent)
|
||||
print(" edge-trust (avg | latest) per stream:")
|
||||
for i, n in enumerate(names):
|
||||
print(f" {n:>7}: {Theta[126:, i].mean():.2f} | {Theta[-1, i]:.2f}")
|
||||
radap = risk_layer(adaptive, M, 2.0, True)
|
||||
print(f" per-year Sharpe (adaptive 2x): " + " ".join(f"{y}:{stats(radap[year == y])[2]:+.1f}" for y in sorted(set(year)) if (year == y).sum() > 150))
|
||||
print("\n VERDICT: edge-decay-adaptive Sharpe > static AND lower maxDD = the foxhunt trust-layer idea adds")
|
||||
print(" real value (down-weights decaying streams, resurrects recovered ones). If ~equal, static suffices.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
79
scripts/surfer/multistrat_book_v3.py
Normal file
79
scripts/surfer/multistrat_book_v3.py
Normal file
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Multi-strat book v3 — risk layer rebuilt with foxhunt's ADAPTIVE-controller discipline.
|
||||
|
||||
Fixes the v1/v2 static risk layer (which crushed returns — a one-way latch per
|
||||
pearl_cmdp_consec_loss_counter_is_one_way_latch). Controllers (all floored / resurrection-capable):
|
||||
- EMA online vol (not fixed-window std) [Welford/EMA online stats]
|
||||
- Kelly leverage with FLOOR + bootstrap [pearl_bootstrap_must_respect_clamp_range]
|
||||
- drawdown de-lever CONTINUOUS + self-recovering [fix the one-way latch -> resurrection]
|
||||
- correlation de-risk Z-SCORED vs own distribution [adaptive, not fixed threshold]
|
||||
- leverage floor so nothing dies permanently [pearl_dead_signal_resurrection_discipline]
|
||||
Combination = edge-decay-adaptive (v2). Compare naive 2x / static-risk 2x / ADAPTIVE-risk 2x.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from multistrat_book import build_streams, volnorm, stats, TARGET_VOL, FIN, risk_managed # noqa: E402
|
||||
from multistrat_book_v2 import edge_decay_trust # noqa: E402
|
||||
|
||||
KELLY_FLOOR, LEV_FLOOR = 0.5, 0.3
|
||||
DD_DEADBAND, DD_SENS, DD_FLOOR = 0.05, 3.0, 0.40
|
||||
|
||||
|
||||
def adaptive_risk(combo, M, maxlev):
|
||||
T = len(combo); out = np.zeros(T); eq = 1.0; peak = 1.0
|
||||
ema_mu = float(np.nanmean(combo[:63])); ema_var = float(np.nanvar(combo[:63])) + 1e-12
|
||||
chist = []
|
||||
for t in range(63, T - 1):
|
||||
x = combo[t]
|
||||
ema_mu = 0.97 * ema_mu + 0.03 * x # EMA online stats
|
||||
ema_var = 0.97 * ema_var + 0.03 * (x - ema_mu) ** 2
|
||||
rv = math.sqrt(max(ema_var, 1e-12) * 252)
|
||||
L_vol = TARGET_VOL / (rv + 1e-9)
|
||||
kelly = min(maxlev, max(KELLY_FLOOR, (ema_mu * 252) / (ema_var * 252 + 1e-9))) # floored Kelly
|
||||
dd = eq / peak - 1.0
|
||||
dd_mult = float(np.clip(1.0 - DD_SENS * max(0.0, -dd - DD_DEADBAND), DD_FLOOR, 1.0)) # continuous + recovers
|
||||
cm = np.corrcoef(np.nan_to_num(M[t - 63:t]).T)
|
||||
ac = (cm.sum() - cm.shape[0]) / (cm.shape[0] ** 2 - cm.shape[0])
|
||||
chist.append(ac)
|
||||
if len(chist) > 60:
|
||||
h = np.array(chist[-120:]); z = (ac - h.mean()) / (h.std() + 1e-9)
|
||||
corr_mult = float(np.clip(1.0 - 0.20 * max(0.0, z), 0.5, 1.0))
|
||||
else:
|
||||
corr_mult = 1.0
|
||||
L = float(np.clip(min(L_vol, kelly) * dd_mult * corr_mult, LEV_FLOOR, maxlev))
|
||||
r = L * combo[t + 1] - FIN * max(L - 1.0, 0.0) / 252
|
||||
out[t + 1] = r; eq *= (1 + r); peak = max(peak, eq)
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
streams, days, year = build_streams()
|
||||
names = list(streams)
|
||||
M = np.column_stack([volnorm(streams[n]) for n in names])
|
||||
Theta = np.column_stack([edge_decay_trust(M[:, i]) for i in range(len(names))])
|
||||
tw = Theta / np.maximum(Theta.sum(1, keepdims=True), 1e-9)
|
||||
combo = np.nansum(tw * np.nan_to_num(M), axis=1) # edge-decay-adaptive combination
|
||||
|
||||
print(f"MULTI-STRAT v3 (adaptive allocation + adaptive risk layer) — {len(names)} streams, {M.shape[0]} days")
|
||||
res = {
|
||||
"naive 2x (no risk layer)": np.concatenate([[0], 2.0 * combo[1:] - FIN * 1.0 / 252]),
|
||||
"static risk layer 2x": risk_managed(M, 2.0, True), # v1 static (recomputes nanmean inside)
|
||||
"ADAPTIVE risk layer 2x": adaptive_risk(combo, M, 2.0),
|
||||
"ADAPTIVE risk layer 1x": adaptive_risk(combo, M, 1.0),
|
||||
}
|
||||
for lab, r in res.items():
|
||||
a, v, sh, dd = stats(r)
|
||||
print(f" {lab:>26}: Sharpe {sh:+.2f} ann {100*a:+.1f}% vol {100*v:.0f}% maxDD {100*dd:+.1f}%")
|
||||
radap = res["ADAPTIVE risk layer 2x"]
|
||||
print(f" per-year Sharpe (ADAPTIVE 2x): " + " ".join(f"{y}:{stats(radap[year == y])[2]:+.1f}" for y in sorted(set(year)) if (year == y).sum() > 150))
|
||||
print("\n VERDICT: ADAPTIVE-risk 2x should beat naive 2x AND static-risk 2x on Sharpe AND maxDD =")
|
||||
print(" proper foxhunt-style risk management makes leverage survivable without killing return.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
218
scripts/surfer/multistrat_bot.py
Normal file
218
scripts/surfer/multistrat_bot.py
Normal file
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Production rebalance bot for the adaptive multi-strat book on IBKR — lean enterprise core.
|
||||
|
||||
Only what money-touching code actually needs: env config (no secrets in code), paper/live guard,
|
||||
leverage cap, drawdown circuit-breaker (HWM-based), once-per-period idempotency, post-trade
|
||||
reconciliation, structured JSON audit log. Default DRY-RUN. Designed to run from cron.
|
||||
|
||||
Run:
|
||||
python3 multistrat_bot.py status # account + targets + gates, no trading
|
||||
python3 multistrat_bot.py dry # full plan incl. orders, no placement (default)
|
||||
python3 multistrat_bot.py run # place orders (still requires MULTISTRAT_EXECUTE=true)
|
||||
|
||||
Env (all optional unless noted):
|
||||
IB_HOST=127.0.0.1 IB_PORT=4002 IB_CLIENT_ID=7
|
||||
MULTISTRAT_MAXLEV=1.0 gross exposure cap (× NLV); book is unlevered
|
||||
MULTISTRAT_HYST=0.03 rebalance hysteresis (× NLV); entry-from-zero uses 0.5% floor
|
||||
MULTISTRAT_REBALANCE_DAYS=7 min days between rebalances (idempotency)
|
||||
MULTISTRAT_DD_HALT=0.20 halt trading if NLV < HWM·(1−this)
|
||||
MULTISTRAT_MAX_ORDER=0.30 refuse any single order > this × NLV (fat-finger guard)
|
||||
MULTISTRAT_EXECUTE=false must be "true" for `run` to actually place
|
||||
MULTISTRAT_ALLOW_LIVE_CONFIRMED must equal "I_UNDERSTAND_REAL_MONEY" to trade a non-paper acct
|
||||
MULTISTRAT_STATE / MULTISTRAT_LOG override state/log paths
|
||||
"""
|
||||
import datetime as dt
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from multistrat_paper import build, book_series, INSTR # noqa: E402
|
||||
|
||||
_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
TICKER = {"equity": "SPY", "bond": "IEF", "gold": "GLD", "commod": "PDBC", "trend": "DBMF", "crypto": "IBIT"}
|
||||
|
||||
|
||||
def cfg(k, d):
|
||||
return os.environ.get(k, d)
|
||||
|
||||
|
||||
C = {
|
||||
"host": cfg("IB_HOST", "127.0.0.1"), "port": int(cfg("IB_PORT", "4002")), "cid": int(cfg("IB_CLIENT_ID", "7")),
|
||||
"maxlev": float(cfg("MULTISTRAT_MAXLEV", "1.0")), "hyst": float(cfg("MULTISTRAT_HYST", "0.03")),
|
||||
"rebal_days": int(cfg("MULTISTRAT_REBALANCE_DAYS", "7")), "dd_halt": float(cfg("MULTISTRAT_DD_HALT", "0.20")),
|
||||
"max_order": float(cfg("MULTISTRAT_MAX_ORDER", "0.30")),
|
||||
"data_tol": float(cfg("MULTISTRAT_DATA_TOL", "0.25")), # halt if broker summary vs positions disagree > this
|
||||
"execute": cfg("MULTISTRAT_EXECUTE", "false").lower() == "true",
|
||||
"live_ok": cfg("MULTISTRAT_ALLOW_LIVE_CONFIRMED", "") == "I_UNDERSTAND_REAL_MONEY",
|
||||
"state": cfg("MULTISTRAT_STATE", os.path.join(_REPO, "data/surfer/multistrat_bot_state.json")),
|
||||
"log": cfg("MULTISTRAT_LOG", os.path.join(_REPO, "data/surfer/multistrat_bot.log")),
|
||||
}
|
||||
|
||||
|
||||
def log(level, event, **kv):
|
||||
rec = {"ts": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"), "level": level, "event": event, **kv}
|
||||
os.makedirs(os.path.dirname(C["log"]), exist_ok=True)
|
||||
with open(C["log"], "a") as f:
|
||||
f.write(json.dumps(rec) + "\n")
|
||||
print(f"[{level}] {event} " + " ".join(f"{k}={v}" for k, v in kv.items()))
|
||||
|
||||
|
||||
def load_state():
|
||||
try:
|
||||
return json.load(open(C["state"]))
|
||||
except Exception:
|
||||
return {"last_rebalance": None, "hwm_nlv": 0.0, "rebalances": 0}
|
||||
|
||||
|
||||
def save_state(s):
|
||||
os.makedirs(os.path.dirname(C["state"]), exist_ok=True)
|
||||
json.dump(s, open(C["state"], "w"), indent=2)
|
||||
|
||||
|
||||
def yhist_last(sym):
|
||||
res = json.loads(urllib.request.urlopen(urllib.request.Request(
|
||||
f"https://query1.finance.yahoo.com/v8/finance/chart/{sym}?interval=1d&range=5d",
|
||||
headers={"User-Agent": "Mozilla/5.0"}), timeout=30).read())["chart"]["result"][0]
|
||||
ts = res["timestamp"]; c = [x for x in res["indicators"]["quote"][0]["close"] if x is not None]
|
||||
age_days = (dt.datetime.now(dt.timezone.utc) - dt.datetime.fromtimestamp(ts[-1], dt.timezone.utc)).days
|
||||
return float(c[-1]), age_days
|
||||
|
||||
|
||||
def target_weights():
|
||||
_, R = build()
|
||||
_, w, lev = book_series(R)
|
||||
return {TICKER[nm]: float(w[j] * lev) for j, (_, nm) in enumerate(INSTR)}
|
||||
|
||||
|
||||
def gates(nlv, acct_is_paper, price_age, tw, state, data_gap=0.0):
|
||||
"""Pre-trade safety gates. Returns (ok, [reasons])."""
|
||||
fail = []
|
||||
if not acct_is_paper and not C["live_ok"]:
|
||||
fail.append("LIVE account but MULTISTRAT_ALLOW_LIVE_CONFIRMED not set — refusing to trade real money")
|
||||
if nlv <= 0:
|
||||
fail.append("NLV <= 0")
|
||||
if data_gap > C["data_tol"]:
|
||||
fail.append(f"ACCOUNT DATA INCONSISTENT: NLV vs cash+positions gap {100*data_gap:.0f}% > {100*C['data_tol']:.0f}% — broker data unreliable, refusing to size")
|
||||
gross = sum(tw.values())
|
||||
if gross > C["maxlev"] + 1e-6:
|
||||
fail.append(f"gross exposure {gross:.2f} > maxlev {C['maxlev']}")
|
||||
if price_age > 4:
|
||||
fail.append(f"stale prices ({price_age}d old) — data feed issue")
|
||||
hwm = max(state.get("hwm_nlv", 0.0), nlv)
|
||||
if hwm > 0 and nlv < hwm * (1 - C["dd_halt"]):
|
||||
fail.append(f"DRAWDOWN CIRCUIT-BREAKER: NLV ${nlv:,.0f} < HWM ${hwm:,.0f}·(1-{C['dd_halt']}) — halting")
|
||||
return (len(fail) == 0, fail)
|
||||
|
||||
|
||||
def main():
|
||||
mode = sys.argv[1] if len(sys.argv) > 1 else "dry"
|
||||
state = load_state()
|
||||
|
||||
tw = target_weights()
|
||||
prices, max_age = {}, 0
|
||||
for t in tw:
|
||||
px, age = yhist_last(t); prices[t] = px; max_age = max(max_age, age)
|
||||
log("INFO", "targets", mode=mode, weights={t: round(w, 3) for t, w in tw.items()}, price_age_d=max_age)
|
||||
|
||||
from ib_async import IB, Stock, MarketOrder
|
||||
ib = IB()
|
||||
last_err = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
ib.connect(C["host"], C["port"], clientId=C["cid"], timeout=15); break
|
||||
except Exception as e:
|
||||
last_err = e; ib.sleep(3)
|
||||
if not ib.isConnected():
|
||||
log("ERROR", "connect_failed", host=C["host"], port=C["port"], err=str(last_err)[:80]); return 1
|
||||
|
||||
try:
|
||||
accts = ib.managedAccounts(); acct = accts[0] if accts else "?"
|
||||
is_paper = acct.startswith("DU")
|
||||
summ = {v.tag: float(v.value) for v in ib.accountSummary() if v.tag in ("NetLiquidation", "TotalCashValue")}
|
||||
nlv = summ.get("NetLiquidation", 0.0); cash = summ.get("TotalCashValue", 0.0)
|
||||
port_mv = sum(it.marketValue for it in ib.portfolio())
|
||||
pos = {p.contract.symbol: p.position for p in ib.positions()}
|
||||
# IBKR (esp. paper) can report summary fields inconsistent with actual positions. Cross-check
|
||||
# NLV against an independent estimate (cash + actual position market value); size on the
|
||||
# CONSERVATIVE (lower) value so a glitch never inflates positions; gate if they disagree badly.
|
||||
est_portfolio = cash + port_mv
|
||||
data_gap = abs(nlv - est_portfolio) / max(nlv, est_portfolio, 1.0)
|
||||
rel_nlv = min(nlv, est_portfolio) if (nlv > 0 and est_portfolio > 0) else max(nlv, est_portfolio)
|
||||
log("INFO", "account", id=acct, paper=is_paper, nlv=round(nlv), cash=round(cash), port_mv=round(port_mv), reliable_nlv=round(rel_nlv), data_gap=round(data_gap, 3), positions=pos or None)
|
||||
if data_gap > 0.02:
|
||||
log("WARN", "account_data_inconsistent", nlv=round(nlv), cash_plus_positions=round(est_portfolio), gap_pct=round(100 * data_gap, 1), note="broker summary disagrees with actual positions (IBKR paper quirk) — sizing on conservative NLV")
|
||||
|
||||
ok, reasons = gates(rel_nlv, is_paper, max_age, tw, state, data_gap)
|
||||
for r in reasons:
|
||||
log("CRITICAL" if "CIRCUIT" in r or "LIVE" in r or "INCONSISTENT" in r else "WARN", "gate_fail", reason=r)
|
||||
if not ok:
|
||||
log("ERROR", "aborted", gates_failed=len(reasons)); return 2
|
||||
|
||||
# update high-water mark (only after gates pass = healthy NLV)
|
||||
state["hwm_nlv"] = max(state.get("hwm_nlv", 0.0), rel_nlv)
|
||||
|
||||
# idempotency: skip a `run` if rebalanced within the window
|
||||
if mode == "run" and state.get("last_rebalance"):
|
||||
last = dt.date.fromisoformat(state["last_rebalance"])
|
||||
age = (dt.date.today() - last).days
|
||||
if age < C["rebal_days"]:
|
||||
log("INFO", "skip_idempotent", days_since=age, window=C["rebal_days"]); return 0
|
||||
|
||||
# compute orders (entry-from-zero floor 0.5% NLV; rebalance uses hysteresis)
|
||||
orders = []
|
||||
for t, w in tw.items():
|
||||
tgt = rel_nlv * w / prices[t]; cur = pos.get(t, 0.0); delta = tgt - cur
|
||||
thresh = (0.005 if cur == 0 else C["hyst"]) * rel_nlv
|
||||
val = abs(delta * prices[t])
|
||||
if val <= thresh:
|
||||
continue
|
||||
if val > C["max_order"] * rel_nlv:
|
||||
log("WARN", "order_capped", ticker=t, value=round(val), cap=round(C["max_order"] * nlv))
|
||||
qty = int(round(abs(delta))) # whole shares — IBKR API rejects fractional (error 10243)
|
||||
if qty == 0:
|
||||
log("INFO", "skip_subshare", ticker=t, note="rounds to 0 whole shares")
|
||||
continue
|
||||
orders.append((t, "BUY" if delta > 0 else "SELL", qty, round(val)))
|
||||
for t, side, qty, val in orders:
|
||||
log("INFO", "planned_order", ticker=t, side=side, qty=qty, value=val)
|
||||
if not orders:
|
||||
log("INFO", "in_band", note="book within hysteresis — nothing to do"); return 0
|
||||
|
||||
if mode != "run":
|
||||
log("INFO", "dry_run", n_orders=len(orders), note="set mode=run + MULTISTRAT_EXECUTE=true to place"); return 0
|
||||
if not C["execute"]:
|
||||
log("WARN", "execute_disabled", note="MULTISTRAT_EXECUTE != true — not placing"); return 0
|
||||
|
||||
# place + reconcile
|
||||
placed = []
|
||||
for t, side, qty, val in orders:
|
||||
try:
|
||||
c = Stock(t, "SMART", "USD"); ib.qualifyContracts(c)
|
||||
o = MarketOrder(side, qty); o.account = acct
|
||||
tr = ib.placeOrder(c, o); ib.sleep(2)
|
||||
log("INFO", "order_sent", ticker=t, side=side, qty=qty, status=tr.orderStatus.status)
|
||||
placed.append(tr)
|
||||
except Exception as e:
|
||||
log("ERROR", "order_failed", ticker=t, err=str(e)[:120]) # continue — partial book is recoverable next run
|
||||
ib.sleep(3)
|
||||
accepted = sum(1 for tr in placed if tr.orderStatus.status not in ("Cancelled", "ApiCancelled", "Inactive"))
|
||||
filled = sum(1 for tr in placed if tr.orderStatus.status == "Filled")
|
||||
newpos = {p.contract.symbol: p.position for p in ib.positions()}
|
||||
if accepted > 0: # mark the period done only if orders were actually accepted (not all rejected)
|
||||
state["last_rebalance"] = dt.date.today().isoformat(); state["rebalances"] = state.get("rebalances", 0) + 1
|
||||
else:
|
||||
log("WARN", "no_orders_accepted", note="all rejected — not marking rebalance; will retry next run")
|
||||
log("INFO", "reconcile", placed=len(placed), accepted=accepted, filled=filled, positions=newpos or None)
|
||||
return 0
|
||||
finally:
|
||||
save_state(state)
|
||||
ib.disconnect()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main() or 0)
|
||||
79
scripts/surfer/multistrat_dd.py
Normal file
79
scripts/surfer/multistrat_dd.py
Normal file
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Full drawdown profile of the deployable ETF book (not just max-DD): depth, frequency, duration,
|
||||
time-underwater, recovery, worst episodes, ulcer index. The live-experience picture."""
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from multistrat_paper import book_series, INSTR # noqa: E402
|
||||
|
||||
|
||||
def yhist(sym):
|
||||
res = json.loads(urllib.request.urlopen(urllib.request.Request(
|
||||
f"https://query1.finance.yahoo.com/v8/finance/chart/{sym}?interval=1d&range=10y",
|
||||
headers={"User-Agent": "Mozilla/5.0"}), timeout=30).read())["chart"]["result"][0]
|
||||
ts = res["timestamp"]; ind = res["indicators"]
|
||||
adj = ind.get("adjclose", [{}])[0].get("adjclose") or ind["quote"][0]["close"]
|
||||
return {datetime.datetime.utcfromtimestamp(t).strftime("%Y-%m-%d"): float(c) for t, c in zip(ts, adj) if c is not None}
|
||||
|
||||
|
||||
def dd_profile(book, dates, label):
|
||||
eq = np.cumprod(1 + book)
|
||||
peak = np.maximum.accumulate(eq)
|
||||
dd = eq / peak - 1.0
|
||||
# episodes: contiguous underwater stretches
|
||||
eps = []
|
||||
i = 0
|
||||
while i < len(dd):
|
||||
if dd[i] < -0.005:
|
||||
j = i
|
||||
while j < len(dd) and dd[j] < -0.0001:
|
||||
j += 1
|
||||
seg = dd[i:j]; trough = i + int(np.argmin(seg))
|
||||
eps.append((i, trough, min(j, len(dd) - 1), float(seg.min())))
|
||||
i = j
|
||||
else:
|
||||
i += 1
|
||||
eps.sort(key=lambda e: e[3])
|
||||
underwater = float((dd < -0.005).mean())
|
||||
# longest underwater stretch (days)
|
||||
longest = cur = 0
|
||||
for x in dd:
|
||||
cur = cur + 1 if x < -0.005 else 0; longest = max(longest, cur)
|
||||
ulcer = float(np.sqrt(np.mean((dd * 100) ** 2)))
|
||||
ann = book[np.isfinite(book)].mean() * 252
|
||||
print(f"\n===== {label} drawdown profile ({len(dates)}d) =====")
|
||||
print(f" max DD {100*dd.min():+.1f}% | avg DD (when underwater) {100*dd[dd<-0.005].mean():+.1f}% | % time underwater {100*underwater:.0f}%")
|
||||
print(f" longest underwater stretch: {longest} trading days (~{longest/21:.1f} months)")
|
||||
print(f" ulcer index {ulcer:.2f} | Calmar (ann/|maxDD|) {ann/abs(dd.min()+1e-9):.2f} | # drawdowns >2%: {sum(1 for e in eps if e[3]<-0.02)}")
|
||||
print(f" worst 5 episodes (depth | peak->trough->recovery, days):")
|
||||
for s, tr, rec, dep in eps[:5]:
|
||||
recd = "recovered" if rec < len(dd) - 1 and dd[rec] > -0.005 else "ongoing/end"
|
||||
print(f" {100*dep:>+6.1f}% {dates[s]} -> {dates[tr]} -> {dates[rec]} ({tr-s}d down, {rec-tr}d up, {recd})")
|
||||
return dd
|
||||
|
||||
|
||||
def main():
|
||||
data = {nm: yhist(sym) for sym, nm in INSTR}
|
||||
dates = sorted(set.intersection(*[set(d) for d in data.values()]))
|
||||
R = np.zeros((len(dates), len(INSTR)))
|
||||
for j, (_, nm) in enumerate(INSTR):
|
||||
s = np.array([data[nm][d] for d in dates]); R[1:, j] = s[1:] / s[:-1] - 1
|
||||
book, w, L = book_series(R)
|
||||
dd_profile(book, dates, "adaptive multi-strat book")
|
||||
# 60/40 for context
|
||||
je = [nm for _, nm in INSTR].index("equity"); jb = [nm for _, nm in INSTR].index("bond")
|
||||
r6040 = np.zeros(len(dates)); r6040[1:] = 0.6 * R[1:, je] + 0.4 * R[1:, jb]
|
||||
dd_profile(r6040, dates, "60/40 (context)")
|
||||
print("\n READ: the book's drawdowns are shallow (~-5%), infrequent, short-recovery vs 60/40's -21% deep ones.")
|
||||
print(" That shallow/short DD profile is the real value -- you stay invested, never panic-sell, compound steadily.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
65
scripts/surfer/multistrat_etf_backtest.py
Normal file
65
scripts/surfer/multistrat_etf_backtest.py
Normal file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Backtest the EXACT deployable ETF book (the multistrat_paper live pipeline) on ~6y Yahoo history.
|
||||
|
||||
Real instruments (SPY/IEF/GLD/PDBC/DBMF + BTC-USD), same adaptive pipeline as the live harness
|
||||
(vol-norm -> edge-decay trust -> trust-weighted combo -> adaptive risk layer, unlevered). Window is
|
||||
limited by DBMF inception (~2019), so it spans COVID-2020, bear-2022, bulls-2021/2024 = multiple
|
||||
regimes. Per-year Sharpe = the regime/robustness check; compared to plain 60/40 (SPY/IEF).
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from multistrat_paper import INSTR, book_series # exact live pipeline # noqa: E402
|
||||
|
||||
|
||||
def yhist(sym, rng="10y"):
|
||||
res = json.loads(urllib.request.urlopen(urllib.request.Request(
|
||||
f"https://query1.finance.yahoo.com/v8/finance/chart/{sym}?interval=1d&range={rng}",
|
||||
headers={"User-Agent": "Mozilla/5.0"}), timeout=30).read())["chart"]["result"][0]
|
||||
ts = res["timestamp"]; ind = res["indicators"]
|
||||
adj = ind.get("adjclose", [{}])[0].get("adjclose") or ind["quote"][0]["close"]
|
||||
return {datetime.datetime.utcfromtimestamp(t).strftime("%Y-%m-%d"): float(c) for t, c in zip(ts, adj) if c is not None}
|
||||
|
||||
|
||||
def stats(r):
|
||||
r = r[np.isfinite(r) & (r != 0)]
|
||||
ann = r.mean() * 252; vol = r.std() * math.sqrt(252)
|
||||
eq = np.cumprod(1 + r); dd = float((eq / np.maximum.accumulate(eq) - 1).min())
|
||||
return ann, vol, (ann / vol if vol > 0 else float("nan")), dd
|
||||
|
||||
|
||||
def main():
|
||||
data = {nm: yhist(sym) for sym, nm in INSTR}
|
||||
dates = sorted(set.intersection(*[set(d) for d in data.values()]))
|
||||
R = np.zeros((len(dates), len(INSTR)))
|
||||
for j, (_, nm) in enumerate(INSTR):
|
||||
s = np.array([data[nm][d] for d in dates]); R[1:, j] = s[1:] / s[:-1] - 1
|
||||
print(f"DEPLOYABLE ETF BOOK backtest — {len(dates)} days ({dates[0]}..{dates[-1]})")
|
||||
|
||||
book, w, L = book_series(R)
|
||||
a, v, sh, dd = stats(book)
|
||||
print(f" adaptive multi-strat (unlevered): Sharpe {sh:+.2f} ann {100*a:+.1f}% vol {100*v:.1f}% maxDD {100*dd:+.1f}%")
|
||||
# 60/40 benchmark over same window
|
||||
j_eq = [nm for _, nm in INSTR].index("equity"); j_bd = [nm for _, nm in INSTR].index("bond")
|
||||
r6040 = np.zeros(len(dates)); r6040[1:] = 0.6 * R[1:, j_eq] + 0.4 * R[1:, j_bd]
|
||||
a2, v2, sh2, dd2 = stats(r6040)
|
||||
print(f" 60/40 (SPY/IEF) benchmark: Sharpe {sh2:+.2f} ann {100*a2:+.1f}% vol {100*v2:.1f}% maxDD {100*dd2:+.1f}%")
|
||||
# equity buy-hold
|
||||
aeq, veq, sheq, ddeq = stats(np.concatenate([[0], R[1:, j_eq]]))
|
||||
print(f" equity buy-hold (SPY): Sharpe {sheq:+.2f} ann {100*aeq:+.1f}% maxDD {100*ddeq:+.1f}%")
|
||||
yr = np.array([int(d[:4]) for d in dates])
|
||||
print(" per-year Sharpe (adaptive book): " + " ".join(f"{y}:{stats(book[yr == y])[2]:+.1f}" for y in sorted(set(yr)) if (yr == y).sum() > 100))
|
||||
print(f" current target weights: " + " ".join(f"{nm}:{100*w[j]*L:.0f}%" for j, (_, nm) in enumerate(INSTR)))
|
||||
print("\n VERDICT: adaptive book Sharpe >= 60/40 AND lower maxDD across most years = the deployable book")
|
||||
print(" delivers on real ETFs. If ~60/40, the diversification+adaptive layer earns its keep modestly.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
70
scripts/surfer/multistrat_etf_expanded.py
Normal file
70
scripts/surfer/multistrat_etf_expanded.py
Normal file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
"""What can we DO with diversification: add more uncorrelated net-positive streams (the sqrt(N) lever).
|
||||
|
||||
Base 6 (SPY/IEF/GLD/PDBC/DBMF/BTC) + candidates: VNQ(REITs), TIP(infl-linked), HYG(HY credit),
|
||||
EFA(intl eq), EEM(EM eq), PUTW(vol-risk-premium/buy-write). Same adaptive pipeline (edge-decay
|
||||
trust auto-down-weights the bad ones). Does the expanded book beat the base-6? The trust layer
|
||||
decides which candidates earn their place.
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from multistrat_paper import book_series # exact adaptive pipeline # noqa: E402
|
||||
|
||||
BASE = [("SPY", "equity"), ("IEF", "bond"), ("GLD", "gold"), ("PDBC", "commod"), ("DBMF", "trend"), ("BTC-USD", "crypto")]
|
||||
CAND = [("VNQ", "reit"), ("TIP", "tips"), ("HYG", "hycredit"), ("EFA", "intl"), ("EEM", "em"), ("PUTW", "volprem")]
|
||||
|
||||
|
||||
def yhist(sym):
|
||||
res = json.loads(urllib.request.urlopen(urllib.request.Request(
|
||||
f"https://query1.finance.yahoo.com/v8/finance/chart/{sym}?interval=1d&range=10y",
|
||||
headers={"User-Agent": "Mozilla/5.0"}), timeout=30).read())["chart"]["result"][0]
|
||||
ts = res["timestamp"]; ind = res["indicators"]
|
||||
adj = ind.get("adjclose", [{}])[0].get("adjclose") or ind["quote"][0]["close"]
|
||||
return {datetime.datetime.utcfromtimestamp(t).strftime("%Y-%m-%d"): float(c) for t, c in zip(ts, adj) if c is not None}
|
||||
|
||||
|
||||
def stats(r):
|
||||
r = r[np.isfinite(r) & (r != 0)]
|
||||
ann = r.mean() * 252; vol = r.std() * math.sqrt(252)
|
||||
eq = np.cumprod(1 + r); dd = float((eq / np.maximum.accumulate(eq) - 1).min())
|
||||
return ann, vol, (ann / vol if vol > 0 else float("nan")), dd
|
||||
|
||||
|
||||
def run(instr, data, label):
|
||||
dates = sorted(set.intersection(*[set(data[nm]) for _, nm in instr]))
|
||||
R = np.zeros((len(dates), len(instr)))
|
||||
for j, (_, nm) in enumerate(instr):
|
||||
s = np.array([data[nm][d] for d in dates]); R[1:, j] = s[1:] / s[:-1] - 1
|
||||
book, w, L = book_series(R)
|
||||
a, v, sh, dd = stats(book)
|
||||
yr = np.array([int(d[:4]) for d in dates])
|
||||
py = " ".join(f"{y}:{stats(book[yr==y])[2]:+.1f}" for y in sorted(set(yr)) if (yr == y).sum() > 100)
|
||||
print(f" {label:>16} ({len(instr)} streams, {len(dates)}d): Sharpe {sh:+.2f} ann {100*a:+.1f}% maxDD {100*dd:+.1f}%")
|
||||
print(f" per-year: {py}")
|
||||
return w, [nm for _, nm in instr]
|
||||
|
||||
|
||||
def main():
|
||||
allinstr = BASE + CAND
|
||||
data = {nm: yhist(sym) for sym, nm in allinstr}
|
||||
print(f"EXPANDED BOOK (diversification lever) — base 6 vs +candidates")
|
||||
run(BASE, data, "base-6")
|
||||
w, names = run(allinstr, data, "expanded-12")
|
||||
print(f"\n trust-layer weights in expanded book (which candidates earned their place):")
|
||||
order = sorted(range(len(names)), key=lambda j: -w[j])
|
||||
for j in order:
|
||||
print(f" {names[j]:>9}: {100*w[j]:>5.1f}%")
|
||||
print("\n VERDICT: expanded Sharpe > base-6 = more uncorrelated streams lift the book (diversification works).")
|
||||
print(" If ~equal, the candidates are too correlated / not net-positive -> 6 streams already captures it.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
78
scripts/surfer/multistrat_longhist.py
Normal file
78
scripts/surfer/multistrat_longhist.py
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Faster confidence WITHOUT waiting forward: (1) long-history backtest (~2006, incl 2008/2011/2015/
|
||||
2018/2020/2022 = many more regimes) on long-lived ETFs, (2) block-bootstrap CI on the Sharpe.
|
||||
|
||||
Long-history ETFs (no DBMF/crypto, which start 2019): SPY/IEF/GLD/DBC + a DIY trend sleeve (12-1
|
||||
momentum long/short on the 4, vol-targeted). Same adaptive pipeline. Per-year Sharpe incl 2008 +
|
||||
bootstrap distribution = how robust is the design across regimes, and how stable is the estimate."""
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from multistrat_paper import book_series # noqa: E402
|
||||
|
||||
ETFS = ["SPY", "IEF", "GLD", "DBC"]
|
||||
|
||||
|
||||
def yhist(sym):
|
||||
res = json.loads(urllib.request.urlopen(urllib.request.Request(
|
||||
f"https://query1.finance.yahoo.com/v8/finance/chart/{sym}?interval=1d&range=25y",
|
||||
headers={"User-Agent": "Mozilla/5.0"}), timeout=30).read())["chart"]["result"][0]
|
||||
ts = res["timestamp"]; ind = res["indicators"]
|
||||
adj = ind.get("adjclose", [{}])[0].get("adjclose") or ind["quote"][0]["close"]
|
||||
return {datetime.datetime.utcfromtimestamp(t).strftime("%Y-%m-%d"): float(c) for t, c in zip(ts, adj) if c is not None}
|
||||
|
||||
|
||||
def main():
|
||||
data = {s: yhist(s) for s in ETFS}
|
||||
dates = sorted(set.intersection(*[set(d) for d in data.values()]))
|
||||
P = np.column_stack([[data[s][d] for d in dates] for s in ETFS])
|
||||
T, n = P.shape
|
||||
R = np.zeros((T, n)); R[1:] = P[1:] / P[:-1] - 1
|
||||
# DIY trend sleeve: 12-1 momentum long/short on the 4 ETFs, equal risk
|
||||
lc = np.log(P); sig = np.zeros((T, n)); sig[252:] = np.sign(lc[252:] - lc[:-252])
|
||||
vol = np.full((T, n), np.nan)
|
||||
for t in range(63, T):
|
||||
vol[t] = R[t - 63:t].std(0)
|
||||
iv = 1.0 / np.where(vol > 0, vol, np.nan)
|
||||
w = np.nan_to_num(sig * iv); g = np.abs(w).sum(1, keepdims=True); g[g == 0] = 1; w = w / g
|
||||
trend = np.zeros(T); trend[1:] = np.sum(w[:-1] * R[1:], axis=1)
|
||||
Rall = np.column_stack([R, trend]) # 5 streams
|
||||
|
||||
book, _, _ = book_series(Rall)
|
||||
yr = np.array([int(d[:4]) for d in dates])
|
||||
|
||||
def st(r):
|
||||
r = r[np.isfinite(r) & (r != 0)]; a = r.mean() * 252; v = r.std() * math.sqrt(252)
|
||||
eq = np.cumprod(1 + r); dd = float((eq / np.maximum.accumulate(eq) - 1).min())
|
||||
return (a / v if v > 0 else float("nan")), dd
|
||||
sh, dd = st(book)
|
||||
print(f"LONG-HISTORY BOOK (SPY/IEF/GLD/DBC + trend, no crypto) — {dates[0]}..{dates[-1]} ({T}d)")
|
||||
print(f" full: Sharpe {sh:+.2f} maxDD {100*dd:+.1f}%")
|
||||
print(f" per-year Sharpe (incl crises): " + " ".join(f"{y}:{st(book[yr==y])[0]:+.1f}" for y in sorted(set(yr)) if (yr == y).sum() > 100))
|
||||
print(f" worst years maxDD: " + " ".join(f"{y}:{100*st(book[yr==y])[1]:+.0f}%" for y in [2008, 2011, 2015, 2018, 2020, 2022] if (yr == y).sum() > 100))
|
||||
|
||||
# block-bootstrap CI on Sharpe (block ~21d, 2000 resamples)
|
||||
b = book[np.isfinite(book)]; nb = len(b); bl = 21
|
||||
rng = np.random.default_rng(7)
|
||||
shs = []
|
||||
for _ in range(2000):
|
||||
idx = rng.integers(0, nb - bl, size=nb // bl)
|
||||
samp = np.concatenate([b[i:i + bl] for i in idx])
|
||||
v = samp.std() * math.sqrt(252)
|
||||
shs.append(samp.mean() * 252 / v if v > 0 else 0)
|
||||
shs = np.array(shs)
|
||||
print(f"\n bootstrap Sharpe CI (2000 block-resamples): p5 {np.percentile(shs,5):+.2f} p50 {np.percentile(shs,50):+.2f} p95 {np.percentile(shs,95):+.2f}")
|
||||
print(f" P(Sharpe>0.5): {100*np.mean(shs>0.5):.0f}% P(Sharpe>1.0): {100*np.mean(shs>1.0):.0f}%")
|
||||
print("\n VERDICT: positive across crisis years (2008/2020/2022) + bootstrap CI floor >0.5 = robust design,")
|
||||
print(" fast confidence without waiting forward. (Caveat: no DBMF/crypto here -> weaker than full book.)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
154
scripts/surfer/multistrat_paper.py
Normal file
154
scripts/surfer/multistrat_paper.py
Normal file
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""multistrat — local paper-forward of the adaptive multi-strat book (deployable spec, Phase 1).
|
||||
|
||||
6 streams via free Yahoo daily adj-close: SPY/IEF/GLD/PDBC/DBMF + BTC-USD. Pipeline (deterministic
|
||||
from history each run): vol-normalize each stream -> edge-decay trust theta (down-weight decaying /
|
||||
resurrect recovered) -> trust-weighted combination -> adaptive risk layer (EMA vol, Kelly-floor,
|
||||
continuous self-recovering drawdown de-lever, z-score correlation de-risk, leverage-floor),
|
||||
UNLEVERED (MAXLEV 1.0). Forward-start, idempotent per UTC day, catch-up. No capital, no agents.
|
||||
|
||||
python3 multistrat_paper.py status cum return + annualized + current target weights & leverage
|
||||
python3 multistrat_paper.py run book new trading days, persist (cron)
|
||||
python3 multistrat_paper.py weights [USD] today's $ allocation per ETF
|
||||
(alias paper == run)
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
import numpy as np
|
||||
|
||||
_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
STATE = os.path.join(_REPO, "data/surfer/multistrat_state.json")
|
||||
INSTR = [("SPY", "equity"), ("IEF", "bond"), ("GLD", "gold"), ("PDBC", "commod"), ("DBMF", "trend"), ("BTC-USD", "crypto")]
|
||||
TARGET_VOL, MAXLEV, KELLY_FLOOR, LEV_FLOOR = 0.10, 1.0, 0.5, 0.3
|
||||
DD_DEADBAND, DD_SENS, DD_FLOOR = 0.05, 3.0, 0.40
|
||||
|
||||
|
||||
def get(url, tries=4):
|
||||
import time
|
||||
for a in range(tries):
|
||||
try:
|
||||
return json.loads(urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}), timeout=30).read())
|
||||
except Exception:
|
||||
if a == tries - 1:
|
||||
raise
|
||||
time.sleep(2 * (a + 1))
|
||||
|
||||
|
||||
def yhist(sym):
|
||||
res = get(f"https://query1.finance.yahoo.com/v8/finance/chart/{sym}?interval=1d&range=2y")["chart"]["result"][0]
|
||||
ts = res["timestamp"]; ind = res["indicators"]
|
||||
adj = ind.get("adjclose", [{}])[0].get("adjclose") or ind["quote"][0]["close"]
|
||||
return {datetime.datetime.utcfromtimestamp(t).strftime("%Y-%m-%d"): float(c) for t, c in zip(ts, adj) if c is not None}
|
||||
|
||||
|
||||
def build():
|
||||
data = {nm: yhist(sym) for sym, nm in INSTR}
|
||||
dates = sorted(set.intersection(*[set(d) for d in data.values()]))
|
||||
# drop today's INCOMPLETE intraday bar if present — this is a daily-CLOSE strategy; an in-session
|
||||
# partial bar spikes the vol estimate and corrupts the leverage (e.g. halves it). Use completed closes.
|
||||
today = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d")
|
||||
if dates and dates[-1] == today:
|
||||
dates = dates[:-1]
|
||||
R = np.zeros((len(dates), len(INSTR)))
|
||||
for j, (_, nm) in enumerate(INSTR):
|
||||
s = np.array([data[nm][d] for d in dates])
|
||||
R[1:, j] = s[1:] / s[:-1] - 1
|
||||
return dates, R
|
||||
|
||||
|
||||
def volnorm(col, tv=TARGET_VOL, win=63):
|
||||
out = np.zeros_like(col)
|
||||
for t in range(win, len(col)):
|
||||
rv = col[t - win:t].std() * math.sqrt(252)
|
||||
out[t] = col[t] * min(5.0, tv / (rv + 1e-9))
|
||||
return out
|
||||
|
||||
|
||||
def trust(col, win=126, a=0.06):
|
||||
th = 1.0; out = np.ones_like(col)
|
||||
for t in range(win, len(col)):
|
||||
seg = col[t - win:t]; sr = seg.mean() / (seg.std() + 1e-9) * math.sqrt(252)
|
||||
th = (1 - a) * th + a * float(np.clip((sr + 0.5) / 1.0, 0.1, 1.0))
|
||||
out[t] = th
|
||||
return out
|
||||
|
||||
|
||||
def book_series(R):
|
||||
S = R.shape[1]
|
||||
M = np.column_stack([volnorm(R[:, j]) for j in range(S)])
|
||||
TH = np.column_stack([trust(M[:, j]) for j in range(S)])
|
||||
tw = TH / np.maximum(TH.sum(1, keepdims=True), 1e-9)
|
||||
combo = np.nansum(tw * M, axis=1)
|
||||
T = len(combo); book = np.zeros(T); eq = 1.0; peak = 1.0
|
||||
emu = float(np.nanmean(combo[:63])); evar = float(np.nanvar(combo[:63])) + 1e-12; chist = []
|
||||
Lc = np.zeros(T)
|
||||
for t in range(63, T - 1):
|
||||
x = combo[t]; emu = 0.97 * emu + 0.03 * x; evar = 0.97 * evar + 0.03 * (x - emu) ** 2
|
||||
L = min(MAXLEV, TARGET_VOL / (math.sqrt(max(evar, 1e-12) * 252) + 1e-9))
|
||||
L = min(L, max(KELLY_FLOOR, (emu * 252) / (evar * 252 + 1e-9)))
|
||||
dd = eq / peak - 1.0
|
||||
L *= float(np.clip(1 - DD_SENS * max(0.0, -dd - DD_DEADBAND), DD_FLOOR, 1.0))
|
||||
sub = np.nan_to_num(M[t - 63:t]); vmask = sub.std(0) > 1e-12 # only streams that vary (avoid /0 in corrcoef -> NaN)
|
||||
if int(vmask.sum()) >= 2:
|
||||
k = int(vmask.sum()); cm = np.corrcoef(sub[:, vmask].T); ac = float((np.nansum(cm) - k) / (k * k - k))
|
||||
else:
|
||||
ac = 0.0
|
||||
chist.append(ac)
|
||||
if len(chist) > 60:
|
||||
h = np.array(chist[-120:]); z = (ac - h.mean()) / (h.std() + 1e-9)
|
||||
L *= float(np.clip(1 - 0.2 * max(0.0, z), 0.5, 1.0))
|
||||
L = float(np.clip(L, LEV_FLOOR, MAXLEV)); Lc[t] = L
|
||||
book[t + 1] = L * combo[t + 1]; eq *= (1 + book[t + 1]); peak = max(peak, eq)
|
||||
return book, tw[-1], Lc[-2] if T > 1 else 0.0
|
||||
|
||||
|
||||
def load_state():
|
||||
if os.path.exists(STATE):
|
||||
return json.load(open(STATE))
|
||||
return {"last_date": "", "days": 0, "sum_r": 0.0, "sumsq_r": 0.0, "equity": 1.0, "peak": 1.0, "max_dd": 0.0}
|
||||
|
||||
|
||||
def main():
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "status"
|
||||
if cmd in ("run", "paper"):
|
||||
st = load_state(); dates, R = build(); book, w, L = book_series(R)
|
||||
os.makedirs(os.path.dirname(STATE), exist_ok=True)
|
||||
if not st["last_date"]:
|
||||
st["last_date"] = dates[-1]; json.dump(st, open(STATE, "w"))
|
||||
print(f"initialized forward tracking from {dates[-1]}"); return
|
||||
booked = 0
|
||||
for i in range(1, len(dates)):
|
||||
if dates[i] <= st["last_date"]:
|
||||
continue
|
||||
r = float(book[i])
|
||||
st["days"] += 1; st["sum_r"] += r; st["sumsq_r"] += r * r
|
||||
st["equity"] *= (1 + r); st["peak"] = max(st["peak"], st["equity"])
|
||||
st["max_dd"] = min(st["max_dd"], st["equity"] / st["peak"] - 1); st["last_date"] = dates[i]; booked += 1
|
||||
json.dump(st, open(STATE, "w"))
|
||||
print(f"{datetime.date.today()} booked {booked} day(s) -> day {st['days']} (through {st['last_date']}) "
|
||||
f"cum {100*(st['equity']-1):+.2f}% leverage {L:.2f}")
|
||||
elif cmd == "weights":
|
||||
cap = float(sys.argv[2]) if len(sys.argv) > 2 else 35000.0
|
||||
dates, R = build(); _, w, L = book_series(R)
|
||||
print(f"target allocation for ${cap:,.0f} (leverage {L:.2f}x, unlevered book):")
|
||||
for j, (sym, nm) in enumerate(INSTR):
|
||||
print(f" {nm:>7} ({sym:>8}): {100*w[j]*L:>5.1f}% ${cap*w[j]*L:>8,.0f}")
|
||||
else:
|
||||
st = load_state()
|
||||
n = st["days"]; m = (st["sum_r"] / n) if n else 0; var = (st["sumsq_r"] / n - m * m) if n > 1 else 0
|
||||
sh = (m * 252) / (math.sqrt(max(var, 1e-12)) * math.sqrt(252)) if n > 1 and var > 0 else float("nan")
|
||||
print(f"adaptive multi-strat paper — day {n} (through {st['last_date'] or 'n/a'})")
|
||||
print(f" cum {100*(st['equity']-1):+.2f}% ann-Sharpe {sh:+.2f} maxDD {100*st['max_dd']:+.1f}%")
|
||||
dates, R = build(); _, w, L = book_series(R)
|
||||
print(f" current leverage {L:.2f}x | target weights:")
|
||||
for j, (sym, nm) in enumerate(INSTR):
|
||||
print(f" {nm:>7} ({sym:>8}): {100*w[j]*L:>5.1f}%")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
83
scripts/surfer/multistrat_vol_test.py
Normal file
83
scripts/surfer/multistrat_vol_test.py
Normal file
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Does an ML vol-nowcaster (proxy for CfC/Mamba2) improve the risk layer's de-lever timing vs EMA?
|
||||
|
||||
Target: next-20d realized vol of the book's combination. Forecasters: EMA (baseline, what the book
|
||||
uses) vs gradient-boosting (walk-forward, leak-free) on vol features. Compare (1) OOS vol-forecast
|
||||
skill, (2) downstream: vol-targeting with ML-vol vs EMA-vol -> Sharpe / maxDD / realized-vol stability.
|
||||
If GB ~ EMA downstream, the heavier CfC/Mamba2 won't help (more capacity = more overfit). Vol clusters,
|
||||
so this is the fair shot.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from multistrat_book import build_streams, volnorm # noqa: E402
|
||||
from multistrat_book_v2 import edge_decay_trust # noqa: E402
|
||||
from sklearn.ensemble import HistGradientBoostingRegressor # noqa: E402
|
||||
|
||||
TV = 0.10
|
||||
|
||||
|
||||
def ema(x, span):
|
||||
a = 2 / (span + 1); out = np.zeros_like(x); m = 0.0
|
||||
for t in range(len(x)):
|
||||
m = a * x[t] + (1 - a) * m; out[t] = m
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
streams, days, year = build_streams()
|
||||
names = list(streams)
|
||||
M = np.column_stack([volnorm(streams[n]) for n in names])
|
||||
TH = np.column_stack([edge_decay_trust(M[:, i]) for i in range(len(names))])
|
||||
tw = TH / np.maximum(TH.sum(1, keepdims=True), 1e-9)
|
||||
combo = np.nansum(tw * np.nan_to_num(M), axis=1)
|
||||
T = len(combo)
|
||||
|
||||
# realized next-20d vol (target) and features (all causal)
|
||||
H = 20
|
||||
fwd = np.full(T, np.nan)
|
||||
for t in range(T - H):
|
||||
fwd[t] = combo[t + 1:t + 1 + H].std() * math.sqrt(252)
|
||||
sq = combo ** 2
|
||||
f_ema20 = np.sqrt(ema(sq, 20) * 252) # EMA vol (the baseline the book uses)
|
||||
feats = np.column_stack([
|
||||
np.sqrt(ema(sq, 10) * 252), np.sqrt(ema(sq, 20) * 252), np.sqrt(ema(sq, 60) * 252),
|
||||
np.abs(combo), np.array([combo[max(0, t - 5):t].std() for t in range(T)]) * math.sqrt(252),
|
||||
np.array([combo[max(0, t - 60):t].std() for t in range(T)]) * math.sqrt(252)])
|
||||
|
||||
# walk-forward GB vol forecast
|
||||
gb_fc = np.full(T, np.nan); INIT, STEP = 252, 60
|
||||
for s in range(INIT, T - H, STEP):
|
||||
e = min(s + STEP, T - H)
|
||||
tr = np.arange(63, s)
|
||||
ok = np.isfinite(fwd[tr]) & np.isfinite(feats[tr]).all(1)
|
||||
gb = HistGradientBoostingRegressor(max_depth=3, max_iter=150, learning_rate=0.05, min_samples_leaf=30).fit(feats[tr][ok], fwd[tr][ok])
|
||||
gb_fc[s:e] = gb.predict(feats[s:e])
|
||||
|
||||
valid = np.arange(INIT, T - H)
|
||||
v = valid[np.isfinite(fwd[valid]) & np.isfinite(gb_fc[valid])]
|
||||
print(f"VOL-NOWCASTER TEST — {T} days, OOS {len(v)}")
|
||||
print(f" OOS vol-forecast corr w/ realized: EMA {np.corrcoef(f_ema20[v], fwd[v])[0,1]:+.2f} GB {np.corrcoef(gb_fc[v], fwd[v])[0,1]:+.2f}")
|
||||
print(f" OOS RMSE (lower=better): EMA {np.sqrt(np.mean((f_ema20[v]-fwd[v])**2)):.3f} GB {np.sqrt(np.mean((gb_fc[v]-fwd[v])**2)):.3f}")
|
||||
|
||||
# downstream: vol-target with each forecast (leverage = TV/forecast, capped 1x, floor 0.3)
|
||||
def book(fc):
|
||||
L = np.clip(TV / (fc + 1e-9), 0.3, 1.0)
|
||||
r = np.zeros(T); r[1:] = L[:-1] * combo[1:]
|
||||
rr = r[v[0]:v[-1]]; rr = rr[np.isfinite(rr)]
|
||||
ann = rr.mean() * 252; vol = rr.std() * math.sqrt(252)
|
||||
eq = np.cumprod(1 + rr); dd = float((eq / np.maximum.accumulate(eq) - 1).min())
|
||||
return ann / vol if vol > 0 else float("nan"), dd, vol
|
||||
se, dde, ve = book(f_ema20); sg, ddg, vg = book(gb_fc)
|
||||
print(f" downstream book: EMA-vol Sharpe {se:+.2f} maxDD {100*dde:+.1f}% realizedVol {100*ve:.1f}%")
|
||||
print(f" GB-vol Sharpe {sg:+.2f} maxDD {100*ddg:+.1f}% realizedVol {100*vg:.1f}%")
|
||||
print("\n VERDICT: GB-vol downstream Sharpe/maxDD meaningfully > EMA-vol = an ML nowcaster helps the risk")
|
||||
print(" layer (then CfC/Mamba2 worth it). If ~equal = EMA already captures vol-clustering; keep it simple.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
112
scripts/surfer/news_sentiment_test.py
Normal file
112
scripts/surfer/news_sentiment_test.py
Normal file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""News-sentiment test with a real small LLM (FinBERT) on open news data (FNSPID).
|
||||
|
||||
Stream a chunk of FNSPID headlines -> FinBERT sentiment -> daily sentiment per ticker -> test if
|
||||
it predicts returns. KEY: same-day corr (contemporaneous, not tradeable) vs next-day corr (the only
|
||||
retail-tradeable horizon) + a net-of-cost OOS long/short trade. Honest prior (research): news real
|
||||
but reaction instant + alpha dies at cost + decaying."""
|
||||
import csv
|
||||
import datetime
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
import numpy as np
|
||||
|
||||
CHUNK = 70 * 1024 * 1024 # ~70MB stream (sorted by ticker -> early-alphabet large caps)
|
||||
MAX_SCORE = 18000 # cap FinBERT inferences (GPU time)
|
||||
|
||||
|
||||
def stream_news():
|
||||
req = urllib.request.Request("https://huggingface.co/datasets/Zihan1004/FNSPID/resolve/main/Stock_news/nasdaq_exteral_data.csv",
|
||||
headers={"User-Agent": "curl/8", "Range": f"bytes=0-{CHUNK}"})
|
||||
buf = urllib.request.urlopen(req, timeout=120).read().decode("utf-8", "replace")
|
||||
buf = buf[:buf.rfind("\n")] # drop partial last line
|
||||
rows = []
|
||||
for r in csv.reader(io.StringIO(buf)):
|
||||
if len(r) < 4 or r[1] == "Date":
|
||||
continue
|
||||
try:
|
||||
d = r[1][:10]; sym = r[3].strip(); title = r[2].strip()
|
||||
datetime.date.fromisoformat(d)
|
||||
if sym and title and len(title) > 8:
|
||||
rows.append((d, sym, title))
|
||||
except Exception:
|
||||
continue
|
||||
return rows
|
||||
|
||||
|
||||
def yclose(sym):
|
||||
try:
|
||||
res = json.loads(urllib.request.urlopen(urllib.request.Request(
|
||||
f"https://query1.finance.yahoo.com/v8/finance/chart/{sym}?interval=1d&range=5y",
|
||||
headers={"User-Agent": "Mozilla/5.0"}), timeout=30).read())["chart"]["result"][0]
|
||||
ts = res["timestamp"]; adj = res["indicators"].get("adjclose", [{}])[0].get("adjclose") or res["indicators"]["quote"][0]["close"]
|
||||
return {datetime.datetime.utcfromtimestamp(t).strftime("%Y-%m-%d"): float(c) for t, c in zip(ts, adj) if c is not None}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def main():
|
||||
print("streaming FNSPID news chunk...")
|
||||
rows = stream_news()
|
||||
syms = {}
|
||||
for d, s, t in rows:
|
||||
syms.setdefault(s, 0); syms[s] += 1
|
||||
top = [s for s, n in sorted(syms.items(), key=lambda kv: -kv[1]) if n >= 50][:12]
|
||||
rows = [r for r in rows if r[1] in top][:MAX_SCORE]
|
||||
print(f" {len(rows)} headlines, {len(top)} tickers: {top}")
|
||||
|
||||
from transformers import pipeline
|
||||
import torch
|
||||
clf = pipeline("sentiment-analysis", model="ProsusAI/finbert", device=0 if torch.cuda.is_available() else -1, truncation=True, max_length=64)
|
||||
titles = [r[2][:200] for r in rows]
|
||||
print(" scoring with FinBERT...")
|
||||
out = clf(titles, batch_size=64)
|
||||
score = {"positive": 1.0, "negative": -1.0, "neutral": 0.0}
|
||||
sent = {} # (sym, date) -> [scores]
|
||||
for r, o in zip(rows, out):
|
||||
sent.setdefault((r[1], r[0]), []).append(score[o["label"]] * o["score"])
|
||||
daily = {k: float(np.mean(v)) for k, v in sent.items()}
|
||||
|
||||
prices = {s: yclose(s) for s in top}
|
||||
# build aligned (sentiment[t], sameday ret[t], nextday ret[t+1]) cross-sectionally
|
||||
S, R0, R1 = [], [], []
|
||||
rec = []
|
||||
for (sym, d), sc in daily.items():
|
||||
px = prices.get(sym, {})
|
||||
days = sorted(px)
|
||||
if d not in px:
|
||||
# map to next trading day
|
||||
nd = [x for x in days if x >= d]
|
||||
if not nd:
|
||||
continue
|
||||
d = nd[0]
|
||||
i = days.index(d) if d in days else -1
|
||||
if i < 1 or i + 1 >= len(days):
|
||||
continue
|
||||
r0 = px[days[i]] / px[days[i - 1]] - 1
|
||||
r1 = px[days[i + 1]] / px[days[i]] - 1
|
||||
rec.append((d, sym, sc, r0, r1))
|
||||
rec.sort()
|
||||
S = np.array([x[2] for x in rec]); R0 = np.array([x[3] for x in rec]); R1 = np.array([x[4] for x in rec])
|
||||
n = len(S)
|
||||
print(f"\n aligned sentiment-day observations: {n}")
|
||||
print(f" corr(sentiment, SAME-day return): {np.corrcoef(S, R0)[0,1]:+.3f} (contemporaneous = news real but not tradeable)")
|
||||
print(f" corr(sentiment, NEXT-day return): {np.corrcoef(S, R1)[0,1]:+.3f} (the only retail-tradeable horizon)")
|
||||
# next-day long/short trade by sentiment sign, net 10bp, IS/OOS by time
|
||||
order = np.argsort([x[0] for x in rec]); S, R1 = S[order], R1[order]
|
||||
sig = np.sign(S); pnl = sig * R1 - 0.0010 * (np.abs(sig) > 0)
|
||||
sp = int(0.6 * n)
|
||||
def sh(x):
|
||||
x = x[np.isfinite(x)]; return x.mean() / (x.std() + 1e-9) * math.sqrt(252) if len(x) > 20 and x.std() > 0 else float("nan")
|
||||
print(f" next-day sentiment trade (net 10bp): IS Sharpe {sh(pnl[:sp]):+.2f} OOS Sharpe {sh(pnl[sp:]):+.2f}")
|
||||
print("\n VERDICT: same-day corr >> next-day corr ~0 + OOS trade ~0/neg = news is real but its reaction is")
|
||||
print(" INSTANT (contemporaneous, HFT-captured); no tradeable next-day drift for retail. Matches the research.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
71
scripts/surfer/pead_gate.py
Normal file
71
scripts/surfer/pead_gate.py
Normal file
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PEAD gate: does post-earnings-announcement drift survive in small/neglected names, net of cost?
|
||||
|
||||
The strongest classic anomaly that persists BECAUSE it's too small/illiquid for funds to arb.
|
||||
Proxy (no earnings-date data needed): an "earnings-like surprise" = a large abnormal-volume single
|
||||
-day move (|ret| > K_SIG x trailing-vol AND volume > K_VOL x trailing-avg). PEAD predicts
|
||||
CONTINUATION: enter at the event-day CLOSE (leak-free, after the full move), hold N days, signed by
|
||||
the surprise direction. Measure net-of-cost drift, t-stat, long vs short, IS/OOS, by liquidity band.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from equity_factor_gate import load, roll # noqa: E402
|
||||
|
||||
K_SIG, K_VOL = 2.0, 2.0 # surprise = |ret|>2sigma AND volume>2x average
|
||||
LO, HI = 1e6, 30e6 # small-but-tradeable $/day band (where PEAD survives)
|
||||
HORIZONS = [1, 3, 5, 10, 20]
|
||||
|
||||
|
||||
def tstat(x):
|
||||
x = x[np.isfinite(x)]
|
||||
return float(x.mean() / (x.std() / math.sqrt(len(x)))) if len(x) > 30 and x.std() > 0 else float("nan")
|
||||
|
||||
|
||||
def main():
|
||||
insts, days, close, dvol = load()
|
||||
T, N = close.shape
|
||||
lc = np.log(close)
|
||||
R = np.zeros((T, N)); R[1:] = lc[1:] - lc[:-1]; R = np.where(np.isfinite(R), R, 0.0)
|
||||
vol20 = roll(np.std, R, 20) # trailing std (excludes t -> causal)
|
||||
advol = roll(np.mean, np.nan_to_num(dvol), 20)
|
||||
year = (1970 + days / 365.25).astype(int)
|
||||
rt_cost = np.clip(40.0 / np.sqrt(np.maximum(advol, 1.0) / 1e6), 5.0, 80.0) / 1e4 # round-trip, illiquidity-scaled
|
||||
|
||||
valid = (np.isfinite(close) & (vol20 > 0) & np.isfinite(advol) & (advol > LO) & (advol < HI))
|
||||
surprise = valid & (np.abs(R) > K_SIG * vol20) & (dvol > K_VOL * advol)
|
||||
surprise[:21] = False; surprise[T - max(HORIZONS):] = False
|
||||
sgn = np.sign(R)
|
||||
print(f"PEAD gate (DBEQ, band ${LO/1e6:.0f}-{HI/1e6:.0f}M/day, surprise=|ret|>{K_SIG}sig & vol>{K_VOL}x)")
|
||||
print(f" total surprise events: {int(surprise.sum())} (avg cost {1e4*rt_cost[surprise].mean():.0f}bp round-trip)")
|
||||
|
||||
oos = np.repeat((year >= 2025)[:, None], N, 1)
|
||||
up = surprise & (sgn > 0); dn = surprise & (sgn < 0)
|
||||
print(f"\n{'horizon':>8} {'n':>7} {'gross%':>7} {'net%':>7} {'t(net)':>7} {'fracpos':>8} | {'LONG net%':>9} {'SHORT net%':>10} | {'OOS net%':>9}")
|
||||
for h in HORIZONS:
|
||||
dr = np.full((T, N), np.nan); dr[:T - h] = lc[h:] - lc[:T - h] # dr[t] = lc[t+h]-lc[t]
|
||||
signed = sgn * dr
|
||||
net = signed - rt_cost
|
||||
g = signed[surprise]; nt = net[surprise]
|
||||
ln = net[up]; sh = net[dn]
|
||||
oo = net[surprise & oos]
|
||||
print(f"{h:>8} {int(np.isfinite(nt).sum()):>7} {100*np.nanmean(g):>+7.2f} {100*np.nanmean(nt):>+7.2f} "
|
||||
f"{tstat(nt):>+7.1f} {np.nanmean(nt>0):>8.2f} | {100*np.nanmean(ln):>+9.2f} {100*np.nanmean(sh):>+10.2f} | {100*np.nanmean(oo):>+9.2f}")
|
||||
|
||||
# by surprise strength at the 10d horizon
|
||||
h = 10
|
||||
dr = np.full((T, N), np.nan); dr[:T - h] = lc[h:] - lc[:T - h]
|
||||
net = sgn * dr - rt_cost
|
||||
strong = surprise & (np.abs(R) > 3 * vol20)
|
||||
print(f"\n 10d net by surprise strength: 2-3sig {100*np.nanmean(net[surprise & ~strong]):+.2f}% "
|
||||
f">3sig {100*np.nanmean(net[strong]):+.2f}% (PEAD: bigger surprise -> bigger drift)")
|
||||
print("\nVERDICT: net drift > 0 with t(net) > 3 (deflated) AND positive OOS AND long-side works = real PEAD edge")
|
||||
print("worth a proper book. If net ~0 or t<2 or OOS collapses = eaten by cost / arbed even in small names.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
98
scripts/surfer/pead_real.py
Normal file
98
scripts/surfer/pead_real.py
Normal file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real PEAD test: actual earnings surprises (Nasdaq) x DBEQ prices, leak-free, net of cost.
|
||||
|
||||
For each earnings event (ticker, date, surprise%): direction = sign(surprise). Enter at the CLOSE
|
||||
of the trading day AFTER the announcement (leak-free — announcement + initial reaction already
|
||||
public), hold N days, signed by surprise. Measure net-of-cost drift, t-stat, long vs short, IS/OOS,
|
||||
by surprise magnitude, in the small/neglected liquidity band where PEAD survives.
|
||||
"""
|
||||
import datetime
|
||||
import glob
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from equity_factor_gate import load, roll # noqa: E402
|
||||
|
||||
LO, HI = 1e6, 50e6
|
||||
HORIZONS = [1, 3, 5, 10, 20, 40]
|
||||
EPOCH = datetime.date(1970, 1, 1)
|
||||
|
||||
|
||||
def tstat(x):
|
||||
x = np.asarray(x); x = x[np.isfinite(x)]
|
||||
return float(x.mean() / (x.std() / math.sqrt(len(x)))) if len(x) > 30 and x.std() > 0 else float("nan")
|
||||
|
||||
|
||||
def main():
|
||||
insts, days, close, dvol = load()
|
||||
T, N = close.shape
|
||||
lc = np.log(close)
|
||||
R = np.zeros((T, N)); R[1:] = lc[1:] - lc[:-1]
|
||||
advol = roll(np.mean, np.nan_to_num(dvol), 20)
|
||||
rt_cost = np.clip(40.0 / np.sqrt(np.maximum(advol, 1.0) / 1e6), 5.0, 80.0) / 1e4
|
||||
col = {int(i): k for k, i in enumerate(insts)}
|
||||
sym = json.load(open("data/surfer/dbeq_symbology.json"))
|
||||
mp = sym.get("result", sym)
|
||||
tick2col = {}
|
||||
for t, ranges in mp.items():
|
||||
if ranges:
|
||||
iid = int(ranges[0]["s"])
|
||||
if iid in col:
|
||||
tick2col[t] = col[iid]
|
||||
|
||||
# load earnings events
|
||||
files = glob.glob("data/surfer/earnings/*.json")
|
||||
ev_day, ev_col, ev_sgn, ev_mag = [], [], [], []
|
||||
matched = 0
|
||||
for f in files:
|
||||
d = datetime.date.fromisoformat(os.path.basename(f)[:-5])
|
||||
epoch = (d - EPOCH).days
|
||||
idx = int(np.searchsorted(days, epoch, "left"))
|
||||
t_enter = idx + 1 # close of day after announcement (leak-free)
|
||||
if t_enter >= T - max(HORIZONS):
|
||||
continue
|
||||
for tk, surp in json.load(open(f)):
|
||||
c = tick2col.get(tk)
|
||||
if c is None or surp in (None, "", "N/A"):
|
||||
continue
|
||||
try:
|
||||
s = float(str(surp).replace("%", "").replace(",", ""))
|
||||
except ValueError:
|
||||
continue
|
||||
if not np.isfinite(close[t_enter, c]) or not (LO < advol[t_enter, c] < HI):
|
||||
continue
|
||||
ev_day.append(t_enter); ev_col.append(c); ev_sgn.append(np.sign(s)); ev_mag.append(abs(s)); matched += 1
|
||||
ev_day = np.array(ev_day); ev_col = np.array(ev_col); ev_sgn = np.array(ev_sgn); ev_mag = np.array(ev_mag)
|
||||
yr = (1970 + days[ev_day] / 365.25).astype(int)
|
||||
print(f"PEAD (real surprises) — {len(files)} earnings days loaded, {matched} events matched to DBEQ "
|
||||
f"(band ${LO/1e6:.0f}-{HI/1e6:.0f}M, avg cost {1e4*rt_cost[ev_day, ev_col].mean():.0f}bp)")
|
||||
if matched < 200:
|
||||
print(" (few events — let the fetch finish, then re-run)"); return
|
||||
|
||||
oosm = yr >= 2025
|
||||
cost = rt_cost[ev_day, ev_col]
|
||||
print(f"\n{'horizon':>8} {'gross%':>7} {'net%':>7} {'t(net)':>7} {'fracpos':>8} | {'LONG net%':>9} {'SHORT net%':>10} | {'OOS net%':>9}")
|
||||
for h in HORIZONS:
|
||||
fut = lc[ev_day + h, ev_col] - lc[ev_day, ev_col]
|
||||
signed = ev_sgn * fut
|
||||
net = signed - cost
|
||||
up = ev_sgn > 0; dn = ev_sgn < 0
|
||||
print(f"{h:>8} {100*np.nanmean(signed):>+7.2f} {100*np.nanmean(net):>+7.2f} {tstat(net):>+7.1f} "
|
||||
f"{np.nanmean(net > 0):>8.2f} | {100*np.nanmean(net[up]):>+9.2f} {100*np.nanmean(net[dn]):>+10.2f} | {100*np.nanmean(net[oosm]):>+9.2f}")
|
||||
|
||||
# by surprise magnitude at 20d (PEAD: bigger surprise -> bigger drift)
|
||||
h = 20
|
||||
fut = lc[ev_day + h, ev_col] - lc[ev_day, ev_col]; net = ev_sgn * fut - cost
|
||||
q = np.nanpercentile(ev_mag, [33, 66])
|
||||
for lab, m in [("small surprise", ev_mag <= q[0]), ("mid", (ev_mag > q[0]) & (ev_mag <= q[1])), ("large surprise", ev_mag > q[1])]:
|
||||
print(f" 20d net, {lab:>14}: {100*np.nanmean(net[m]):+.2f}% (n={int(m.sum())})")
|
||||
print("\nVERDICT: net>0, t(net)>3, positive OOS, long-side works, AND bigger-surprise->bigger-drift = real PEAD.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
126
scripts/surfer/phase1_signals.py
Normal file
126
scripts/surfer/phase1_signals.py
Normal file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Phase 1 — hunt & validate NEW crypto signals; build the linear-blend baseline RL must beat.
|
||||
|
||||
Each candidate is theoretically distinct from momentum (different driver), run through the same
|
||||
gauntlet that killed carry/low-vol: PIT universe (top-50 by dollar-vol, dead coins in), death-excl,
|
||||
~10bp cost, full/IS/OOS Sharpe, CPCV-median, coin-bootstrap (frac>0), and CORRELATION to momentum.
|
||||
Survivors (CPCVmed>0 & OOS>0 & bootstrap frac>0>=0.8) -> linear blend = the baseline to beat.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import pit_sweep # noqa: E402
|
||||
from signal_sweep import xs_weights, pnl_w, validate, sharpe_t # noqa: E402
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
TOPK = 50
|
||||
|
||||
|
||||
def roll(fn, X, L):
|
||||
out = np.full_like(X, np.nan)
|
||||
for t in range(L, len(X)):
|
||||
out[t] = fn(X[t - L:t], axis=0)
|
||||
return out
|
||||
|
||||
|
||||
def trailing(lc, L):
|
||||
out = np.full_like(lc, np.nan); out[L:] = lc[L:] - lc[:-L]; return out
|
||||
|
||||
|
||||
def main():
|
||||
syms, days, close, qv, fund = pit_sweep.load()
|
||||
T, N = close.shape
|
||||
lc = np.log(close)
|
||||
R = np.zeros((T, N)); R[1:] = lc[1:] - lc[:-1]; R = np.where(np.isfinite(R), R, 0.0)
|
||||
fund = np.where(np.isfinite(fund), fund, 0.0)
|
||||
qv = np.nan_to_num(qv)
|
||||
dv30 = roll(np.mean, qv, 30)
|
||||
tradeable = np.ones((T, N), bool)
|
||||
for j in range(N):
|
||||
idx = np.where(np.isfinite(close[:, j]))[0]
|
||||
if len(idx):
|
||||
tradeable[max(0, idx[-1] - 4):idx[-1] + 1, j] = False
|
||||
Reff = np.where(tradeable, R - fund, 0.0)
|
||||
univ = np.zeros((T, N), bool)
|
||||
for t in range(T):
|
||||
elig = np.where((dv30[t] > 0) & np.isfinite(close[t]))[0]
|
||||
if len(elig):
|
||||
univ[t, elig[np.argsort(-dv30[t, elig])[:TOPK]]] = True
|
||||
year = (1970 + days / 365.25).astype(int)
|
||||
|
||||
# rolling BTC-beta -> residual returns (for residual momentum + IVOL)
|
||||
bj = syms.index("BTCUSDT")
|
||||
rb = R[:, bj]
|
||||
beta = np.zeros((T, N)); W = 60
|
||||
for t in range(W, T):
|
||||
rw = rb[t - W:t]; vb = rw.var() + 1e-12
|
||||
cov = (R[t - W:t] * rw[:, None]).mean(0) - R[t - W:t].mean(0) * rw.mean()
|
||||
beta[t] = cov / vb
|
||||
resid = R - beta * rb[:, None]
|
||||
resid_cum = np.cumsum(np.where(np.isfinite(resid), resid, 0.0), axis=0)
|
||||
|
||||
def trail_arr(A, L):
|
||||
out = np.full_like(A, np.nan); out[L:] = A[L:] - A[:-L]; return out
|
||||
|
||||
sigs = {
|
||||
"mom_30 (ref)": trailing(lc, 30),
|
||||
"resid_mom_30": trail_arr(resid_cum, 30),
|
||||
"MAX_lottery": -roll(np.max, R, 20), # short extreme-spike (lottery) coins
|
||||
"ivol_low": -roll(np.std, resid, 30), # low idiosyncratic vol
|
||||
"amihud_illiq": roll(np.mean, np.abs(R) / np.maximum(qv, 1.0), 30), # illiquidity premium
|
||||
"accel": trailing(lc, 10) - trailing(lc, 30), # momentum acceleration
|
||||
}
|
||||
|
||||
def book(sig):
|
||||
s = sig.copy(); s[~univ] = np.nan
|
||||
return pnl_w(xs_weights(s), Reff, cost_bp=10)
|
||||
|
||||
mom_pnl = book(sigs["mom_30 (ref)"])
|
||||
rng = np.random.default_rng(3)
|
||||
print(f"\n===== PHASE 1 — candidate crypto signals (PIT top{TOPK}, death-excl, 10bp) =====")
|
||||
print(f"{'signal':>16} {'full':>6} {'IS':>6} {'OOS':>6} {'CPCVmed':>8} {'boot>0':>7} {'corr_mom':>8}")
|
||||
pnls = {}
|
||||
for nm, sg in sigs.items():
|
||||
p = book(sg); pnls[nm] = p
|
||||
v = validate(p, days, 40)
|
||||
# coin-bootstrap
|
||||
sh = []
|
||||
for _ in range(80):
|
||||
cols = rng.choice(N, size=max(N // 2, 10), replace=False)
|
||||
sub = np.full((T, N), np.nan); sub[:, cols] = sg[:, cols]
|
||||
s = sharpe_t(torch.tensor(book(sub), device=DEV, dtype=torch.float64))
|
||||
if not math.isnan(s):
|
||||
sh.append(s)
|
||||
bootp = float(np.mean(np.array(sh) > 0)) if sh else float("nan")
|
||||
a, b = mom_pnl, p; m = np.isfinite(a) & np.isfinite(b)
|
||||
corr = float(np.corrcoef(a[m], b[m])[0, 1])
|
||||
print(f"{nm:>16} {v['full']:>+6.2f} {v['is_']:>+6.2f} {v['oos']:>+6.2f} {v['med']:>+8.2f} {bootp:>7.2f} {corr:>+8.2f}")
|
||||
v["_boot"] = bootp
|
||||
sigs[nm] = (sg, v)
|
||||
|
||||
# survivors -> linear blend baseline (sum of cross-sectional z-scores)
|
||||
def zc(x):
|
||||
mu = np.nanmean(x, axis=1, keepdims=True); sd = np.nanstd(x, axis=1, keepdims=True)
|
||||
return np.nan_to_num((x - mu) / np.where(sd > 0, sd, 1))
|
||||
surv = [nm for nm, (sg, v) in sigs.items() if v["med"] > 0 and v["oos"] > 0 and v.get("_boot", 0) >= 0.8]
|
||||
print(f"\nSURVIVORS (CPCVmed>0 & OOS>0 & boot>0>=0.8): {surv}")
|
||||
if len(surv) >= 2:
|
||||
blend = sum(zc(sigs[nm][0]) for nm in surv)
|
||||
bp = book(blend)
|
||||
vb = validate(bp, days, 40)
|
||||
T_ = lambda x: torch.tensor(x, device=DEV, dtype=torch.float64)
|
||||
py = " ".join(f"{y}:{sharpe_t(T_(bp[year[1:]==y])):+.2f}" for y in range(2020, 2027) if (year[1:] == y).sum() > 30)
|
||||
print(f"LINEAR-BLEND BASELINE ({len(surv)} signals): full {vb['full']:+.2f} OOS {vb['oos']:+.2f} CPCVmed {vb['med']:+.2f}")
|
||||
print(" per-year: " + py)
|
||||
print(" -> this is the bar an RL combiner must BEAT out-of-sample (deflated) to justify itself.")
|
||||
else:
|
||||
print("Too few survivors for a blend — RL has no signal set to combine; momentum stays solo.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
87
scripts/surfer/pit_realism.py
Normal file
87
scripts/surfer/pit_realism.py
Normal file
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execution-realism stress for point-in-time crypto momentum — is the magnitude believable?
|
||||
|
||||
Layers on the realistic frictions that could inflate the paper Sharpe:
|
||||
- cost 10→20→30 bp on turnover (smaller coins cost more)
|
||||
- WINSORIZE per-coin daily returns to ±cap (you cannot fill a short into an 80%/day collapse)
|
||||
- EXCLUDE each coin's final K days (delisting death-spiral: untradeable → zero PnL there)
|
||||
- liquidity FLOOR: only trade coins with trailing dollar-vol > floor
|
||||
If mom_20/30 stays positive EVERY year under the combined stress, the edge magnitude is real.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import pit_sweep as ps # noqa: E402
|
||||
from signal_sweep import xs_weights, pnl_w, validate, sharpe_t # noqa: E402
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
|
||||
|
||||
def trailing(lc, L):
|
||||
out = np.full_like(lc, np.nan); out[L:] = lc[L:] - lc[:-L]; return out
|
||||
|
||||
|
||||
def main():
|
||||
syms, days, close, qv, fund = ps.load()
|
||||
T, N = close.shape
|
||||
lc = np.log(close)
|
||||
R = np.zeros((T, N)); R[1:] = lc[1:] - lc[:-1]; R = np.where(np.isfinite(R), R, 0.0)
|
||||
fund = np.where(np.isfinite(fund), fund, 0.0)
|
||||
qv = np.nan_to_num(qv)
|
||||
dv30 = np.zeros((T, N))
|
||||
for t in range(30, T):
|
||||
dv30[t] = qv[t - 30:t].mean(axis=0)
|
||||
year = (1970 + days / 365.25).astype(int)
|
||||
|
||||
# death-spiral mask: each coin's final 5 valid days = untradeable
|
||||
tradeable = np.ones((T, N), bool)
|
||||
for j in range(N):
|
||||
idx = np.where(np.isfinite(close[:, j]))[0]
|
||||
if len(idx):
|
||||
tradeable[max(0, idx[-1] - 4):idx[-1] + 1, j] = False
|
||||
|
||||
def variant(cost_bp, winsor, excl_death, dv_floor, L=20, TOPK=50):
|
||||
Reff = (R - fund)
|
||||
if winsor is not None:
|
||||
Reff = np.clip(Reff, -winsor, winsor)
|
||||
if excl_death:
|
||||
Reff = np.where(tradeable, Reff, 0.0)
|
||||
univ = np.zeros((T, N), bool)
|
||||
for t in range(T):
|
||||
elig = np.where((dv30[t] > dv_floor) & np.isfinite(close[t]))[0]
|
||||
if len(elig) == 0:
|
||||
continue
|
||||
k = min(TOPK, len(elig))
|
||||
univ[t, elig[np.argsort(-dv30[t, elig])[:k]]] = True
|
||||
sig = trailing(lc, L).copy(); sig[~univ] = np.nan
|
||||
pnl = pnl_w(xs_weights(sig), Reff, cost_bp=cost_bp)
|
||||
v = validate(pnl, days, 6)
|
||||
py = [sharpe_t(torch.tensor(pnl[year[1:] == y], device=DEV, dtype=torch.float64))
|
||||
if (year[1:] == y).sum() > 30 else float("nan") for y in range(2020, 2027)]
|
||||
return v, py
|
||||
|
||||
print(f"\n===== EXECUTION-REALISM STRESS — PIT momentum (mom_20, TOPK=50) — {N} coins =====")
|
||||
print(f"{'variant':>34} {'full':>6} {'IS':>6} {'OOS':>6} {'CPCVmed':>8} | per-year 2020..2026")
|
||||
cfgs = [
|
||||
("baseline 10bp", dict(cost_bp=10, winsor=None, excl_death=False, dv_floor=0)),
|
||||
("20bp cost", dict(cost_bp=20, winsor=None, excl_death=False, dv_floor=0)),
|
||||
("winsor ±20%", dict(cost_bp=10, winsor=0.20, excl_death=False, dv_floor=0)),
|
||||
("excl death-spiral (last 5d)", dict(cost_bp=10, winsor=None, excl_death=True, dv_floor=0)),
|
||||
("dv-floor $5M/day", dict(cost_bp=10, winsor=None, excl_death=False, dv_floor=5e6)),
|
||||
("COMBINED 20bp+wins20+death+$5M", dict(cost_bp=20, winsor=0.20, excl_death=True, dv_floor=5e6)),
|
||||
("HARSH 30bp+wins10+death+$10M", dict(cost_bp=30, winsor=0.10, excl_death=True, dv_floor=1e7)),
|
||||
]
|
||||
for nm, cfg in cfgs:
|
||||
v, py = variant(**cfg)
|
||||
pys = " ".join(f"{p:>+5.2f}" if not math.isnan(p) else " n/a" for p in py)
|
||||
print(f"{nm:>34} {v['full']:>+6.2f} {v['is_']:>+6.2f} {v['oos']:>+6.2f} {v['med']:>+8.2f} | {pys}")
|
||||
print("\nVERDICT: positive every year under COMBINED/HARSH => believable, sizable edge (not paper).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
89
scripts/surfer/pit_sweep.py
Normal file
89
scripts/surfer/pit_sweep.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Point-in-time survivorship test for crypto cross-sectional momentum.
|
||||
|
||||
Universe is rebuilt EACH DAY: among coins with active trailing-30d dollar-volume (>0),
|
||||
take the top-K by that volume. Dead coins (LUNA, SRM, ...) are IN the cross-section while
|
||||
they trade and DROP OUT (after carrying their crash) when volume dies. No lookahead:
|
||||
volume & momentum use only past data; weights apply to next-day return. If momentum's
|
||||
edge survives here — especially 2022 with LUNA included — it is real beyond the bootstrap proxy.
|
||||
"""
|
||||
import glob
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from signal_sweep import xs_weights, pnl_w, validate, sharpe_t # noqa: E402
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
|
||||
|
||||
def load():
|
||||
syms, data = [], {}
|
||||
_base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # cwd-independent
|
||||
for p in sorted(glob.glob(os.path.join(_base, "data/surfer/crypto_pit/*.npz"))):
|
||||
d = np.load(p); s = p.split("/")[-1][:-4]
|
||||
data[s] = (d["day"].astype(np.int64), d["close"].astype(float), d["qvol"].astype(float), d["funding"].astype(float))
|
||||
syms.append(s)
|
||||
syms = sorted(syms)
|
||||
days = np.array(sorted(set().union(*[set(data[s][0].tolist()) for s in syms])))
|
||||
di = {int(v): i for i, v in enumerate(days.tolist())}
|
||||
T, N = len(days), len(syms)
|
||||
close = np.full((T, N), np.nan); qv = np.full((T, N), np.nan); fund = np.full((T, N), np.nan)
|
||||
for j, s in enumerate(syms):
|
||||
dd, cc, vv, ff = data[s]
|
||||
for k in range(len(dd)):
|
||||
r = di[int(dd[k])]; close[r, j] = cc[k]; qv[r, j] = vv[k]; fund[r, j] = ff[k]
|
||||
return syms, days, close, qv, fund
|
||||
|
||||
|
||||
def trailing(lc, L):
|
||||
out = np.full_like(lc, np.nan); out[L:] = lc[L:] - lc[:-L]; return out
|
||||
|
||||
|
||||
def main():
|
||||
syms, days, close, qv, fund = load()
|
||||
T, N = close.shape
|
||||
lc = np.log(close)
|
||||
R = np.zeros((T, N)); R[1:] = lc[1:] - lc[:-1]; R = np.where(np.isfinite(R), R, 0.0)
|
||||
Reff = R - np.where(np.isfinite(fund), fund, 0.0)
|
||||
qv = np.nan_to_num(qv)
|
||||
dv30 = np.full((T, N), 0.0) # trailing 30d mean dollar-volume
|
||||
for t in range(30, T):
|
||||
dv30[t] = qv[t - 30:t].mean(axis=0)
|
||||
dead = sum(1 for j in range(N) if qv[-30:, j].mean() == 0)
|
||||
year = (1970 + days / 365.25).astype(int)
|
||||
|
||||
print(f"\n===== POINT-IN-TIME MOMENTUM — {N} coins ({dead} dead/delisted), {T}d =====")
|
||||
for TOPK in [30, 50]:
|
||||
# daily universe = top-K by trailing dollar-vol among actively-trading coins
|
||||
univ = np.zeros((T, N), bool)
|
||||
for t in range(T):
|
||||
elig = np.where((dv30[t] > 0) & np.isfinite(close[t]))[0]
|
||||
if len(elig) == 0:
|
||||
continue
|
||||
k = min(TOPK, len(elig))
|
||||
top = elig[np.argsort(-dv30[t, elig])[:k]]
|
||||
univ[t, top] = True
|
||||
print(f"\n--- TOPK={TOPK} (avg coins/day in-universe = {univ.sum(1).mean():.0f}) ---")
|
||||
print(f"{'lookback':>9} {'full':>6} {'IS':>6} {'OOS':>6} {'CPCVmed':>8} {'DSR':>5} | per-year (2020..2026)")
|
||||
for L in [10, 20, 30]:
|
||||
sig = trailing(lc, L).copy()
|
||||
sig[~univ] = np.nan # restrict signal to PIT universe
|
||||
w = xs_weights(sig)
|
||||
pnl = pnl_w(w, Reff, cost_bp=10)
|
||||
v = validate(pnl, days, 6)
|
||||
py = []
|
||||
for y in range(2020, 2027):
|
||||
m = year[1:] == y
|
||||
py.append(sharpe_t(torch.tensor(pnl[m], device=DEV, dtype=torch.float64)) if m.sum() > 30 else float("nan"))
|
||||
pys = " ".join(f"{p:>+5.2f}" if not math.isnan(p) else " n/a" for p in py)
|
||||
print(f"{L:>9} {v['full']:>+6.2f} {v['is_']:>+6.2f} {v['oos']:>+6.2f} {v['med']:>+8.2f} {v['dsr']:>5.2f} | {pys}")
|
||||
print("\nVERDICT: if mom_20-30 stays positive (esp. 2022 with LUNA in-universe) => survivorship-robust REAL edge.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
90
scripts/surfer/portfolio_harvest.py
Normal file
90
scripts/surfer/portfolio_harvest.py
Normal file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The strategy class we never tested: HARVEST structural premia with the risk system, not predict.
|
||||
|
||||
Diversified multi-asset futures (equity/rates/metals/energy/ags/FX). Build a ladder:
|
||||
equal-weight -> inverse-vol (risk-parity) -> + vol-target -> + trend-overlay (long-flat by 252d trend,
|
||||
the crisis-alpha de-risk). Measure RISK-ADJUSTED (Sharpe, ann-vol, max-DD, Calmar, per-year) vs
|
||||
benchmarks (equal-weight, equity-only, 60/40). No prediction/alpha needed — just disciplined
|
||||
harvesting + risk control (what the engine is actually good at). Roll-zeroed returns (understates
|
||||
carry; fine for comparing the risk-management strategies).
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from signal_sweep import load_panel, build_returns # noqa: E402
|
||||
|
||||
TARGET_VOL = 0.10 # 10% annualized portfolio vol
|
||||
|
||||
|
||||
def metrics(r):
|
||||
r = r[np.isfinite(r)]
|
||||
if len(r) < 50 or r.std() == 0:
|
||||
return dict(sr=float("nan"), ann=float("nan"), vol=float("nan"), dd=float("nan"), calmar=float("nan"))
|
||||
ann = r.mean() * 252; vol = r.std() * math.sqrt(252); sr = ann / vol
|
||||
eq = np.cumprod(1 + r); dd = float((eq / np.maximum.accumulate(eq) - 1).min())
|
||||
return dict(sr=sr, ann=ann, vol=vol, dd=dd, calmar=ann / (abs(dd) + 1e-9))
|
||||
|
||||
|
||||
def main():
|
||||
roots, days, close, op, inst, weekday = load_panel()
|
||||
lc, R, ov, intr = build_returns(close, op, inst)
|
||||
T, N = close.shape
|
||||
year = (1970 + days / 365.25).astype(int)
|
||||
vol63 = np.full_like(lc, np.nan)
|
||||
for t in range(63, T):
|
||||
vol63[t] = np.nanstd(R[t - 63:t], axis=0)
|
||||
iv = 1.0 / np.where(vol63 > 0, vol63, np.nan)
|
||||
avail = np.isfinite(close) & np.isfinite(vol63) & (vol63 > 0)
|
||||
|
||||
def port(weights):
|
||||
w = np.nan_to_num(weights)
|
||||
w = np.where(avail, w, 0.0)
|
||||
g = np.sum(np.abs(w), axis=1, keepdims=True); g[g == 0] = 1
|
||||
w = w / g # unit gross (fully invested)
|
||||
return np.sum(w[:-1] * R[1:], axis=1)
|
||||
|
||||
def voltarget(r):
|
||||
out = np.zeros_like(r)
|
||||
for t in range(63, len(r)):
|
||||
rv = np.std(r[t - 63:t]) * math.sqrt(252)
|
||||
out[t] = r[t] * min(3.0, TARGET_VOL / (rv + 1e-9)) # scale to target vol, cap 3x leverage
|
||||
return out
|
||||
|
||||
# weight schemes
|
||||
eqw = np.where(avail, 1.0, 0.0)
|
||||
rp = np.where(avail, iv, 0.0) # inverse-vol risk parity
|
||||
trend = (np.full_like(lc, np.nan)); trend[252:] = lc[252:] - lc[:-252]
|
||||
rp_trend = np.where(avail & (trend > 0), iv, 0.0) # trend-overlay: hold only uptrending assets
|
||||
|
||||
books = {
|
||||
"equal-weight": port(eqw),
|
||||
"risk-parity (inv-vol)": port(rp),
|
||||
"risk-parity + vol-target": voltarget(port(rp)),
|
||||
"RP + trend + vol-target": voltarget(port(rp_trend)),
|
||||
}
|
||||
# benchmarks
|
||||
es = roots.index("ES") if "ES" in roots else 0
|
||||
bench = {"equity-only (ES)": R[1:, es]}
|
||||
if "ZN" in roots:
|
||||
zn = roots.index("ZN")
|
||||
w6040 = np.zeros((T, N)); w6040[:, es] = 0.6; w6040[:, zn] = 0.4
|
||||
bench["60/40 (ES/ZN)"] = port(w6040)
|
||||
|
||||
print(f"\n===== PREMIUM HARVEST (diversified futures, {N} assets, {T} days, target {int(TARGET_VOL*100)}% vol) =====")
|
||||
print(f"{'strategy':>26} {'Sharpe':>7} {'ann%':>6} {'vol%':>6} {'maxDD%':>7} {'Calmar':>7}")
|
||||
for nm, r in {**bench, **books}.items():
|
||||
m = metrics(r)
|
||||
print(f"{nm:>26} {m['sr']:>+7.2f} {100*m['ann']:>+6.1f} {100*m['vol']:>5.1f} {100*m['dd']:>+7.1f} {m['calmar']:>7.2f}")
|
||||
print("\nper-year Sharpe (RP + trend + vol-target):")
|
||||
rtv = books["RP + trend + vol-target"]
|
||||
print(" " + " ".join(f"{y}:{metrics(rtv[year[1:]==y])['sr']:+.1f}" for y in range(2010, 2027) if (year[1:] == y).sum() > 100))
|
||||
print("\nVERDICT: if risk-managed harvest beats benchmarks on Sharpe/Calmar (esp lower maxDD) = the engine's")
|
||||
print("real job (risk management of real premia) delivers, no alpha needed. Modest but robust = the honest win.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
98
scripts/surfer/portfolio_harvest2.py
Normal file
98
scripts/surfer/portfolio_harvest2.py
Normal file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Clean premium-harvest: risk-parity across ~5 broad ASSET CLASSES (not 39 instruments).
|
||||
|
||||
Equity (ES), bonds (ZN), gold (GC), energy (CL), crypto (BTC) — genuinely uncorrelated premia.
|
||||
Risk-parity (inv-vol) + vol-target + optional trend-overlay, vs 60/40 and buy-and-hold equity.
|
||||
Runs 4-asset (2010-2026, longer) and 5-asset (+BTC, 2019-2026). The fair test of whether the
|
||||
risk system adds value over plain 60/40 when applied to the RIGHT universe.
|
||||
Futures = roll-zeroed price returns (understates commodity carry); BTC = clean close-close.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from signal_sweep import load_panel, build_returns # noqa: E402
|
||||
import pit_sweep # noqa: E402
|
||||
|
||||
TVOL = 0.10
|
||||
|
||||
|
||||
def metrics(r):
|
||||
r = np.asarray(r); r = r[np.isfinite(r)]
|
||||
if len(r) < 50 or r.std() == 0:
|
||||
return dict(sr=float("nan"), ann=float("nan"), dd=float("nan"), cal=float("nan"))
|
||||
ann = r.mean() * 252; vol = r.std() * math.sqrt(252)
|
||||
eq = np.cumprod(1 + r); dd = float((eq / np.maximum.accumulate(eq) - 1).min())
|
||||
return dict(sr=ann / vol, ann=ann, dd=dd, cal=ann / (abs(dd) + 1e-9))
|
||||
|
||||
|
||||
def build():
|
||||
roots, fdays, close, op, inst = load_panel()[:5]
|
||||
lc, R, ov, intr = build_returns(close, op, inst)
|
||||
fcol = {r: roots.index(r) for r in ["ES", "ZN", "GC", "CL"] if r in roots}
|
||||
fut = {r: {int(fdays[t]): R[t, c] for t in range(len(fdays))} for r, c in fcol.items()}
|
||||
syms, cdays, cc, cqv, cf = pit_sweep.load()
|
||||
j = syms.index("BTCUSDT"); lcb = np.log(cc[:, j])
|
||||
btc = {int(cdays[t]): (lcb[t] - lcb[t - 1]) for t in range(1, len(cdays)) if np.isfinite(lcb[t]) and np.isfinite(lcb[t - 1])}
|
||||
return fut, btc
|
||||
|
||||
|
||||
def run(assets, rets, label):
|
||||
days = np.array(sorted(set.intersection(*[set(rets[a]) for a in assets])))
|
||||
M = np.array([[rets[a][int(d)] for a in assets] for d in days]) # [T, K]
|
||||
M = np.where(np.isfinite(M), M, 0.0)
|
||||
T, K = M.shape
|
||||
year = (1970 + days / 365.25).astype(int)
|
||||
vol = np.full((T, K), np.nan)
|
||||
for t in range(63, T):
|
||||
vol[t] = M[t - 63:t].std(0)
|
||||
iv = 1.0 / np.where(vol > 0, vol, np.nan)
|
||||
trend = np.full((T, K), np.nan)
|
||||
L = 126
|
||||
for t in range(L, T):
|
||||
trend[t] = M[t - L:t].sum(0)
|
||||
|
||||
def book(w):
|
||||
w = np.nan_to_num(w); g = np.abs(w).sum(1, keepdims=True); g[g == 0] = 1
|
||||
w = w / g
|
||||
return np.sum(w[:-1] * M[1:], axis=1)
|
||||
|
||||
def vt(r):
|
||||
out = np.zeros_like(r)
|
||||
for t in range(63, len(r)):
|
||||
rv = np.std(r[t - 63:t]) * math.sqrt(252)
|
||||
out[t] = r[t] * min(3.0, TVOL / (rv + 1e-9))
|
||||
return out
|
||||
|
||||
rp = book(iv)
|
||||
rp_t = book(np.where(trend > 0, iv, 0.0))
|
||||
es_i = assets.index("ES") if "ES" in assets else 0
|
||||
bench_eq = M[1:, es_i]
|
||||
w60 = np.zeros((T, K)); w60[:, es_i] = 0.6
|
||||
if "ZN" in assets:
|
||||
w60[:, assets.index("ZN")] = 0.4
|
||||
print(f"\n===== {label}: {assets} ({T} days) =====")
|
||||
print(f"{'strategy':>24} {'Sharpe':>7} {'ann%':>6} {'maxDD%':>7} {'Calmar':>7}")
|
||||
for nm, r in [("equity-only (ES)", bench_eq), ("60/40", book(w60)),
|
||||
("risk-parity", rp), ("RP + vol-target", vt(rp)),
|
||||
("RP + trend + vol-target", vt(rp_t))]:
|
||||
m = metrics(r)
|
||||
print(f"{nm:>24} {m['sr']:>+7.2f} {100*m['ann']:>+6.1f} {100*m['dd']:>+7.1f} {m['cal']:>7.2f}")
|
||||
best = vt(rp)
|
||||
print(" per-year Sharpe (RP+vol-target): " + " ".join(
|
||||
f"{y}:{metrics(best[year[1:]==y])['sr']:+.1f}" for y in sorted(set(year)) if (year[1:] == y).sum() > 100))
|
||||
|
||||
|
||||
def main():
|
||||
fut, btc = build()
|
||||
run(["ES", "ZN", "GC", "CL"], {**fut}, "4-asset (2010-2026)")
|
||||
run(["ES", "ZN", "GC", "CL", "BTC"], {**fut, "BTC": btc}, "5-asset +crypto (2019-2026)")
|
||||
print("\nVERDICT: if RP/vol-target beats 60/40 on Sharpe AND maxDD = risk system adds real value on the")
|
||||
print("right universe (the engine's true job). If 60/40 still wins, the deployable answer is just simple harvest.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
93
scripts/surfer/portfolio_harvest3.py
Normal file
93
scripts/surfer/portfolio_harvest3.py
Normal file
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The one evidence-backed non-crypto lever: add a diversified TREND sleeve to 60/40.
|
||||
|
||||
Research verdict: trend/managed-futures is the only premium genuinely uncorrelated-to-equity AND
|
||||
convex in crises (made money in 2022 when stocks+bonds both fell). We tested trend standalone
|
||||
(marginal) and as a filter (hurt) — never as a DIVERSIFYING SLEEVE on 60/40, the specific thing
|
||||
the evidence supports. Build a long/SHORT diversified time-series-momentum book (vol-targeted),
|
||||
blend with 60/40 at various weights, measure Sharpe + maxDD + crisis-year behavior. Honest
|
||||
question: does 60/40 + trend beat plain 60/40 on risk-adjusted terms (~0.7 -> ~0.8-0.9)?
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from signal_sweep import load_panel, build_returns # noqa: E402
|
||||
|
||||
TVOL = 0.10
|
||||
|
||||
|
||||
def metrics(r):
|
||||
r = np.asarray(r); r = r[np.isfinite(r)]
|
||||
if len(r) < 50 or r.std() == 0:
|
||||
return dict(sr=float("nan"), ann=float("nan"), dd=float("nan"), cal=float("nan"))
|
||||
ann = r.mean() * 252; vol = r.std() * math.sqrt(252)
|
||||
eq = np.cumprod(1 + r); dd = float((eq / np.maximum.accumulate(eq) - 1).min())
|
||||
return dict(sr=ann / vol, ann=ann, dd=dd, cal=ann / (abs(dd) + 1e-9))
|
||||
|
||||
|
||||
def voltarget(r, win=63):
|
||||
out = np.zeros_like(r)
|
||||
for t in range(win, len(r)):
|
||||
rv = np.std(r[t - win:t]) * math.sqrt(252)
|
||||
out[t] = r[t] * min(3.0, TVOL / (rv + 1e-9))
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
roots, days, close, op, inst = load_panel()[:5]
|
||||
lc, R, ov, intr = build_returns(close, op, inst)
|
||||
T, N = close.shape
|
||||
year = (1970 + days / 365.25).astype(int)
|
||||
vol63 = np.full_like(lc, np.nan)
|
||||
for t in range(63, T):
|
||||
vol63[t] = np.nanstd(R[t - 63:t], axis=0)
|
||||
iv = 1.0 / np.where(vol63 > 0, vol63, np.nan)
|
||||
avail = np.isfinite(close) & np.isfinite(vol63) & (vol63 > 0)
|
||||
|
||||
es, zn = roots.index("ES"), roots.index("ZN")
|
||||
r6040 = 0.6 * R[1:, es] + 0.4 * R[1:, zn] # plain 60/40 (daily returns)
|
||||
|
||||
# diversified long/SHORT time-series-momentum (CTA) sleeve, vol-targeted
|
||||
tsig = np.full_like(lc, np.nan)
|
||||
tsig[252:] = np.sign(lc[252:] - lc[:-252]) # long if 12mo up, short if down
|
||||
w = np.where(avail & np.isfinite(tsig), tsig * iv, 0.0)
|
||||
g = np.sum(np.abs(w), axis=1, keepdims=True); g[g == 0] = 1
|
||||
w = w / g
|
||||
rtrend_raw = np.sum(w[:-1] * R[1:], axis=1)
|
||||
rtrend = voltarget(rtrend_raw) # the managed-futures sleeve
|
||||
|
||||
# normalize both legs to ~TVOL so blend weights are meaningful, then blend
|
||||
def norm(r):
|
||||
return voltarget(r)
|
||||
base = norm(r6040)
|
||||
sleeve = rtrend
|
||||
yr = year[1:]
|
||||
|
||||
print(f"\n===== 60/40 + TREND SLEEVE ({N}-asset long/short CTA, vol-targeted) =====")
|
||||
print(f"{'blend':>22} {'Sharpe':>7} {'ann%':>6} {'maxDD%':>7} {'Calmar':>7}")
|
||||
blends = [("100% 60/40", 1.0, 0.0), ("80/20 +trend", 0.8, 0.2), ("70/30 +trend", 0.7, 0.3),
|
||||
("60/40 +trend", 0.6, 0.4), ("50/50 +trend", 0.5, 0.5), ("100% trend", 0.0, 1.0)]
|
||||
rows = {}
|
||||
for nm, a, b in blends:
|
||||
r = a * base + b * sleeve
|
||||
rows[nm] = r; m = metrics(r)
|
||||
print(f"{nm:>22} {m['sr']:>+7.2f} {100*m['ann']:>+6.1f} {100*m['dd']:>+7.1f} {m['cal']:>7.2f}")
|
||||
# correlation of the two legs (the diversification engine)
|
||||
mask = np.isfinite(base) & np.isfinite(sleeve) & (base != 0) & (sleeve != 0)
|
||||
corr = np.corrcoef(base[mask], sleeve[mask])[0, 1]
|
||||
print(f"\n corr(60/40, trend) = {corr:+.2f} (negative/low = the diversification that lifts Sharpe + cuts DD)")
|
||||
print(" CRISIS-YEAR check — Sharpe by year (does trend save the equity/bond drawdowns?):")
|
||||
for nm in ["100% 60/40", "70/30 +trend"]:
|
||||
r = rows[nm]
|
||||
py = " ".join(f"{y}:{metrics(r[yr==y])['sr']:+.1f}" for y in [2015, 2018, 2020, 2022, 2025] if (yr == y).sum() > 100)
|
||||
print(f" {nm:>14}: {py}")
|
||||
print("\nVERDICT: if 70/30 or 60/40+trend Sharpe > plain 60/40 AND maxDD smaller (esp 2022) = the evidence-backed")
|
||||
print("lever works; ~0.7 -> ~0.8-0.9 with crisis protection. If not, 60/40 is genuinely the retail ceiling.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
126
scripts/surfer/reflexivity_test.py
Normal file
126
scripts/surfer/reflexivity_test.py
Normal file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The decisive gate both the critic and the exhaustion-researcher named: does the
|
||||
exhaustion-short carry MARGINAL ALPHA over plain residual momentum, or is it collinear?
|
||||
|
||||
Build, on the PIT crypto panel: (1) residual-momentum L/S book [validated], (2) exhaustion
|
||||
L/S book (z-sum of: neg residual-mom, fading dollar-volume rank, extreme funding, drawdown).
|
||||
Then: correlation, and regress exhaustion daily returns on momentum daily returns -> the
|
||||
intercept (alpha) is what matters. High corr + ~0 alpha => momentum re-spelled => drop it.
|
||||
Also A/B the squeeze veto (no short when funding<=0). Realistic: Reff = return - funding
|
||||
(shorts pay positive funding), death-excl, 10bp.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import pit_sweep # noqa: E402
|
||||
from signal_sweep import xs_weights, pnl_w, validate, sharpe_t # noqa: E402
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
TOPK = 50
|
||||
|
||||
|
||||
def roll(fn, X, L):
|
||||
out = np.full_like(X, np.nan)
|
||||
for t in range(L, len(X)):
|
||||
out[t] = fn(X[t - L:t], axis=0)
|
||||
return out
|
||||
|
||||
|
||||
def zc(x):
|
||||
mu = np.nanmean(x, axis=1, keepdims=True); sd = np.nanstd(x, axis=1, keepdims=True)
|
||||
return np.nan_to_num((x - mu) / np.where(sd > 0, sd, 1))
|
||||
|
||||
|
||||
def main():
|
||||
syms, days, close, qv, fund = pit_sweep.load()
|
||||
T, N = close.shape
|
||||
lc = np.log(close)
|
||||
R = np.zeros((T, N)); R[1:] = lc[1:] - lc[:-1]; R = np.where(np.isfinite(R), R, 0.0)
|
||||
fund = np.where(np.isfinite(fund), fund, 0.0)
|
||||
qv = np.nan_to_num(qv)
|
||||
dv30 = roll(np.mean, qv, 30)
|
||||
tradeable = np.ones((T, N), bool)
|
||||
for j in range(N):
|
||||
idx = np.where(np.isfinite(close[:, j]))[0]
|
||||
if len(idx):
|
||||
tradeable[max(0, idx[-1] - 4):idx[-1] + 1, j] = False
|
||||
Reff = np.where(tradeable, R - fund, 0.0)
|
||||
univ = np.zeros((T, N), bool)
|
||||
for t in range(T):
|
||||
elig = np.where((dv30[t] > 0) & np.isfinite(close[t]))[0]
|
||||
if len(elig):
|
||||
univ[t, elig[np.argsort(-dv30[t, elig])[:TOPK]]] = True
|
||||
year = (1970 + days / 365.25).astype(int)
|
||||
|
||||
# residual (beta-stripped) momentum
|
||||
mkt = np.array([R[t][univ[t]].mean() if univ[t].any() else 0.0 for t in range(T)])
|
||||
beta = np.zeros((T, N)); Wb = 60
|
||||
for t in range(Wb, T):
|
||||
mw = mkt[t - Wb:t]; vb = mw.var() + 1e-12
|
||||
beta[t] = ((R[t - Wb:t] * mw[:, None]).mean(0) - R[t - Wb:t].mean(0) * mw.mean()) / vb
|
||||
rcum = np.cumsum(R - beta * mkt[:, None], axis=0)
|
||||
resid_mom = np.full((T, N), np.nan); resid_mom[20:] = rcum[20:] - rcum[:-20]
|
||||
|
||||
# exhaustion terms (all oriented so HIGH => short candidate)
|
||||
dvr = np.full((T, N), np.nan) # cross-sectional dollar-vol rank (0..1)
|
||||
for t in range(T):
|
||||
e = np.where(univ[t])[0]
|
||||
if len(e) > 1:
|
||||
r = dv30[t, e].argsort().argsort().astype(float) / (len(e) - 1)
|
||||
dvr[t, e] = r
|
||||
dvr_slope = np.full((T, N), np.nan); dvr_slope[30:] = dvr[30:] - dvr[:-30] # falling rank = fading
|
||||
fund7 = roll(np.mean, fund, 7) # crowded-long carry
|
||||
rmax = roll(np.max, lc, 90); dd = lc - rmax # drawdown-from-ATH (<=0)
|
||||
|
||||
exh = zc(-resid_mom) + zc(-dvr_slope) + zc(fund7) + zc(-dd) # additive, equal-weight (no fitting)
|
||||
|
||||
def book(sig, veto_short=None):
|
||||
s = sig.copy(); s[~univ] = np.nan
|
||||
w = xs_weights(s)
|
||||
if veto_short is not None:
|
||||
w = np.where((w < 0) & veto_short, 0.0, w) # drop shorts where veto true
|
||||
return pnl_w(w, Reff, cost_bp=10)
|
||||
|
||||
pnl_mom = book(resid_mom)
|
||||
pnl_exh = book(-exh) # long low-exhaustion / short high
|
||||
squeeze_veto = fund <= 0 # don't short crowded-short (squeeze fuel)
|
||||
pnl_exh_veto = book(-exh, veto_short=squeeze_veto)
|
||||
|
||||
def stats(p):
|
||||
v = validate(p, days, 50); return v
|
||||
T_ = lambda x: torch.tensor(x[np.isfinite(x)], device=DEV, dtype=torch.float64)
|
||||
|
||||
print(f"\n===== EXHAUSTION-SHORT MARGINAL-ALPHA GATE (PIT top{TOPK}, death-excl, 10bp, deflate N=50) =====")
|
||||
for nm, p in [("residual_momentum", pnl_mom), ("exhaustion", pnl_exh), ("exhaustion+veto", pnl_exh_veto)]:
|
||||
v = stats(p)
|
||||
print(f" {nm:>18}: full {v['full']:+.2f} IS {v['is_']:+.2f} OOS {v['oos']:+.2f} CPCVmed {v['med']:+.2f} DSR {v['dsr']:.2f}")
|
||||
|
||||
# THE decisive test: regress exhaustion returns on momentum returns -> marginal alpha
|
||||
a, b = pnl_mom, pnl_exh
|
||||
m = np.isfinite(a) & np.isfinite(b)
|
||||
x, y = a[m], b[m]
|
||||
corr = float(np.corrcoef(x, y)[0, 1])
|
||||
beta1 = float(np.cov(x, y)[0, 1] / (np.var(x) + 1e-12))
|
||||
resid = y - beta1 * x
|
||||
alpha_daily = float(resid.mean())
|
||||
alpha_ann_sr = alpha_daily / (resid.std() + 1e-12) * math.sqrt(365)
|
||||
alpha_t = alpha_daily / (resid.std() / math.sqrt(len(resid)) + 1e-12)
|
||||
print(f"\n REGRESS exhaustion ~ momentum: corr {corr:+.2f} beta {beta1:+.2f}")
|
||||
print(f" marginal alpha: ann-Sharpe {alpha_ann_sr:+.2f} t-stat {alpha_t:+.2f} (>2 = real distinct edge)")
|
||||
|
||||
# does combining beat momentum alone (OOS)?
|
||||
sm, se = np.nanstd(a), np.nanstd(b)
|
||||
comb = (np.nan_to_num(a) / sm + np.nan_to_num(b) / se)
|
||||
vc = stats(comb); vm = stats(pnl_mom)
|
||||
print(f"\n combined(mom+exh) OOS {vc['oos']:+.2f} vs momentum-alone OOS {vm['oos']:+.2f} "
|
||||
f"=> {'ADDS' if vc['oos'] > vm['oos'] + 0.1 else 'no improvement'}")
|
||||
print("\nVERDICT: high corr + alpha t<2 + no OOS improvement => exhaustion is momentum re-spelled -> DROP (ship Comp-1).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
163
scripts/surfer/research_ab.py
Normal file
163
scripts/surfer/research_ab.py
Normal file
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Proper research: (a) robust momentum construction + (b) diversifying second edge.
|
||||
|
||||
All point-in-time (universe = daily top-50 by trailing dollar-volume, dead coins included) and
|
||||
under REALISM stress (Reff winsorized ±20%/day, death-spiral last-5d excluded, 20bp cost) — we
|
||||
optimize for robust/realistic performance, not headline.
|
||||
|
||||
(a) 6 pre-registered momentum constructions judged by STRESSED Sharpe + degradation ratio + every-year.
|
||||
(b) canonical diversifiers (carry, TS-trend, long/short reversal, low-vol, size) judged by correlation
|
||||
to the momentum sleeve + COMBINED inverse-vol book Sharpe (a weak-but-uncorrelated sleeve can help).
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import pit_sweep as ps # noqa: E402
|
||||
from signal_sweep import xs_weights, pnl_w, validate, sharpe_t # noqa: E402
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
WINSOR, COST, TOPK = 0.20, 20.0, 50
|
||||
|
||||
|
||||
def tr(lc, L, skip=0):
|
||||
out = np.full_like(lc, np.nan)
|
||||
if skip == 0:
|
||||
out[L:] = lc[L:] - lc[:-L]
|
||||
else: # return from t-L-skip to t-skip (classic 12-1 skip)
|
||||
out[L + skip:] = lc[L:-skip] - lc[:-(L + skip)]
|
||||
return out
|
||||
|
||||
|
||||
def roll(fn, X, L):
|
||||
out = np.full_like(X, np.nan)
|
||||
for t in range(L, len(X)):
|
||||
out[t] = fn(X[t - L:t], axis=0)
|
||||
return out
|
||||
|
||||
|
||||
def row_rank(x): # per-row percentile rank in [-0.5,0.5], nan-aware
|
||||
out = np.full_like(x, np.nan)
|
||||
for t in range(x.shape[0]):
|
||||
m = np.isfinite(x[t])
|
||||
if m.sum() > 1:
|
||||
r = x[t, m].argsort().argsort().astype(float)
|
||||
out[t, m] = r / (m.sum() - 1) - 0.5
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
syms, days, close, qv, fund = ps.load()
|
||||
T, N = close.shape
|
||||
lc = np.log(close)
|
||||
R = np.zeros((T, N)); R[1:] = lc[1:] - lc[:-1]; R = np.where(np.isfinite(R), R, 0.0)
|
||||
fund = np.where(np.isfinite(fund), fund, 0.0)
|
||||
qv = np.nan_to_num(qv)
|
||||
dv30 = roll(np.mean, qv, 30)
|
||||
year = (1970 + days / 365.25).astype(int)
|
||||
yrs = list(range(2020, 2027))
|
||||
|
||||
# PIT universe + tradeable (death-spiral) mask
|
||||
univ = np.zeros((T, N), bool)
|
||||
for t in range(T):
|
||||
elig = np.where((dv30[t] > 0) & np.isfinite(close[t]))[0]
|
||||
if len(elig):
|
||||
univ[t, elig[np.argsort(-dv30[t, elig])[:TOPK]]] = True
|
||||
tradeable = np.ones((T, N), bool)
|
||||
for j in range(N):
|
||||
idx = np.where(np.isfinite(close[:, j]))[0]
|
||||
if len(idx):
|
||||
tradeable[max(0, idx[-1] - 4):idx[-1] + 1, j] = False
|
||||
|
||||
Reff_base = R - fund
|
||||
Reff_str = np.where(tradeable, np.clip(Reff_base, -WINSOR, WINSOR), 0.0)
|
||||
vol20 = roll(np.std, R, 20)
|
||||
|
||||
def pnl(sig, reff, cost):
|
||||
s = sig.copy(); s[~univ] = np.nan
|
||||
return pnl_w(xs_weights(s), reff, cost_bp=cost)
|
||||
|
||||
def peryear(p):
|
||||
return [sharpe_t(torch.tensor(p[year[1:] == y], device=DEV, dtype=torch.float64))
|
||||
if (year[1:] == y).sum() > 30 else float("nan") for y in yrs]
|
||||
|
||||
# ---------- (a) momentum constructions ----------
|
||||
raw20 = tr(lc, 20)
|
||||
cons = {
|
||||
"raw_20": raw20,
|
||||
"rank_20": row_rank(raw20),
|
||||
"volscaled_20": raw20 / np.where(vol20 > 0, vol20, np.nan),
|
||||
"pathconsist_20": roll(np.mean, np.sign(R), 20),
|
||||
"skiprecent_20s3": tr(lc, 20, skip=3),
|
||||
"winsorinput_20": roll(np.sum, np.clip(R, -0.05, 0.05), 20),
|
||||
}
|
||||
print(f"\n===== (a) ROBUST MOMENTUM CONSTRUCTION (PIT top-{TOPK}, stress=winsor±{int(WINSOR*100)}%+death+{int(COST)}bp) =====")
|
||||
print(f"{'construction':>16} {'base_full':>9} {'str_full':>8} {'str_OOS':>7} {'str_CPCV':>8} {'ratio':>5} | per-year(stress) 2020..26")
|
||||
abest, abest_pnl, abest_score = None, None, -9
|
||||
for nm, sg in cons.items():
|
||||
pb = pnl(sg, Reff_base, 10.0); ps_ = pnl(sg, Reff_str, COST)
|
||||
sb = sharpe_t(torch.tensor(pb, device=DEV, dtype=torch.float64))
|
||||
v = validate(ps_, days, 6)
|
||||
ratio = v["full"] / sb if sb and not math.isnan(sb) and sb != 0 else float("nan")
|
||||
py = peryear(ps_)
|
||||
pys = " ".join(f"{p:>+5.2f}" if not math.isnan(p) else " n/a" for p in py)
|
||||
nneg = sum(1 for p in py if not math.isnan(p) and p < 0)
|
||||
score = v["med"] - 0.3 * nneg # robust: high stressed CPCV-med, few negative years
|
||||
print(f"{nm:>16} {sb:>+9.2f} {v['full']:>+8.2f} {v['oos']:>+7.2f} {v['med']:>+8.2f} {ratio:>5.2f} | {pys}")
|
||||
if score > abest_score:
|
||||
abest, abest_pnl, abest_score = nm, ps_, score
|
||||
print(f" -> robust momentum sleeve = {abest}")
|
||||
|
||||
# ---------- (b) diversifying second edge ----------
|
||||
sleeve = abest_pnl
|
||||
cand = {
|
||||
"carry_7": -roll(np.mean, fund, 7),
|
||||
"TStrend_30": np.sign(tr(lc, 30)) * np.where(univ, 1.0, np.nan),
|
||||
"LTreversal_180": -tr(lc, 180),
|
||||
"STreversal_2": -tr(lc, 2),
|
||||
"lowvol": -vol20,
|
||||
"size_small": -np.log(np.where(dv30 > 0, dv30, np.nan)),
|
||||
}
|
||||
print(f"\n===== (b) DIVERSIFYING EDGE vs momentum sleeve ({abest}) — stress applied =====")
|
||||
print(f"{'candidate':>16} {'standalone':>10} {'corr_to_mom':>11} {'combined':>9} | combined per-year 2020..26")
|
||||
sl = torch.tensor(sleeve, device=DEV, dtype=torch.float64)
|
||||
sl_sd = float(sl.std())
|
||||
combos = []
|
||||
for nm, sg in cand.items():
|
||||
pc = pnl(sg, Reff_str, COST)
|
||||
a, b = np.asarray(sleeve), np.asarray(pc)
|
||||
m = np.isfinite(a) & np.isfinite(b)
|
||||
corr = float(np.corrcoef(a[m], b[m])[0, 1]) if m.sum() > 50 else float("nan")
|
||||
sc = float(torch.tensor(pc, device=DEV, dtype=torch.float64).std())
|
||||
wm, wc = (1 / sl_sd), (1 / sc if sc > 0 else 0)
|
||||
comb = (wm * a + wc * b) / (wm + wc)
|
||||
vc = sharpe_t(torch.tensor(comb, device=DEV, dtype=torch.float64))
|
||||
py = peryear(comb)
|
||||
pys = " ".join(f"{p:>+5.2f}" if not math.isnan(p) else " n/a" for p in py)
|
||||
sa = sharpe_t(torch.tensor(pc, device=DEV, dtype=torch.float64))
|
||||
print(f"{nm:>16} {sa:>+10.2f} {corr:>+11.2f} {vc:>+9.2f} | {pys}")
|
||||
combos.append((nm, corr, vc, pc))
|
||||
|
||||
# ---------- final BOOK: momentum + diversifiers that lift combined Sharpe and are low-corr ----------
|
||||
base_sh = sharpe_t(sl)
|
||||
keep = [(nm, pc) for nm, corr, vc, pc in combos if vc > base_sh and (math.isnan(corr) or corr < 0.5)]
|
||||
print(f"\n===== BOOK = momentum + {[k for k,_ in keep]} (inverse-vol weighted, stress) =====")
|
||||
sleeves = [np.asarray(sleeve)] + [np.asarray(pc) for _, pc in keep]
|
||||
sds = [float(np.nanstd(s)) for s in sleeves]
|
||||
ws = np.array([1 / s if s > 0 else 0 for s in sds]); ws /= ws.sum()
|
||||
book = sum(w * s for w, s in zip(ws, sleeves))
|
||||
vb = validate(book, days, 6)
|
||||
py = peryear(book)
|
||||
pys = " ".join(f"{p:>+5.2f}" if not math.isnan(p) else " n/a" for p in py)
|
||||
print(f" momentum-only Sharpe (stress) = {base_sh:+.2f}")
|
||||
print(f" BOOK: full {vb['full']:+.2f} IS {vb['is_']:+.2f} OOS {vb['oos']:+.2f} CPCVmed {vb['med']:+.2f} CPCV5% {vb['p5']:+.2f}")
|
||||
print(f" BOOK per-year 2020..26: {pys}")
|
||||
print("\nVERDICT: book Sharpe > momentum-only with every-year positive => diversification works (deployable book).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
120
scripts/surfer/research_book.py
Normal file
120
scripts/surfer/research_book.py
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Capstone research: (2) low-vol full validation battery + (1) per-coin slippage/capacity model.
|
||||
|
||||
Point-in-time universe (daily top-50 by trailing dollar-vol, dead coins in). Returns are
|
||||
death-spiral-excluded (last 5 untradeable days zeroed) but NOT blanket-winsorized — the
|
||||
slippage model replaces the crude cap with realistic, liquidity-aware cost:
|
||||
one-way cost_i = half_spread(ADV_i) + η·σ_i·sqrt(trade_notional_i / ADV_i) (square-root impact)
|
||||
Sweeping AUM gives the capacity curve (Sharpe vs size) for momentum, low-vol, and the book.
|
||||
Low-vol gets the same battery momentum passed (window sweep, coin-bootstrap, per-year, IS/OOS).
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import pit_sweep as ps # noqa: E402
|
||||
from signal_sweep import xs_weights, pnl_w, validate, sharpe_t # noqa: E402
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
TOPK = 50
|
||||
|
||||
|
||||
def roll(fn, X, L):
|
||||
out = np.full_like(X, np.nan)
|
||||
for t in range(L, len(X)):
|
||||
out[t] = fn(X[t - L:t], axis=0)
|
||||
return out
|
||||
|
||||
|
||||
def tr(lc, L):
|
||||
out = np.full_like(lc, np.nan); out[L:] = lc[L:] - lc[:-L]; return out
|
||||
|
||||
|
||||
def main():
|
||||
syms, days, close, qv, fund = ps.load()
|
||||
T, N = close.shape
|
||||
lc = np.log(close)
|
||||
R = np.zeros((T, N)); R[1:] = lc[1:] - lc[:-1]; R = np.where(np.isfinite(R), R, 0.0)
|
||||
fund = np.where(np.isfinite(fund), fund, 0.0)
|
||||
qv = np.nan_to_num(qv)
|
||||
dv30 = roll(np.mean, qv, 30)
|
||||
vol20 = np.nan_to_num(roll(np.std, R, 20))
|
||||
year = (1970 + days / 365.25).astype(int)
|
||||
yrs = list(range(2020, 2027))
|
||||
|
||||
univ = np.zeros((T, N), bool)
|
||||
for t in range(T):
|
||||
elig = np.where((dv30[t] > 0) & np.isfinite(close[t]))[0]
|
||||
if len(elig):
|
||||
univ[t, elig[np.argsort(-dv30[t, elig])[:TOPK]]] = True
|
||||
tradeable = np.ones((T, N), bool)
|
||||
for j in range(N):
|
||||
idx = np.where(np.isfinite(close[:, j]))[0]
|
||||
if len(idx):
|
||||
tradeable[max(0, idx[-1] - 4):idx[-1] + 1, j] = False
|
||||
Reff = np.where(tradeable, R - fund, 0.0) # death-excl, NO blanket winsor
|
||||
|
||||
def W(sig):
|
||||
s = sig.copy(); s[~univ] = np.nan
|
||||
return xs_weights(s)
|
||||
|
||||
def peryear(p):
|
||||
return [sharpe_t(torch.tensor(p[year[1:] == y], device=DEV, dtype=torch.float64))
|
||||
if (year[1:] == y).sum() > 30 else float("nan") for y in yrs]
|
||||
|
||||
# ---------- (2) LOW-VOL BATTERY ----------
|
||||
print(f"\n===== (2) LOW-VOL VALIDATION BATTERY (PIT top-{TOPK}, death-excl, 10bp) =====")
|
||||
print(f"{'vol-window':>10} {'full':>6} {'IS':>6} {'OOS':>6} {'CPCVmed':>8} {'DSR':>5} | per-year 2020..26")
|
||||
for Wd in [10, 20, 30, 60]:
|
||||
sig = -np.nan_to_num(roll(np.std, R, Wd))
|
||||
pnl = pnl_w(W(sig), Reff, cost_bp=10)
|
||||
v = validate(pnl, days, 8)
|
||||
py = " ".join(f"{p:>+5.2f}" if not math.isnan(p) else " n/a" for p in peryear(pnl))
|
||||
print(f"{Wd:>10} {v['full']:>+6.2f} {v['is_']:>+6.2f} {v['oos']:>+6.2f} {v['med']:>+8.2f} {v['dsr']:>5.2f} | {py}")
|
||||
# coin-bootstrap on lowvol(20)
|
||||
rng = np.random.default_rng(7)
|
||||
lvsig = -vol20
|
||||
sh = []
|
||||
for _ in range(200):
|
||||
cols = rng.choice(N, size=max(N // 2, 10), replace=False)
|
||||
sub = np.full((T, N), np.nan); sub[:, cols] = lvsig[:, cols]
|
||||
s = sharpe_t(torch.tensor(pnl_w(W(sub), Reff, cost_bp=10), device=DEV, dtype=torch.float64))
|
||||
if not math.isnan(s):
|
||||
sh.append(s)
|
||||
sh = np.array(sh)
|
||||
print(f" coin-bootstrap(200): mean {sh.mean():+.2f} median {np.median(sh):+.2f} 5th {np.percentile(sh,5):+.2f} frac>0 {np.mean(sh>0):.2f}")
|
||||
|
||||
# ---------- (1) SLIPPAGE / CAPACITY MODEL ----------
|
||||
w_mom = W(tr(lc, 20)) # raw 20d momentum (diversifies w/ lowvol, corr -0.13)
|
||||
w_lv = W(-vol20)
|
||||
w_book = 0.5 * (w_mom + w_lv) # netting preserved (offsetting positions reduce turnover)
|
||||
|
||||
def net_sharpe(w, AUM, eta=1.0):
|
||||
turn = np.abs(w[1:] - w[:-1]) # fraction of AUM traded per coin
|
||||
adv = dv30[1:]; sg = vol20[1:]
|
||||
hs = np.clip(30.0 / np.sqrt(np.maximum(adv, 1.0) / 1e6), 1.0, 30.0) / 1e4 # half-spread frac
|
||||
part = np.where(adv > 0, turn * AUM / adv, 0.0)
|
||||
impact = eta * sg * np.sqrt(np.clip(part, 0, None))
|
||||
cost = np.sum(turn * (hs + impact), axis=1)
|
||||
gross = np.sum(w[:-1] * Reff[1:], axis=1)
|
||||
return sharpe_t(torch.tensor(gross - cost, device=DEV, dtype=torch.float64))
|
||||
|
||||
print(f"\n===== (1) SLIPPAGE / CAPACITY — Sharpe vs AUM (sqrt-impact η=1, liquidity-scaled spread) =====")
|
||||
aums = [1e6, 5e6, 2e7, 5e7, 2e8, 5e8]
|
||||
print(f"{'sleeve':>12} " + " ".join(f"{'$'+(f'{a/1e6:.0f}M' if a<1e9 else f'{a/1e9:.1f}B'):>8}" for a in aums))
|
||||
for nm, w in [("momentum", w_mom), ("lowvol", w_lv), ("BOOK", w_book)]:
|
||||
row = [net_sharpe(w, a) for a in aums]
|
||||
print(f"{nm:>12} " + " ".join(f"{r:>+8.2f}" for r in row))
|
||||
# frictionless reference (death-excl only, no cost)
|
||||
print(f"{'(grossSR)':>12} " + " ".join(
|
||||
f"{sharpe_t(torch.tensor(np.sum(w_book[:-1]*Reff[1:],axis=1),device=DEV,dtype=torch.float64)):>+8.2f}" for _ in [0]) +
|
||||
" <- book gross (no cost)")
|
||||
print("\nVERDICT: Sharpe at $5-50M AUM = realistic deployable number; where it decays = capacity ceiling.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
100
scripts/surfer/research_capacity.py
Normal file
100
scripts/surfer/research_capacity.py
Normal file
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Turnover / capacity optimization for crypto XS momentum (the one validated edge).
|
||||
|
||||
Daily-rebalancing a 20-day signal pays ~5x wasted turnover. Test rebalance frequency,
|
||||
signal smoothing, and universe breadth (top-K) against the same square-root slippage model.
|
||||
Trade-off: weekly/top-20 cuts cost but top-20 cuts breadth (momentum needs dispersion).
|
||||
Report gross Sharpe, avg daily turnover, and net Sharpe-vs-AUM per config.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import pit_sweep as ps # noqa: E402
|
||||
from signal_sweep import xs_weights, sharpe_t # noqa: E402
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
|
||||
|
||||
def roll(fn, X, L):
|
||||
out = np.full_like(X, np.nan)
|
||||
for t in range(L, len(X)):
|
||||
out[t] = fn(X[t - L:t], axis=0)
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
syms, days, close, qv, fund = ps.load()
|
||||
T, N = close.shape
|
||||
lc = np.log(close)
|
||||
R = np.zeros((T, N)); R[1:] = lc[1:] - lc[:-1]; R = np.where(np.isfinite(R), R, 0.0)
|
||||
fund = np.where(np.isfinite(fund), fund, 0.0)
|
||||
qv = np.nan_to_num(qv)
|
||||
dv30 = roll(np.mean, qv, 30)
|
||||
vol20 = np.nan_to_num(roll(np.std, R, 20))
|
||||
Reff = R - fund
|
||||
tradeable = np.ones((T, N), bool)
|
||||
for j in range(N):
|
||||
idx = np.where(np.isfinite(close[:, j]))[0]
|
||||
if len(idx):
|
||||
tradeable[max(0, idx[-1] - 4):idx[-1] + 1, j] = False
|
||||
Reff = np.where(tradeable, Reff, 0.0)
|
||||
raw20 = np.full_like(lc, np.nan); raw20[20:] = lc[20:] - lc[:-20]
|
||||
|
||||
def univ_mask(TOPK):
|
||||
u = np.zeros((T, N), bool)
|
||||
for t in range(T):
|
||||
elig = np.where((dv30[t] > 0) & np.isfinite(close[t]))[0]
|
||||
if len(elig):
|
||||
u[t, elig[np.argsort(-dv30[t, elig])[:TOPK]]] = True
|
||||
return u
|
||||
|
||||
def held_weights(TOPK, K, smooth):
|
||||
sig = raw20.copy(); sig[~univ_mask(TOPK)] = np.nan
|
||||
wt = xs_weights(sig)
|
||||
if smooth > 1: # EWMA the target weights over time
|
||||
a = 2.0 / (smooth + 1)
|
||||
for t in range(1, T):
|
||||
wt[t] = a * wt[t] + (1 - a) * wt[t - 1]
|
||||
wh = wt.copy() # step-rebalance every K days
|
||||
if K > 1:
|
||||
for t in range(T):
|
||||
wh[t] = wt[t - (t % K)]
|
||||
return wh
|
||||
|
||||
def net_sharpe(wh, AUM, eta=1.0):
|
||||
turn = np.abs(wh[1:] - wh[:-1])
|
||||
adv = dv30[1:]; sg = vol20[1:]
|
||||
hs = np.clip(30.0 / np.sqrt(np.maximum(adv, 1.0) / 1e6), 1.0, 30.0) / 1e4
|
||||
part = np.where(adv > 0, turn * AUM / adv, 0.0)
|
||||
cost = np.sum(turn * (hs + eta * sg * np.sqrt(np.clip(part, 0, None))), axis=1)
|
||||
return sharpe_t(torch.tensor(np.sum(wh[:-1] * Reff[1:], axis=1) - cost, device=DEV, dtype=torch.float64))
|
||||
|
||||
aums = [1e6, 5e6, 2e7, 5e7, 2e8, 5e8]
|
||||
print(f"\n===== TURNOVER / CAPACITY OPTIMIZATION (XS momentum-20, sqrt-impact η=1) =====")
|
||||
print(f"{'config':>26} {'grossSR':>7} {'turn%':>6} | " + " ".join(
|
||||
f"{('$'+(f'{a/1e6:.0f}M' if a<1e9 else f'{a/1e9:.1f}B')):>7}" for a in aums))
|
||||
cfgs = [
|
||||
("top50 daily (baseline)", 50, 1, 1),
|
||||
("top50 weekly", 50, 5, 1),
|
||||
("top50 weekly+smooth5", 50, 5, 5),
|
||||
("top30 weekly+smooth5", 30, 5, 5),
|
||||
("top20 weekly+smooth5", 20, 5, 5),
|
||||
("top20 biweekly+smooth10", 20, 10, 10),
|
||||
("top30 biweekly+smooth10", 30, 10, 10),
|
||||
]
|
||||
for nm, TOPK, K, sm in cfgs:
|
||||
wh = held_weights(TOPK, K, sm)
|
||||
gross = sharpe_t(torch.tensor(np.sum(wh[:-1] * Reff[1:], axis=1), device=DEV, dtype=torch.float64))
|
||||
turn = float(np.mean(np.sum(np.abs(wh[1:] - wh[:-1]), axis=1))) * 100
|
||||
row = [net_sharpe(wh, a) for a in aums]
|
||||
print(f"{nm:>26} {gross:>+7.2f} {turn:>5.1f}% | " + " ".join(f"{r:>+7.2f}" for r in row))
|
||||
print("\nVERDICT: best config's $20-50M net Sharpe = realistic deployable number at meaningful scale.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
115
scripts/surfer/research_diversifier.py
Normal file
115
scripts/surfer/research_diversifier.py
Normal file
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Diversifier hunt: directional TIME-SERIES trend (CTA-style) vs market-neutral XS momentum.
|
||||
|
||||
XS momentum (the validated edge) is market-NEUTRAL relative-value. A proper TS-trend sleeve is
|
||||
DIRECTIONAL — net-long when coins trend up, net-SHORT when they trend down — so it should profit
|
||||
in sustained bears (2022) when XS momentum is weak: the classic crisis-alpha complement.
|
||||
Tests TS-trend standalone battery (lookback, per-year, bootstrap), its CORRELATION to the
|
||||
momentum sleeve, and the COMBINED book. PIT universe, death-excl, realistic.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import pit_sweep as ps # noqa: E402
|
||||
from signal_sweep import xs_weights, pnl_w, validate, sharpe_t # noqa: E402
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
TOPK = 30
|
||||
|
||||
|
||||
def roll(fn, X, L):
|
||||
out = np.full_like(X, np.nan)
|
||||
for t in range(L, len(X)):
|
||||
out[t] = fn(X[t - L:t], axis=0)
|
||||
return out
|
||||
|
||||
|
||||
def tr(lc, L):
|
||||
out = np.full_like(lc, np.nan); out[L:] = lc[L:] - lc[:-L]; return out
|
||||
|
||||
|
||||
def main():
|
||||
syms, days, close, qv, fund = ps.load()
|
||||
T, N = close.shape
|
||||
lc = np.log(close)
|
||||
R = np.zeros((T, N)); R[1:] = lc[1:] - lc[:-1]; R = np.where(np.isfinite(R), R, 0.0)
|
||||
fund = np.where(np.isfinite(fund), fund, 0.0)
|
||||
qv = np.nan_to_num(qv)
|
||||
dv30 = roll(np.mean, qv, 30)
|
||||
vol20 = np.nan_to_num(roll(np.std, R, 20))
|
||||
year = (1970 + days / 365.25).astype(int); yrs = list(range(2020, 2027))
|
||||
univ = np.zeros((T, N), bool)
|
||||
for t in range(T):
|
||||
elig = np.where((dv30[t] > 0) & np.isfinite(close[t]))[0]
|
||||
if len(elig):
|
||||
univ[t, elig[np.argsort(-dv30[t, elig])[:TOPK]]] = True
|
||||
tradeable = np.ones((T, N), bool)
|
||||
for j in range(N):
|
||||
idx = np.where(np.isfinite(close[:, j]))[0]
|
||||
if len(idx):
|
||||
tradeable[max(0, idx[-1] - 4):idx[-1] + 1, j] = False
|
||||
Reff = np.where(tradeable, R - fund, 0.0)
|
||||
invvol = np.where(vol20 > 0, 1.0 / vol20, 0.0)
|
||||
|
||||
def wn(sig): # market-neutral (demeaned) unit-gross
|
||||
s = sig.copy(); s[~univ] = np.nan
|
||||
return xs_weights(s)
|
||||
|
||||
def wdir(sig): # DIRECTIONAL unit-gross (NOT demeaned) — net long/short exposure
|
||||
s = np.where(univ, sig, 0.0)
|
||||
g = np.sum(np.abs(s), axis=1, keepdims=True); g[g == 0] = 1
|
||||
return s / g
|
||||
|
||||
def peryear(p):
|
||||
return [sharpe_t(torch.tensor(p[year[1:] == y], device=DEV, dtype=torch.float64))
|
||||
if (year[1:] == y).sum() > 30 else float("nan") for y in yrs]
|
||||
|
||||
mom_pnl = pnl_w(wn(tr(lc, 20)), Reff, cost_bp=10)
|
||||
print(f"\n===== DIVERSIFIER HUNT: directional TS-trend vs XS momentum (PIT top-{TOPK}) =====")
|
||||
print("XS momentum sleeve per-year: " + " ".join(f"{p:>+5.2f}" for p in peryear(mom_pnl)) +
|
||||
f" (full {sharpe_t(torch.tensor(mom_pnl,device=DEV,dtype=torch.float64)):+.2f})")
|
||||
print(f"\n[TS-trend] DIRECTIONAL sign(trail_L)*invvol, net exposure")
|
||||
print(f"{'lookback':>9} {'full':>6} {'IS':>6} {'OOS':>6} {'CPCVmed':>8} {'corr_mom':>8} {'combined':>9} | per-year 2020..26 (TS)")
|
||||
for L in [20, 30, 60, 90]:
|
||||
ts = wdir(np.sign(tr(lc, L)) * invvol)
|
||||
ts_pnl = pnl_w(ts, Reff, cost_bp=10)
|
||||
v = validate(ts_pnl, days, 8)
|
||||
a, b = mom_pnl, ts_pnl
|
||||
m = np.isfinite(a) & np.isfinite(b)
|
||||
corr = float(np.corrcoef(a[m], b[m])[0, 1])
|
||||
sm = float(np.nanstd(a)); st = float(np.nanstd(b))
|
||||
comb = ((1 / sm) * a + (1 / st) * b) / (1 / sm + 1 / st)
|
||||
vc = sharpe_t(torch.tensor(comb, device=DEV, dtype=torch.float64))
|
||||
py = " ".join(f"{p:>+5.2f}" if not math.isnan(p) else " n/a" for p in peryear(ts_pnl))
|
||||
print(f"{L:>9} {v['full']:>+6.2f} {v['is_']:>+6.2f} {v['oos']:>+6.2f} {v['med']:>+8.2f} {corr:>+8.2f} {vc:>+9.2f} | {py}")
|
||||
|
||||
# bootstrap on TS-trend(30)
|
||||
rng = np.random.default_rng(11)
|
||||
base = np.sign(tr(lc, 30)) * invvol
|
||||
sh = []
|
||||
for _ in range(200):
|
||||
cols = rng.choice(N, size=max(N // 2, 10), replace=False)
|
||||
sub = np.zeros((T, N)); sub[:, cols] = base[:, cols]
|
||||
s = sharpe_t(torch.tensor(pnl_w(wdir(np.where(univ, sub, 0)), Reff, cost_bp=10), device=DEV, dtype=torch.float64))
|
||||
if not math.isnan(s):
|
||||
sh.append(s)
|
||||
sh = np.array(sh)
|
||||
print(f"\nTS-trend(30) coin-bootstrap(200): mean {sh.mean():+.2f} median {np.median(sh):+.2f} 5th {np.percentile(sh,5):+.2f} frac>0 {np.mean(sh>0):.2f}")
|
||||
|
||||
# best combined book per-year
|
||||
ts30 = pnl_w(wdir(np.sign(tr(lc, 30)) * invvol), Reff, cost_bp=10)
|
||||
sm, st = float(np.nanstd(mom_pnl)), float(np.nanstd(ts30))
|
||||
book = ((1 / sm) * mom_pnl + (1 / st) * ts30) / (1 / sm + 1 / st)
|
||||
vb = validate(book, days, 8)
|
||||
print(f"\nBOOK = XS-momentum + TS-trend(30) (inv-vol): full {vb['full']:+.2f} IS {vb['is_']:+.2f} OOS {vb['oos']:+.2f} CPCVmed {vb['med']:+.2f} 5% {vb['p5']:+.2f}")
|
||||
print("BOOK per-year 2020..26: " + " ".join(f"{p:>+5.2f}" for p in peryear(book)))
|
||||
print("\nVERDICT: TS-trend real (battery) + low corr + 2022 positive + lifts book => genuine diversifier.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
163
scripts/surfer/signal_sweep.py
Normal file
163
scripts/surfer/signal_sweep.py
Normal file
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deflated signal sweep — a factor zoo on data in hand, judged against the FULL search.
|
||||
|
||||
Wisdom: breadth without multiple-testing control is the fastest way to fool yourself.
|
||||
So every signal's Deflated Sharpe is deflated by N_trials = the number of signals tested.
|
||||
Batch 1 (instant, free, data already downloaded): 22-futures daily panel — cross-sectional
|
||||
& time-series momentum, short-term reversal, low-vol — plus structural SEASONALITY
|
||||
(overnight-drift, intraday, day-of-week). Each signal → daily PnL → full/IS/OOS Sharpe,
|
||||
CPCV(45-path) median+5th-pct, Deflated Sharpe. Ranked by OOS. Survivor = DSR>0.95 AND
|
||||
OOS>0 AND CPCV-median>0 (consistent + multiple-testing-honest).
|
||||
|
||||
Validation on GPU (torch); panel/signal staging on CPU (small). Roll-zeroed close-close
|
||||
returns (screening; survivors get proper roll handling in deeper validation).
|
||||
"""
|
||||
import glob
|
||||
import math
|
||||
import re
|
||||
from itertools import combinations
|
||||
from statistics import NormalDist
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
ND = NormalDist()
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
MONTHS = "FGHJKMNQUVXZ"
|
||||
SPLIT_DAY = 19723 # 2024-01-01 in days-since-epoch
|
||||
|
||||
|
||||
def load_panel():
|
||||
import databento as db
|
||||
series = {}
|
||||
for p in sorted(glob.glob("data/surfer/*.dbn")):
|
||||
root = p.split("/")[-1][:-4]
|
||||
pat = re.compile("^" + re.escape(root) + "[" + MONTHS + r"]\d{1,2}$")
|
||||
try:
|
||||
df = db.DBNStore.from_file(p).to_df().reset_index()
|
||||
except Exception:
|
||||
continue
|
||||
if df.empty or "close" not in df.columns:
|
||||
continue
|
||||
df = df[df["close"] > 0]
|
||||
df = df[df["symbol"].astype(str).str.match(pat)]
|
||||
if df.empty:
|
||||
continue
|
||||
df["day"] = (df["ts_event"].astype("int64") // (86_400 * 10**9))
|
||||
idx = df.groupby("day")["volume"].idxmax()
|
||||
f = df.loc[idx, ["day", "instrument_id", "open", "close"]].sort_values("day")
|
||||
series[root] = (f["day"].to_numpy(np.int64), f["instrument_id"].to_numpy(np.int64),
|
||||
f["open"].to_numpy(np.float64), f["close"].to_numpy(np.float64))
|
||||
roots = sorted(series)
|
||||
days = np.array(sorted(set().union(*[set(series[r][0].tolist()) for r in roots])))
|
||||
di = {d: i for i, d in enumerate(days.tolist())}
|
||||
T, N = len(days), len(roots)
|
||||
close = np.full((T, N), np.nan); op = np.full((T, N), np.nan); inst = np.full((T, N), -1, np.int64)
|
||||
for j, r in enumerate(roots):
|
||||
d, ii, oo, cc = series[r]
|
||||
for k in range(len(d)):
|
||||
row = di[int(d[k])]; close[row, j] = cc[k]; op[row, j] = oo[k]; inst[row, j] = ii[k]
|
||||
weekday = (days + 3) % 7 # epoch day0 = Thursday → 0=Mon..6=Sun
|
||||
return roots, days, close, op, inst, weekday
|
||||
|
||||
|
||||
def build_returns(close, op, inst):
|
||||
T, N = close.shape
|
||||
lc = np.log(close)
|
||||
R = np.zeros((T, N)); R[1:] = lc[1:] - lc[:-1]
|
||||
same = np.zeros((T, N), bool); same[1:] = inst[1:] == inst[:-1]
|
||||
R = np.where(same & np.isfinite(R), R, 0.0)
|
||||
ov = np.zeros((T, N)); ov[1:] = np.log(op[1:] / close[:-1])
|
||||
ov = np.where(same & np.isfinite(ov), ov, 0.0)
|
||||
intr = np.log(close / op); intr = np.where(np.isfinite(intr), intr, 0.0)
|
||||
return lc, R, ov, intr
|
||||
|
||||
|
||||
def trailing(lc, L):
|
||||
out = np.full_like(lc, np.nan); out[L:] = lc[L:] - lc[:-L]; return out
|
||||
|
||||
|
||||
def xs_weights(sig): # cross-sectional z-score, market-neutral, unit gross
|
||||
mu = np.nanmean(sig, axis=1, keepdims=True); sd = np.nanstd(sig, axis=1, keepdims=True)
|
||||
z = np.nan_to_num((sig - mu) / np.where(sd > 0, sd, 1))
|
||||
g = np.sum(np.abs(z), axis=1, keepdims=True); g[g == 0] = 1
|
||||
return z / g
|
||||
|
||||
|
||||
def pnl_w(w, R, cost_bp=2.0):
|
||||
p = np.sum(w[:-1] * R[1:], axis=1)
|
||||
turn = np.sum(np.abs(w[1:] - w[:-1]), axis=1)
|
||||
return p - turn * (cost_bp / 1e4)
|
||||
|
||||
|
||||
def sharpe_t(x):
|
||||
x = x[torch.isfinite(x)]
|
||||
if len(x) < 10 or float(x.std()) == 0:
|
||||
return float("nan")
|
||||
return float(x.mean() / x.std() * math.sqrt(252))
|
||||
|
||||
|
||||
def validate(pnl, days, n_trials):
|
||||
x = torch.tensor(np.asarray(pnl), device=DEV, dtype=torch.float64)
|
||||
n = len(x); full = sharpe_t(x)
|
||||
dd = days[-n:]
|
||||
ism = torch.tensor(dd < SPLIT_DAY, device=DEV)
|
||||
sis = sharpe_t(x[ism])
|
||||
soos = sharpe_t(x[~ism]) if int((~ism).sum()) > 30 else float("nan")
|
||||
nb, k = 10, 2
|
||||
blk = [torch.arange(i * n // nb, (i + 1) * n // nb, device=DEV) for i in range(nb)]
|
||||
oos = [sharpe_t(x[torch.cat([blk[b] for b in c])]) for c in combinations(range(nb), k)]
|
||||
oos = np.array([o for o in oos if not math.isnan(o)])
|
||||
med = float(np.median(oos)) if len(oos) else float("nan")
|
||||
p5 = float(np.percentile(oos, 5)) if len(oos) else float("nan")
|
||||
srd = full / math.sqrt(252)
|
||||
if n_trials > 1:
|
||||
sr0 = (1 / math.sqrt(n)) * ((1 - 0.5772) * ND.inv_cdf(1 - 1.0 / n_trials)
|
||||
+ 0.5772 * ND.inv_cdf(1 - 1.0 / (n_trials * math.e)))
|
||||
else:
|
||||
sr0 = 0.0
|
||||
dsr = ND.cdf((srd - sr0) * math.sqrt(n - 1)) if not math.isnan(full) else float("nan")
|
||||
return dict(full=full, is_=sis, oos=soos, med=med, p5=p5, dsr=dsr)
|
||||
|
||||
|
||||
def main():
|
||||
roots, days, close, op, inst, weekday = load_panel()
|
||||
lc, R, ov, intr = build_returns(close, op, inst)
|
||||
T, N = close.shape
|
||||
vol63 = np.full_like(lc, np.nan)
|
||||
for t in range(63, T):
|
||||
vol63[t] = np.nanstd(R[t - 63:t], axis=0)
|
||||
invvol = 1.0 / np.where(vol63 > 0, vol63, np.nan)
|
||||
|
||||
sig = {}
|
||||
for L in [21, 63, 126, 252]:
|
||||
sig[f"XS_mom_{L}"] = pnl_w(xs_weights(trailing(lc, L)), R)
|
||||
for L in [1, 5]:
|
||||
sig[f"XS_rev_{L}"] = pnl_w(xs_weights(-trailing(lc, L)), R)
|
||||
sig["XS_lowvol"] = pnl_w(xs_weights(-vol63), R)
|
||||
for L in [21, 63, 252]:
|
||||
w = np.nan_to_num(np.sign(trailing(lc, L)) * invvol)
|
||||
g = np.sum(np.abs(w), axis=1, keepdims=True); g[g == 0] = 1
|
||||
sig[f"TS_mom_{L}"] = pnl_w(w / g, R)
|
||||
sig["SEAS_overnight"] = np.nanmean(ov[1:], axis=1) - 1.0 / 1e4 # EW long overnight, ~1bp cost
|
||||
sig["SEAS_intraday"] = np.nanmean(intr[1:], axis=1) - 1.0 / 1e4 # EW long intraday
|
||||
ewR = np.nanmean(R, axis=1)
|
||||
for d, nm in enumerate(["Mon", "Tue", "Wed", "Thu", "Fri"]):
|
||||
sig[f"SEAS_{nm}"] = np.where(weekday[1:] == d, ewR[1:], 0.0)
|
||||
|
||||
NT = len(sig)
|
||||
rows = [(nm, validate(p, days, NT)) for nm, p in sig.items()]
|
||||
rows.sort(key=lambda r: -(r[1]["oos"] if not math.isnan(r[1]["oos"]) else -9))
|
||||
print(f"\n===== DEFLATED SIGNAL SWEEP — Batch 1 (22 futures daily + seasonality) =====")
|
||||
print(f"device={DEV} roots={len(roots)} days={T} N_trials(deflation)={NT}")
|
||||
print(f"{'signal':>16} {'full':>7} {'IS':>7} {'OOS':>7} {'CPCVmed':>8} {'CPCV5%':>8} {'DSR':>6}")
|
||||
for nm, v in rows:
|
||||
print(f"{nm:>16} {v['full']:>+7.2f} {v['is_']:>+7.2f} {v['oos']:>+7.2f} "
|
||||
f"{v['med']:>+8.2f} {v['p5']:>+8.2f} {v['dsr']:>6.2f}")
|
||||
surv = [nm for nm, v in rows if v["dsr"] > 0.95 and v["oos"] > 0 and v["med"] > 0]
|
||||
print(f"\nSURVIVORS (DSR>0.95 & OOS>0 & CPCVmed>0): {surv if surv else 'NONE'}")
|
||||
print("DSR is deflated by N_trials → multiple-testing-honest. Survivors get deeper validation + crypto/COT batches.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
151
scripts/surfer/sixtyforty_paper.py
Normal file
151
scripts/surfer/sixtyforty_paper.py
Normal file
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""sixtyforty — local CLI paper-forward test of a simple 60/40 ETF portfolio (the ~0.7-Sharpe
|
||||
non-crypto baseline). No agents, no cloud, no capital. Free Yahoo daily adjusted closes.
|
||||
|
||||
60% SPY (S&P 500) + 40% IEF (7-10yr Treasuries) — the deployable ETF analog of the ES/ZN 60/40
|
||||
backtest (+0.72 Sharpe). Always invested (no regime filter); the test just confirms the realized
|
||||
forward Sharpe/return tracks the backtest. Books each real trading day exactly once (catch-up on
|
||||
weekends / missed runs), using ADJUSTED closes (dividends + coupons = true total return).
|
||||
|
||||
python3 sixtyforty_paper.py snapshot latest prices + recent daily portfolio returns
|
||||
python3 sixtyforty_paper.py run book new trading days, persist (cron-compatible)
|
||||
python3 sixtyforty_paper.py status cumulative return + annualized Sharpe/vol/maxDD
|
||||
(alias: 'paper' == 'run')
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
STATE = os.path.join(_REPO, "data/surfer/sixtyforty_state.json")
|
||||
INSTR = [("SPY", 0.6), ("IEF", 0.4)]
|
||||
|
||||
|
||||
def get(url, tries=4):
|
||||
import time
|
||||
for a in range(tries):
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
return json.loads(urllib.request.urlopen(req, timeout=30).read())
|
||||
except Exception:
|
||||
if a == tries - 1:
|
||||
raise
|
||||
time.sleep(2 * (a + 1))
|
||||
|
||||
|
||||
def daily_adjclose(sym):
|
||||
"""{ 'YYYY-MM-DD': adjclose } for ~3 months of trading days (Yahoo, free)."""
|
||||
u = f"https://query1.finance.yahoo.com/v8/finance/chart/{sym}?interval=1d&range=3mo"
|
||||
res = get(u)["chart"]["result"][0]
|
||||
ts = res["timestamp"]
|
||||
ind = res["indicators"]
|
||||
adj = ind.get("adjclose", [{}])[0].get("adjclose") or ind["quote"][0]["close"]
|
||||
out = {}
|
||||
for t, c in zip(ts, adj):
|
||||
if c is not None:
|
||||
d = datetime.datetime.utcfromtimestamp(t).strftime("%Y-%m-%d")
|
||||
out[d] = float(c)
|
||||
return out
|
||||
|
||||
|
||||
def load_state():
|
||||
if os.path.exists(STATE):
|
||||
return json.load(open(STATE))
|
||||
return {"last_date": "", "days": 0, "sum_r": 0.0, "sumsq_r": 0.0,
|
||||
"equity": 1.0, "peak": 1.0, "max_dd": 0.0}
|
||||
|
||||
|
||||
def metrics(st):
|
||||
n = st["days"]
|
||||
if n < 2:
|
||||
return None
|
||||
mean = st["sum_r"] / n
|
||||
var = max(st["sumsq_r"] / n - mean * mean, 0.0)
|
||||
std = math.sqrt(var)
|
||||
apr = mean * 252
|
||||
vol = std * math.sqrt(252)
|
||||
return dict(apr=apr, vol=vol, sharpe=(apr / vol if vol > 0 else float("nan")),
|
||||
total=st["equity"] - 1.0, maxdd=st["max_dd"])
|
||||
|
||||
|
||||
def series():
|
||||
data = {s: daily_adjclose(s) for s, _ in INSTR}
|
||||
dates = sorted(set.intersection(*[set(d) for d in data.values()]))
|
||||
return data, dates
|
||||
|
||||
|
||||
def cmd_run():
|
||||
st = load_state()
|
||||
data, dates = series()
|
||||
if len(dates) < 2:
|
||||
print("not enough data"); return
|
||||
os.makedirs(os.path.dirname(STATE), exist_ok=True)
|
||||
if not st["last_date"]: # fresh: start forward from latest close, no backfill
|
||||
st["last_date"] = dates[-1]
|
||||
json.dump(st, open(STATE, "w"))
|
||||
print(f"initialized forward tracking from {dates[-1]}; first booked return on the next trading day.")
|
||||
return
|
||||
booked = 0
|
||||
for i in range(1, len(dates)):
|
||||
d, dprev = dates[i], dates[i - 1]
|
||||
if d <= st["last_date"]:
|
||||
continue
|
||||
r = sum(w * (data[s][d] / data[s][dprev] - 1.0) for s, w in INSTR) # daily-rebalanced port return
|
||||
st["days"] += 1
|
||||
st["sum_r"] += r
|
||||
st["sumsq_r"] += r * r
|
||||
st["equity"] *= (1 + r)
|
||||
st["peak"] = max(st["peak"], st["equity"])
|
||||
st["max_dd"] = min(st["max_dd"], st["equity"] / st["peak"] - 1.0)
|
||||
st["last_date"] = d
|
||||
booked += 1
|
||||
os.makedirs(os.path.dirname(STATE), exist_ok=True)
|
||||
json.dump(st, open(STATE, "w"))
|
||||
m = metrics(st)
|
||||
tail = f" | Sharpe {m['sharpe']:+.2f} APR {100*m['apr']:+.1f}% maxDD {100*m['maxdd']:+.1f}%" if m else ""
|
||||
print(f"{datetime.date.today()} booked {booked} new trading day(s) -> day {st['days']} "
|
||||
f"(through {st['last_date']}); total {100*(st['equity']-1):+.2f}%{tail}")
|
||||
|
||||
|
||||
def cmd_status():
|
||||
st = load_state()
|
||||
m = metrics(st)
|
||||
print(f"60/40 (SPY 60% / IEF 40%) paper — day {st['days']} (through {st['last_date'] or 'n/a'})")
|
||||
print(f" total return: {100*(st['equity']-1):+.2f}%")
|
||||
if m:
|
||||
print(f" annualized: Sharpe {m['sharpe']:+.2f} return {100*m['apr']:+.1f}% vol {100*m['vol']:.1f}% maxDD {100*m['maxdd']:+.1f}%")
|
||||
print(f" backtest reference: ~0.72 Sharpe (ES/ZN 60/40, 2010-2026)")
|
||||
else:
|
||||
print(" (need >=2 booked trading days for annualized stats)")
|
||||
|
||||
|
||||
def cmd_snapshot():
|
||||
data, dates = series()
|
||||
last = dates[-1]
|
||||
print(f"60/40 snapshot {datetime.date.today()} (latest close {last})")
|
||||
for s, w in INSTR:
|
||||
print(f" {s} ({int(w*100)}%): {data[s][last]:.2f}")
|
||||
print(" recent daily portfolio returns:")
|
||||
for i in range(max(1, len(dates) - 5), len(dates)):
|
||||
d, dprev = dates[i], dates[i - 1]
|
||||
r = sum(w * (data[s][d] / data[s][dprev] - 1.0) for s, w in INSTR)
|
||||
print(f" {d}: {100*r:+.2f}%")
|
||||
|
||||
|
||||
def main():
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "status"
|
||||
if cmd in ("run", "paper"):
|
||||
cmd_run()
|
||||
elif cmd == "status":
|
||||
cmd_status()
|
||||
elif cmd == "snapshot":
|
||||
cmd_snapshot()
|
||||
else:
|
||||
print(__doc__)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
98
scripts/surfer/strategy_compare.py
Normal file
98
scripts/surfer/strategy_compare.py
Normal file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Side-by-side comparison of the wealth candidates (the CAGR reference plan, re-runnable).
|
||||
|
||||
Backtest (2006-2026, incl 2008) + the $360k+$8k/mo 20y trajectory for: adaptive multi-strat book,
|
||||
SPY buy-hold, SPY 1.3x (cheap futures financing), 60/40, 70/30 SPY+book, SPY+adaptive overlay.
|
||||
Honest: CAGR builds wealth; more CAGR = more drawdown. See docs/.../2026-06-08-cagr-reference-plan.md.
|
||||
|
||||
python3 strategy_compare.py # full table
|
||||
python3 strategy_compare.py --p0 100000 --pmt 2000 --fin 0.03 --years 20
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from multistrat_paper import book_series # noqa: E402
|
||||
|
||||
|
||||
def arg(flag, default, cast=float):
|
||||
return cast(sys.argv[sys.argv.index(flag) + 1]) if flag in sys.argv else default
|
||||
|
||||
|
||||
def yh(s):
|
||||
r = json.loads(urllib.request.urlopen(urllib.request.Request(
|
||||
f"https://query1.finance.yahoo.com/v8/finance/chart/{s}?interval=1d&range=25y",
|
||||
headers={"User-Agent": "Mozilla/5.0"}), timeout=30).read())["chart"]["result"][0]
|
||||
a = r["indicators"].get("adjclose", [{}])[0].get("adjclose") or r["indicators"]["quote"][0]["close"]
|
||||
ts = r["timestamp"]
|
||||
return {datetime.datetime.utcfromtimestamp(t).strftime("%Y-%m-%d"): float(c) for t, c in zip(ts, a) if c is not None}
|
||||
|
||||
|
||||
def stats(r):
|
||||
r = np.nan_to_num(r)
|
||||
eq = np.cumprod(1 + r); cagr = eq[-1] ** (252 / len(r)) - 1
|
||||
vol = r.std() * math.sqrt(252)
|
||||
dd = float((eq / np.maximum.accumulate(eq) - 1).min())
|
||||
rf = 0.03; sharpe = (r.mean() * 252 - rf) / vol if vol > 0 else 0 # excess-over-financing Sharpe
|
||||
return cagr, vol, sharpe, dd
|
||||
|
||||
|
||||
def main():
|
||||
P0 = arg("--p0", 360000.0); PMT = arg("--pmt", 8000.0); FIN = arg("--fin", 0.03); YEARS = int(arg("--years", 20))
|
||||
syms = ["SPY", "IEF", "GLD", "DBC"]
|
||||
data = {s: yh(s) for s in syms}
|
||||
dates = sorted(set.intersection(*[set(d) for d in data.values()]))
|
||||
T = len(dates)
|
||||
Pm = np.column_stack([np.array([data[s][d] for d in dates]) for s in syms])
|
||||
R = np.zeros((T, 4)); R[1:] = Pm[1:] / Pm[:-1] - 1
|
||||
spy, ief = R[:, 0], R[:, 1]
|
||||
|
||||
# multi-strat book (proxy: SPY/IEF/GLD/DBC + DIY trend sleeve)
|
||||
lc = np.log(Pm); sg = np.zeros((T, 4)); sg[252:] = np.sign(lc[252:] - lc[:-252])
|
||||
vol = np.full((T, 4), np.nan)
|
||||
for t in range(63, T):
|
||||
vol[t] = R[t - 63:t].std(0)
|
||||
iv = 1 / np.where(vol > 0, vol, np.nan); w = np.nan_to_num(sg * iv)
|
||||
g = np.abs(w).sum(1, keepdims=True); g[g == 0] = 1; w = w / g
|
||||
trend = np.zeros(T); trend[1:] = np.sum(w[:-1] * R[1:], axis=1)
|
||||
book, _, _ = book_series(np.column_stack([R, trend]))
|
||||
|
||||
# SPY + adaptive overlay (vol-target 15% + 200d trend + drawdown de-lever)
|
||||
lp = np.log(Pm[:, 0]); ma = np.array([lp[max(0, t - 200):t].mean() if t >= 200 else lp[t] for t in range(T)])
|
||||
ovr = np.zeros(T); eqo = pk = 1.0
|
||||
for t in range(63, T - 1):
|
||||
rv = spy[t - 63:t].std() * math.sqrt(252); vs = min(1.0, 0.15 / (rv + 1e-9))
|
||||
tr = 1.0 if lp[t] > ma[t] else 0.3
|
||||
dd = eqo / pk - 1; dds = float(np.clip(1 + 3 * min(0, dd + 0.07), 0.4, 1.0))
|
||||
e = float(np.clip(vs * tr * dds, 0, 1.0)); ovr[t + 1] = e * spy[t + 1]; eqo *= (1 + ovr[t + 1]); pk = max(pk, eqo)
|
||||
|
||||
cands = {
|
||||
"SPY buy-hold (1.0x)": spy,
|
||||
"SPY 1.3x (cheap fin)": 1.3 * spy - 0.3 * FIN / 252,
|
||||
"60/40 (SPY/IEF)": 0.6 * spy + 0.4 * ief,
|
||||
"70/30 SPY+book": 0.7 * spy + 0.3 * book,
|
||||
"SPY+adaptive overlay": ovr,
|
||||
"adaptive multi-strat book": book,
|
||||
}
|
||||
|
||||
def fv(cagr):
|
||||
rm = cagr / 12; N = YEARS * 12; gg = (1 + rm) ** N
|
||||
return P0 * gg + PMT * ((gg - 1) / rm)
|
||||
|
||||
print(f"STRATEGY COMPARISON — {dates[0]}..{dates[-1]} | ${P0:,.0f} + ${PMT:,.0f}/mo, {YEARS}y, fin {100*FIN:.0f}%")
|
||||
print(f" {'strategy':>26} | CAGR | vol | Sharpe* | maxDD | {YEARS}y wealth")
|
||||
for nm, r in cands.items():
|
||||
c, v, sh, dd = stats(r)
|
||||
print(f" {nm:>26} | {100*c:>4.1f}% | {100*v:>3.0f}% | {sh:>+5.2f} | {100*dd:>+5.0f}% | ${fv(c):>13,.0f}")
|
||||
print(" *Sharpe = excess over financing (the leverage-relevant one). More CAGR ALWAYS = deeper maxDD.")
|
||||
print(" Reference plan: docs/superpowers/specs/2026-06-08-cagr-reference-plan.md")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
391
scripts/surfer/surfer_poc.py
Normal file
391
scripts/surfer/surfer_poc.py
Normal file
@@ -0,0 +1,391 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Surfer PoC — crypto cross-sectional momentum (the validated edge), end-to-end.
|
||||
|
||||
Modes:
|
||||
backtest — run the strategy over the cached point-in-time history; print honest stats
|
||||
(gross/net Sharpe at AUM, OOS, CPCV-median, DSR, per-year, max-DD, turnover, capacity curve)
|
||||
regime — diagnostic: does any regime feature predict when momentum works? (overlay is OFF by default)
|
||||
paper — forward paper-trade: fetch live data, compute today's target weights, log intended trades,
|
||||
mark the paper book (price + funding), persist state. The only true out-of-sample test.
|
||||
status — print the paper book + equity curve summary.
|
||||
|
||||
Design: ONE signal function (`compute_weights`) drives BOTH backtest and live (no backtest/live skew).
|
||||
No lookahead (signal uses closed bars <= t; weights apply to t+1; rebalance keyed on calendar day%K).
|
||||
Funding is P&L (longs pay, shorts earn). Realistic sqrt-impact cost. Validated edge only; regime overlay
|
||||
stays a diagnostic until separately confirmed.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from signal_sweep import xs_weights, validate, sharpe_t # noqa: E402
|
||||
import pit_sweep # noqa: E402 (load() of the cached PIT universe)
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
DAY_MS = 86_400_000
|
||||
_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # cwd-independent (cron-safe)
|
||||
STATE = os.path.join(_REPO, "data/surfer/poc_state.json")
|
||||
|
||||
CFG = dict(
|
||||
lookback=20, # XS momentum horizon (days)
|
||||
beta_win=60, # rolling window for market-beta (residual momentum strips this)
|
||||
topk=30, # universe = top-K liquid by trailing dollar-vol (breadth/liquidity optimum)
|
||||
rebal_k=7, # rebalance every 7 calendar days (weekly)
|
||||
smooth_span=5, # EWMA weight-smoothing span (cuts turnover + whipsaw)
|
||||
vol_win=30, # trailing vol window (for sizing / regime)
|
||||
cost_aum=2e7, # AUM the slippage model is priced at ($20M)
|
||||
eta=1.0, # sqrt market-impact coefficient (conservative)
|
||||
dd_breaker=-0.20, # cut gross exposure if rolling drawdown worse than this
|
||||
vrp_risk_frac=0.30, # VRP diversifier sleeve risk budget (small — short-vol tail)
|
||||
vrp_rise_k=5, # don't sell vol when DVOL rose over last K days (tail gate)
|
||||
vrp_level_win=60, # sell full size only when DVOL < trailing-win median
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------- helpers
|
||||
def roll(fn, X, L):
|
||||
out = np.full_like(X, np.nan)
|
||||
for t in range(L, len(X)):
|
||||
out[t] = fn(X[t - L:t], axis=0)
|
||||
return out
|
||||
|
||||
|
||||
def trailing(lc, L):
|
||||
out = np.full_like(lc, np.nan); out[L:] = lc[L:] - lc[:-L]; return out
|
||||
|
||||
|
||||
def ewma_rows(W, span):
|
||||
if span <= 1:
|
||||
return W
|
||||
a = 2.0 / (span + 1)
|
||||
out = W.copy()
|
||||
for t in range(1, len(W)):
|
||||
out[t] = a * W[t] + (1 - a) * out[t - 1]
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- SHARED SIGNAL (backtest == live)
|
||||
def compute_weights(close, qvol, days, cfg):
|
||||
"""Panel -> held target weights [T,N] (market-neutral, weekly, smoothed). No lookahead.
|
||||
Live takes the last row; backtest uses all rows. Identical code path."""
|
||||
T, N = close.shape
|
||||
lc = np.log(close)
|
||||
dv = roll(np.mean, np.nan_to_num(qvol), 30)
|
||||
univ = np.zeros((T, N), bool)
|
||||
for t in range(T):
|
||||
elig = np.where((dv[t] > 0) & np.isfinite(close[t]))[0]
|
||||
if len(elig):
|
||||
univ[t, elig[np.argsort(-dv[t, elig])[:cfg["topk"]]]] = True
|
||||
# residual (beta-stripped) momentum: strip each coin's beta to the equal-weight market,
|
||||
# rank the residual trend (Phase-1: +0.72 vs +0.57 raw — cleaner base edge). No lookahead.
|
||||
R = np.zeros((T, N)); R[1:] = lc[1:] - lc[:-1]; R = np.where(np.isfinite(R), R, 0.0)
|
||||
mkt = np.array([R[t][univ[t]].mean() if univ[t].any() else 0.0 for t in range(T)])
|
||||
Wb = cfg["beta_win"]; beta = np.zeros((T, N))
|
||||
for t in range(Wb, T):
|
||||
mw = mkt[t - Wb:t]; vb = mw.var() + 1e-12
|
||||
beta[t] = ((R[t - Wb:t] * mw[:, None]).mean(0) - R[t - Wb:t].mean(0) * mw.mean()) / vb
|
||||
rcum = np.cumsum(R - beta * mkt[:, None], axis=0)
|
||||
L = cfg["lookback"]
|
||||
sig = np.full((T, N), np.nan); sig[L:] = rcum[L:] - rcum[:-L]
|
||||
sig[~univ] = np.nan
|
||||
wt = xs_weights(sig) # daily target (market-neutral, unit gross)
|
||||
wt = ewma_rows(wt, cfg["smooth_span"]) # smooth
|
||||
held = wt.copy() # weekly rebalance on FIXED calendar phase (day%K==0)
|
||||
K = cfg["rebal_k"] # epoch day0=Thu → rebalances every Thursday, live-consistent
|
||||
last = 0
|
||||
for t in range(T):
|
||||
if days[t] % K == 0:
|
||||
last = t
|
||||
held[t] = wt[last]
|
||||
return held, univ
|
||||
|
||||
|
||||
def slippage_net(w, Reff, dv, vol, days, cfg):
|
||||
"""Net daily PnL series under sqrt market-impact + liquidity-scaled spread at cfg['cost_aum']."""
|
||||
AUM = cfg["cost_aum"]
|
||||
turn = np.abs(w[1:] - w[:-1])
|
||||
adv = np.nan_to_num(dv[1:]); sg = np.nan_to_num(vol[1:])
|
||||
hs = np.clip(30.0 / np.sqrt(np.maximum(adv, 1.0) / 1e6), 1.0, 30.0) / 1e4
|
||||
part = np.where(adv > 0, turn * AUM / adv, 0.0)
|
||||
cost = np.sum(turn * (hs + cfg["eta"] * sg * np.sqrt(np.clip(part, 0, None))), axis=1)
|
||||
return np.sum(w[:-1] * Reff[1:], axis=1) - cost
|
||||
|
||||
|
||||
def max_drawdown(pnl):
|
||||
eq = np.cumsum(np.nan_to_num(pnl)); peak = np.maximum.accumulate(eq)
|
||||
return float((eq - peak).min())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- VRP SLEEVE (tail-managed short-vol diversifier)
|
||||
def _dvol(ccy, refresh):
|
||||
cache = os.path.join(_REPO, "data/surfer/dvol", f"{ccy}.json")
|
||||
d = {int(k): v for k, v in json.load(open(cache)).items()} if os.path.exists(cache) else {}
|
||||
if refresh:
|
||||
end = int(time.time() * 1000); start = end - 200 * DAY_MS
|
||||
try:
|
||||
rows = _get(f"https://www.deribit.com/api/v2/public/get_volatility_index_data?currency={ccy}"
|
||||
f"&start_timestamp={start}&end_timestamp={end}&resolution=86400").get("result", {}).get("data", [])
|
||||
for r in rows:
|
||||
d[int(r[0]) // DAY_MS] = float(r[4])
|
||||
os.makedirs(os.path.dirname(cache), exist_ok=True)
|
||||
json.dump({str(k): v for k, v in d.items()}, open(cache, "w"))
|
||||
except Exception:
|
||||
pass
|
||||
return d
|
||||
|
||||
|
||||
def vrp_ccy(iv, close, cfg):
|
||||
"""Tail-managed short-variance daily P&L per ccy. Gate uses DVOL up to t-1 (no lookahead)."""
|
||||
days = sorted(set(iv) & set(close)); pnl = {}
|
||||
w0 = max(cfg["vrp_level_win"], cfg["vrp_rise_k"]) + 1
|
||||
for i in range(w0, len(days)):
|
||||
d0, d1 = days[i - 1], days[i]
|
||||
if close[d0] <= 0 or close[d1] <= 0:
|
||||
continue
|
||||
r = math.log(close[d1] / close[d0]); ivl = iv[d0] / 100.0
|
||||
raw = ivl * ivl / 365.0 - r * r # collect implied var, pay realized
|
||||
rising = iv[d0] > iv[days[i - 1 - cfg["vrp_rise_k"]]] # don't sell into rising vol
|
||||
win = [iv[days[j]] for j in range(i - 1 - cfg["vrp_level_win"], i - 1)]
|
||||
level = 1.0 if iv[d0] < float(np.median(win)) else 0.5 # full in calm, half elevated
|
||||
pnl[d1] = (0.0 if rising else 1.0) * level * raw
|
||||
return pnl
|
||||
|
||||
|
||||
def vrp_book_series(cfg, btc_close, eth_close, refresh):
|
||||
b = vrp_ccy(_dvol("BTC", refresh), btc_close, cfg)
|
||||
e = vrp_ccy(_dvol("ETH", refresh), eth_close, cfg)
|
||||
days = sorted(set(b) | set(e))
|
||||
return {d: float(np.nanmean([b.get(d, np.nan), e.get(d, np.nan)])) for d in days}
|
||||
|
||||
|
||||
def _closes_from(syms, days, close, sym):
|
||||
if sym not in syms:
|
||||
return {}
|
||||
j = syms.index(sym)
|
||||
return {int(days[t]): float(close[t, j]) for t in range(len(days)) if np.isfinite(close[t, j])}
|
||||
|
||||
|
||||
def _vrp_calib(cfg):
|
||||
"""From cached history: (momentum daily vol, VRP daily vol) — to size VRP to its risk budget."""
|
||||
syms, days, close, qv, fund = pit_sweep.load()
|
||||
R = np.zeros_like(close); R[1:] = np.log(close)[1:] - np.log(close)[:-1]; R = np.where(np.isfinite(R), R, 0.0)
|
||||
cf = np.where(np.isfinite(fund), fund, 0.0)
|
||||
w, _ = compute_weights(close, qv, days, cfg)
|
||||
mom = np.sum(w[:-1] * (R - cf)[1:], axis=1)
|
||||
vrpb = vrp_book_series(cfg, _closes_from(syms, days, close, "BTCUSDT"),
|
||||
_closes_from(syms, days, close, "ETHUSDT"), refresh=False)
|
||||
return float(np.nanstd(mom)), float(np.nanstd(np.array(list(vrpb.values()))))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- BACKTEST
|
||||
def backtest(cfg):
|
||||
syms, days, close, qv, fund = pit_sweep.load()
|
||||
T, N = close.shape
|
||||
R = np.zeros((T, N)); R[1:] = np.log(close)[1:] - np.log(close)[:-1]
|
||||
R = np.where(np.isfinite(R), R, 0.0)
|
||||
fund = np.where(np.isfinite(fund), fund, 0.0)
|
||||
tradeable = np.ones((T, N), bool)
|
||||
for j in range(N):
|
||||
idx = np.where(np.isfinite(close[:, j]))[0]
|
||||
if len(idx):
|
||||
tradeable[max(0, idx[-1] - 4):idx[-1] + 1, j] = False
|
||||
Reff = np.where(tradeable, R - fund, 0.0)
|
||||
dv = roll(np.mean, np.nan_to_num(qv), 30)
|
||||
vol = roll(np.std, R, cfg["vol_win"])
|
||||
w, univ = compute_weights(close, qv, days, cfg)
|
||||
gross = np.sum(w[:-1] * Reff[1:], axis=1)
|
||||
net = slippage_net(w, Reff, dv, vol, days, cfg)
|
||||
year = (1970 + days / 365.25).astype(int)
|
||||
v = validate(net, days, 30)
|
||||
gsr = sharpe_t(torch.tensor(gross, device=DEV, dtype=torch.float64))
|
||||
turn = float(np.mean(np.sum(np.abs(w[1:] - w[:-1]), axis=1))) * 100
|
||||
print(f"\n===== SURFER PoC BACKTEST — XS momentum (top{cfg['topk']}, {cfg['lookback']}d, weekly, smooth{cfg['smooth_span']}) =====")
|
||||
print(f"coins(ever)={N} days={T} universe/day~{int(univ.sum(1).mean())} cost-AUM=${cfg['cost_aum']/1e6:.0f}M turnover/day={turn:.1f}%")
|
||||
print(f"gross Sharpe {gsr:+.2f} | NET Sharpe {v['full']:+.2f} IS {v['is_']:+.2f} OOS {v['oos']:+.2f} CPCVmed {v['med']:+.2f} DSR {v['dsr']:.2f}")
|
||||
print(f"max-DD (sum-rets) {max_drawdown(net):+.2f}")
|
||||
py = [sharpe_t(torch.tensor(net[year[1:] == y], device=DEV, dtype=torch.float64)) if (year[1:] == y).sum() > 30 else float('nan') for y in range(2020, 2027)]
|
||||
print("per-year 2020..26: " + " ".join(f"{p:+.2f}" if not math.isnan(p) else " n/a" for p in py))
|
||||
print("capacity: " + " ".join(
|
||||
f"${a/1e6:.0f}M={sharpe_t(torch.tensor(slippage_net(w,Reff,dv,vol,days,{**cfg,'cost_aum':a}),device=DEV,dtype=torch.float64)):+.2f}"
|
||||
for a in [1e6, 5e6, 2e7, 5e7, 2e8]))
|
||||
# ---- VRP diversifier sleeve + combined two-sleeve book ----
|
||||
vrpb = vrp_book_series(cfg, _closes_from(syms, days, close, "BTCUSDT"),
|
||||
_closes_from(syms, days, close, "ETHUSDT"), refresh=False)
|
||||
mom_by = {int(days[1:][i]): net[i] for i in range(len(net))}
|
||||
common = sorted(set(vrpb) & set(mom_by))
|
||||
if common:
|
||||
mo = np.array([mom_by[d] for d in common]); vr = np.array([vrpb[d] for d in common])
|
||||
mu, vu, f = np.nanstd(mo), np.nanstd(vr), cfg["vrp_risk_frac"]
|
||||
comb = (mo / mu) * (1 - f) + (vr / vu) * f
|
||||
cy = (1970 + np.array(common) / 365.25).astype(int)
|
||||
T = lambda x: torch.tensor(x, device=DEV, dtype=torch.float64)
|
||||
print(f"VRP sleeve (tail-managed) Sharpe {sharpe_t(T(vr)):+.2f} corr-to-momentum {np.corrcoef(mo,vr)[0,1]:+.2f}")
|
||||
print(f"COMBINED BOOK (momentum {int((1-f)*100)}% + VRP {int(f*100)}% risk) Sharpe {sharpe_t(T(comb)):+.2f}")
|
||||
print("combined per-year: " + " ".join(
|
||||
f"{y}:{sharpe_t(T(comb[cy==y])):+.2f}" for y in range(2021, 2027) if (cy == y).sum() > 30))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- REGIME DIAGNOSTIC (overlay default OFF)
|
||||
def regime(cfg):
|
||||
syms, days, close, qv, fund = pit_sweep.load()
|
||||
T, N = close.shape
|
||||
R = np.zeros((T, N)); R[1:] = np.log(close)[1:] - np.log(close)[:-1]; R = np.where(np.isfinite(R), R, 0.0)
|
||||
fund = np.where(np.isfinite(fund), fund, 0.0)
|
||||
w, univ = compute_weights(close, qv, days, cfg)
|
||||
book = np.sum(w[:-1] * (np.where(np.isfinite(R), R, 0.0) - fund)[1:], axis=1) # daily book return
|
||||
lc = np.log(close)
|
||||
disp = np.array([np.nanstd(trailing(lc, cfg["lookback"])[t][univ[t]]) if univ[t].any() else np.nan for t in range(T)])
|
||||
mvol = np.nanmedian(roll(np.std, R, cfg["vol_win"]), axis=1)
|
||||
mtrend = np.array([np.nanmedian(np.abs(trailing(lc, cfg["lookback"])[t][univ[t]])) if univ[t].any() else np.nan for t in range(T)])
|
||||
feats = {"xs_dispersion": disp[:-1], "median_vol": mvol[:-1], "abs_trend": mtrend[:-1]}
|
||||
split = int(0.7 * len(book))
|
||||
print("\n===== REGIME DIAGNOSTIC — does a feature predict next-day book return? (overlay OFF until confirmed) =====")
|
||||
print(f"{'feature':>16} {'IC_all':>7} {'IC_IS':>7} {'IC_OOS':>7}")
|
||||
for nm, f in feats.items():
|
||||
f = f[1:]; b = book[1:] # feature_t -> book return_{t+1}
|
||||
m = np.isfinite(f) & np.isfinite(b)
|
||||
def ic(mask):
|
||||
mm = m & mask
|
||||
return float(np.corrcoef(f[mm], b[mm])[0, 1]) if mm.sum() > 50 else float("nan")
|
||||
idx = np.arange(len(f))
|
||||
print(f"{nm:>16} {ic(np.ones_like(m)):>+7.3f} {ic(idx < split):>+7.3f} {ic(idx >= split):>+7.3f}")
|
||||
print("Significant + IS/OOS-consistent IC => regime sizing worth building. Otherwise momentum runs flat-sized.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- LIVE (paper)
|
||||
def _get(u):
|
||||
return json.load(urllib.request.urlopen(urllib.request.Request(u, headers={"User-Agent": "curl/8"}), timeout=30))
|
||||
|
||||
|
||||
def _live_panel(cfg):
|
||||
info = _get("https://fapi.binance.com/fapi/v1/exchangeInfo")
|
||||
perps = {s["symbol"] for s in info["symbols"] if s.get("contractType") == "PERPETUAL"
|
||||
and s.get("quoteAsset") == "USDT" and s.get("status") == "TRADING"}
|
||||
tick = _get("https://fapi.binance.com/fapi/v1/ticker/24hr")
|
||||
vol = {t["symbol"]: float(t["quoteVolume"]) for t in tick if t["symbol"] in perps and t["symbol"].isascii()}
|
||||
syms = sorted(vol, key=lambda s: -vol[s])[:max(cfg["topk"] * 2, 60)] # wide net; signal picks top-K
|
||||
need = max(cfg["lookback"] + cfg["vol_win"], cfg["vrp_level_win"] + cfg["vrp_rise_k"],
|
||||
cfg["beta_win"] + cfg["lookback"]) + 12 # cover VRP gate + residual-momentum beta window
|
||||
closes, qvols, funds, keep = {}, {}, {}, []
|
||||
for s in syms:
|
||||
k = _get(f"https://fapi.binance.com/fapi/v1/klines?symbol={s}&interval=1d&limit={need}")
|
||||
if len(k) < need - 2:
|
||||
continue
|
||||
d = np.array([int(r[0]) // DAY_MS for r in k])
|
||||
closes[s] = (d, np.array([float(r[4]) for r in k]), np.array([float(r[7]) for r in k]))
|
||||
fr = _get(f"https://fapi.binance.com/fapi/v1/fundingRate?symbol={s}&limit=10")
|
||||
funds[s] = float(fr[-1]["fundingRate"]) * 3 if fr else 0.0 # ~daily funding (3x 8h)
|
||||
keep.append(s)
|
||||
time.sleep(0.05)
|
||||
days = np.array(sorted(set().union(*[set(closes[s][0].tolist()) for s in keep])))
|
||||
di = {int(v): i for i, v in enumerate(days.tolist())}
|
||||
T, N = len(days), len(keep)
|
||||
close = np.full((T, N), np.nan); qv = np.full((T, N), np.nan)
|
||||
for j, s in enumerate(keep):
|
||||
d, c, q = closes[s]
|
||||
for i in range(len(d)):
|
||||
close[di[int(d[i])], j] = c[i]; qv[di[int(d[i])], j] = q[i]
|
||||
return keep, days, close, qv, np.array([funds[s] for s in keep])
|
||||
|
||||
|
||||
def _load_state():
|
||||
if os.path.exists(STATE):
|
||||
return json.load(open(STATE))
|
||||
return dict(inception=None, last_mark_day=None, positions={}, prices={}, equity=1.0, log=[])
|
||||
|
||||
|
||||
def _save_state(st):
|
||||
tmp = STATE + ".tmp"
|
||||
json.dump(st, open(tmp, "w"), indent=1)
|
||||
os.replace(tmp, STATE)
|
||||
|
||||
|
||||
def paper(cfg):
|
||||
keep, days, close, qv, fund_now = _live_panel(cfg)
|
||||
w, univ = compute_weights(close, qv, days, cfg)
|
||||
target = {keep[j]: float(w[-1, j]) for j in range(len(keep)) if abs(w[-1, j]) > 1e-6}
|
||||
last_close = {keep[j]: float(close[-1, j]) for j in range(len(keep)) if np.isfinite(close[-1, j])}
|
||||
today = int(days[-1])
|
||||
st = _load_state()
|
||||
if st["inception"] is None:
|
||||
st["inception"] = today
|
||||
st.setdefault("vrp_equity", 1.0); st.setdefault("combined_equity", 1.0)
|
||||
if "vrp_vu" not in st:
|
||||
try:
|
||||
st["vrp_mu"], st["vrp_vu"] = _vrp_calib(cfg)
|
||||
except Exception:
|
||||
st["vrp_mu"], st["vrp_vu"] = 0.0, 1.0
|
||||
vrpb = vrp_book_series(cfg, _closes_from(keep, days, close, "BTCUSDT"),
|
||||
_closes_from(keep, days, close, "ETHUSDT"), refresh=True)
|
||||
vrp_today = vrpb.get(today, None)
|
||||
# 1) MARK both sleeves to latest prices (+ funding) since last mark
|
||||
if st["positions"] and st["last_mark_day"] != today:
|
||||
ret = 0.0
|
||||
for s, pos in st["positions"].items():
|
||||
p0 = st["prices"].get(s); p1 = last_close.get(s)
|
||||
if p0 and p1 and p0 > 0:
|
||||
ret += pos * (p1 / p0 - 1.0)
|
||||
ret -= pos * fund_now[keep.index(s)] if s in keep else 0.0 # long pays funding
|
||||
f = cfg["vrp_risk_frac"]
|
||||
scaled = (vrp_today / st["vrp_vu"]) * st["vrp_mu"] if (vrp_today is not None and st["vrp_vu"] > 0) else 0.0
|
||||
comb_ret = (1 - f) * ret + f * scaled # VRP scaled to momentum vol, f risk
|
||||
st["equity"] *= (1.0 + ret)
|
||||
st["vrp_equity"] *= (1.0 + scaled)
|
||||
st["combined_equity"] *= (1.0 + comb_ret)
|
||||
st["log"].append({"day": today, "mom_ret": round(ret, 6), "vrp_ret": round(scaled, 6),
|
||||
"comb_ret": round(comb_ret, 6), "combined": round(st["combined_equity"], 5)})
|
||||
# 2) REBALANCE on the weekly boundary
|
||||
rebal = (st["last_mark_day"] is None) or (today % cfg["rebal_k"] == 0) # fixed weekly phase (Thursdays)
|
||||
trades = []
|
||||
if rebal:
|
||||
cur = st["positions"]
|
||||
allk = set(cur) | set(target)
|
||||
for s in sorted(allk):
|
||||
d = target.get(s, 0.0) - cur.get(s, 0.0)
|
||||
if abs(d) > 1e-4:
|
||||
trades.append({"sym": s, "side": "BUY" if d > 0 else "SELL", "dweight": round(d, 4)})
|
||||
st["positions"] = target
|
||||
st["prices"] = last_close
|
||||
st["last_mark_day"] = today
|
||||
_save_state(st)
|
||||
print(f"\n===== SURFER PoC PAPER — day {today} (universe {len(keep)} perps) =====")
|
||||
print(f"momentum {st['equity']:.4f} | VRP {st['vrp_equity']:.4f} | COMBINED BOOK {st['combined_equity']:.4f} "
|
||||
f"(inception day {st['inception']}, {len(st['log'])} marks)")
|
||||
print(f"VRP today: {'flat (gated)' if (vrp_today is not None and abs(vrp_today)<1e-9) else ('%.5f'%vrp_today if vrp_today is not None else 'n/a')} "
|
||||
f"rebalance: {rebal} intended trades: {len(trades)}")
|
||||
longs = sorted([(s, x) for s, x in target.items() if x > 0], key=lambda z: -z[1])[:8]
|
||||
shorts = sorted([(s, x) for s, x in target.items() if x < 0], key=lambda z: z[1])[:8]
|
||||
print("top longs : " + ", ".join(f"{s} {x:+.3f}" for s, x in longs))
|
||||
print("top shorts: " + ", ".join(f"{s} {x:+.3f}" for s, x in shorts))
|
||||
for t in trades[:12]:
|
||||
print(f" {t['side']:>4} {t['sym']:>12} dW {t['dweight']:+.4f}")
|
||||
print("(paper only — no real orders. Re-run weekly to forward-test edge persistence.)")
|
||||
|
||||
|
||||
def status(cfg):
|
||||
st = _load_state()
|
||||
print(f"\n===== SURFER PoC STATUS =====")
|
||||
if st["inception"] is None:
|
||||
print("no paper state yet — run: python scripts/surfer/surfer_poc.py paper"); return
|
||||
print(f"inception day {st['inception']} last mark {st['last_mark_day']} marks {len(st['log'])}")
|
||||
print(f"equity: momentum {st['equity']:.4f} | VRP {st.get('vrp_equity',1.0):.4f} | COMBINED {st.get('combined_equity',1.0):.4f}")
|
||||
if st["log"]:
|
||||
def srp(key):
|
||||
r = np.array([m[key] for m in st["log"] if key in m])
|
||||
return r.mean() / (r.std() + 1e-9) * math.sqrt(252) if len(r) > 5 else float("nan")
|
||||
print(f"annual-ish Sharpe (live): momentum {srp('mom_ret'):+.2f} | VRP {srp('vrp_ret'):+.2f} | COMBINED {srp('comb_ret'):+.2f}")
|
||||
print(f"current book: {len([p for p in st['positions'].values() if abs(p)>1e-6])} positions")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("mode", choices=["backtest", "regime", "paper", "status"])
|
||||
a = ap.parse_args()
|
||||
{"backtest": backtest, "regime": regime, "paper": paper, "status": status}[a.mode](CFG)
|
||||
111
scripts/surfer/vrp_diversifier.py
Normal file
111
scripts/surfer/vrp_diversifier.py
Normal file
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Crypto volatility-risk-premium (VRP) as a diversifier to the momentum book.
|
||||
|
||||
VRP = implied vol (Deribit DVOL, BTC+ETH) systematically exceeds realized vol; a vol-seller
|
||||
harvests it. Short-variance daily P&L proxy: vrp_t = (DVOL_{t-1}/100)^2/365 - r_t^2
|
||||
(collect implied daily variance, pay realized squared return). Approximate (no Greeks) but
|
||||
captures the premium's edge + correlation — enough for a diversifier CHECK.
|
||||
|
||||
Then the prize: Sharpe of VRP, per-year (incl. crash years), worst day, CORRELATION to the
|
||||
crypto momentum book, and the combined two-premium book. A real diversifier needs Sharpe>=~0.4
|
||||
AND low correlation. Honest: short-vol has negative skew -> Sharpe FLATTERS it; watch the tails.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import pit_sweep # noqa: E402
|
||||
from surfer_poc import compute_weights, CFG # noqa: E402
|
||||
from signal_sweep import sharpe_t # noqa: E402
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
DAY_MS = 86_400_000
|
||||
DVOL_DIR = "data/surfer/dvol"
|
||||
|
||||
|
||||
def _get(u):
|
||||
return json.load(urllib.request.urlopen(urllib.request.Request(u, headers={"User-Agent": "curl/8"}), timeout=30))
|
||||
|
||||
|
||||
def dvol(ccy):
|
||||
os.makedirs(DVOL_DIR, exist_ok=True)
|
||||
cache = f"{DVOL_DIR}/{ccy}.json"
|
||||
if os.path.exists(cache):
|
||||
return {int(k): v for k, v in json.load(open(cache)).items()}
|
||||
out, end, start = {}, 1780704000000, 1577836800000
|
||||
for _ in range(8):
|
||||
u = f"https://www.deribit.com/api/v2/public/get_volatility_index_data?currency={ccy}&start_timestamp={start}&end_timestamp={end}&resolution=86400"
|
||||
d = _get(u).get("result", {}).get("data", [])
|
||||
if not d:
|
||||
break
|
||||
for row in d:
|
||||
out[int(row[0]) // DAY_MS] = float(row[4])
|
||||
end = d[0][0] - 1
|
||||
if len(d) < 1000:
|
||||
break
|
||||
json.dump({str(k): v for k, v in out.items()}, open(cache, "w"))
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
syms, cdays, cclose, cqv, cfund = pit_sweep.load()
|
||||
idx = {s: j for j, s in enumerate(syms)}
|
||||
# realized daily returns for BTC/ETH
|
||||
def closes(sym):
|
||||
j = idx[sym]; c = cclose[:, j]
|
||||
return {int(cdays[t]): c[t] for t in range(len(cdays)) if np.isfinite(c[t])}
|
||||
btc, eth = closes("BTCUSDT"), closes("ETHUSDT")
|
||||
dv_btc, dv_eth = dvol("BTC"), dvol("ETH")
|
||||
print(f"DVOL coverage: BTC {len(dv_btc)}d ({min(dv_btc)}..{max(dv_btc)}), ETH {len(dv_eth)}d")
|
||||
|
||||
def vrp_series(px, dv):
|
||||
days = sorted(set(px) & set(dv))
|
||||
pnl = {}
|
||||
for i in range(1, len(days)):
|
||||
d0, d1 = days[i - 1], days[i]
|
||||
if px[d0] > 0 and px[d1] > 0:
|
||||
r = math.log(px[d1] / px[d0])
|
||||
iv = dv[d0] / 100.0
|
||||
pnl[d1] = (iv * iv) / 365.0 - r * r # short-variance daily P&L
|
||||
return pnl
|
||||
vb, ve = vrp_series(btc, dv_btc), vrp_series(eth, dv_eth)
|
||||
alld = sorted(set(vb) | set(ve))
|
||||
vrp = np.array([np.nanmean([vb.get(d, np.nan), ve.get(d, np.nan)]) for d in alld])
|
||||
alld = np.array(alld)
|
||||
year = (1970 + alld / 365.25).astype(int)
|
||||
sr = sharpe_t(torch.tensor(vrp, device=DEV, dtype=torch.float64))
|
||||
print(f"\n===== CRYPTO VRP (short-vol BTC+ETH) =====")
|
||||
print(f"days={len(vrp)} Sharpe {sr:+.2f} worst-day {np.nanmin(vrp)/np.nanstd(vrp):+.1f}σ skew {float(((vrp-np.nanmean(vrp))**3).mean()/np.nanstd(vrp)**3):+.2f}")
|
||||
print("per-year: " + " ".join(f"{y}:{sharpe_t(torch.tensor(vrp[year==y],device=DEV,dtype=torch.float64)):+.2f}" for y in range(2021, 2027) if (year == y).sum() > 30))
|
||||
|
||||
# crypto momentum book
|
||||
cR = np.zeros_like(cclose); cR[1:] = np.log(cclose)[1:] - np.log(cclose)[:-1]; cR = np.where(np.isfinite(cR), cR, 0.0)
|
||||
cf = np.where(np.isfinite(cfund), cfund, 0.0)
|
||||
cw, _ = compute_weights(cclose, cqv, cdays, CFG)
|
||||
mom = np.sum(cw[:-1] * (cR - cf)[1:], axis=1)
|
||||
mom_by_day = {int(cdays[1:][i]): mom[i] for i in range(len(mom))}
|
||||
common = sorted(set(alld.tolist()) & set(mom_by_day))
|
||||
va = np.array([vrp[np.where(alld == d)[0][0]] for d in common])
|
||||
ma = np.array([mom_by_day[d] for d in common])
|
||||
m = np.isfinite(va) & np.isfinite(ma)
|
||||
corr = float(np.corrcoef(va[m], ma[m])[0, 1])
|
||||
sv, sm = np.std(va[m]), np.std(ma[m])
|
||||
comb = ((1 / sv) * va[m] + (1 / sm) * ma[m]) / (1 / sv + 1 / sm)
|
||||
svr = sharpe_t(torch.tensor(va[m], device=DEV, dtype=torch.float64))
|
||||
smr = sharpe_t(torch.tensor(ma[m], device=DEV, dtype=torch.float64))
|
||||
scr = sharpe_t(torch.tensor(comb, device=DEV, dtype=torch.float64))
|
||||
print(f"\n===== DIVERSIFIER CHECK — VRP vs crypto-momentum (overlap {m.sum()} days) =====")
|
||||
print(f"VRP Sharpe {svr:+.2f} momentum Sharpe {smr:+.2f} CORRELATION {corr:+.2f} combined {scr:+.2f}")
|
||||
opt = math.sqrt(max(svr, 0)**2 + max(smr, 0)**2)
|
||||
print(f"optimal-weight combined (uncorrelated approx) ~ {opt:.2f} (vs momentum alone {smr:+.2f})")
|
||||
print("\nVERDICT: VRP Sharpe>=~0.4 + low |corr| + combined>momentum-alone = genuine diversifier (mind the short-vol tail).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
118
scripts/surfer/vrp_tail.py
Normal file
118
scripts/surfer/vrp_tail.py
Normal file
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tail-managed crypto VRP — does 'don't sell vol into a storm' fix the −21σ skew AND the 2025-26 decay?
|
||||
|
||||
Pre-registered tail rules (point-in-time, gates use DVOL/return info up to t-1 only):
|
||||
baseline — full short-vol always (the +1.03 / -21σ version)
|
||||
dvol_rising — flat when implied vol is rising over 5d (don't sell into a spike)
|
||||
dvol_level — full size when DVOL < trailing-60d median (calm), half when elevated
|
||||
combo — dvol_level AND not rising
|
||||
loss_cap — idealized: truncate each day's loss at -3σ (stop/protection reference)
|
||||
vol_target — scale exposure to constant risk (inverse trailing P&L vol)
|
||||
Judge: Sharpe, per-year (esp 2025-26), skew, worst-day. Best -> diversifier check vs momentum.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import pit_sweep # noqa: E402
|
||||
from surfer_poc import compute_weights, CFG # noqa: E402
|
||||
from signal_sweep import sharpe_t # noqa: E402
|
||||
from vrp_diversifier import dvol # noqa: E402 (cached DVOL fetch)
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
|
||||
|
||||
def per_ccy(px, dv):
|
||||
days = np.array(sorted(set(px) & set(dv)))
|
||||
iv = np.array([dv[int(d)] / 100.0 for d in days])
|
||||
r = np.full(len(days), np.nan)
|
||||
for i in range(1, len(days)):
|
||||
if px[int(days[i])] > 0 and px[int(days[i - 1])] > 0:
|
||||
r[i] = math.log(px[int(days[i])] / px[int(days[i - 1])])
|
||||
raw = np.full(len(days), np.nan)
|
||||
raw[1:] = (iv[:-1] ** 2) / 365.0 - r[1:] ** 2 # short-var daily P&L (iv lagged)
|
||||
return days, iv, r, raw
|
||||
|
||||
|
||||
def gates(iv, raw):
|
||||
n = len(iv)
|
||||
rising = np.zeros(n) # 1 if NOT rising (ok to sell)
|
||||
for i in range(1, n):
|
||||
rising[i] = 0.0 if (i >= 6 and iv[i - 1] > iv[i - 6]) else 1.0
|
||||
level = np.zeros(n) # 1 calm, 0.5 elevated (vs trailing median)
|
||||
for i in range(1, n):
|
||||
win = iv[max(0, i - 61):i - 1]
|
||||
level[i] = 1.0 if (len(win) > 10 and iv[i - 1] < np.median(win)) else 0.5
|
||||
vt = np.ones(n) # vol-target on raw P&L
|
||||
for i in range(31, n):
|
||||
s = np.nanstd(raw[i - 30:i])
|
||||
vt[i] = min(1.0, (np.nanstd(raw[~np.isnan(raw)]) / s)) if s > 0 else 1.0
|
||||
return {"baseline": np.ones(n), "dvol_rising": rising, "dvol_level": level,
|
||||
"combo": rising * level, "vol_target": vt}
|
||||
|
||||
|
||||
def main():
|
||||
syms, cdays, cclose, cqv, cfund = pit_sweep.load()
|
||||
idx = {s: j for j, s in enumerate(syms)}
|
||||
|
||||
def closes(sym):
|
||||
j = idx[sym]; c = cclose[:, j]
|
||||
return {int(cdays[t]): c[t] for t in range(len(cdays)) if np.isfinite(c[t])}
|
||||
series = {c: per_ccy(closes(c + "USDT"), dvol(c)) for c in ["BTC", "ETH"]}
|
||||
|
||||
# build per-rule pooled daily P&L across BTC+ETH (aligned by day)
|
||||
def rule_pnl(rule, cap=False):
|
||||
bag = {}
|
||||
for c, (days, iv, r, raw) in series.items():
|
||||
g = gates(iv, raw)[rule]
|
||||
p = g * raw
|
||||
if cap:
|
||||
sd = np.nanstd(raw)
|
||||
p = np.maximum(p, -3 * sd)
|
||||
for i in range(len(days)):
|
||||
bag.setdefault(int(days[i]), []).append(p[i])
|
||||
dd = np.array(sorted(bag))
|
||||
return dd, np.array([np.nanmean(bag[int(d)]) for d in dd])
|
||||
|
||||
rules = ["baseline", "dvol_rising", "dvol_level", "combo", "vol_target"]
|
||||
print("\n===== TAIL-MANAGED CRYPTO VRP =====")
|
||||
print(f"{'rule':>14} {'Sharpe':>7} {'skew':>6} {'worstσ':>7} | per-year 2021..26")
|
||||
results = {}
|
||||
for rule in rules + ["loss_cap"]:
|
||||
dd, p = rule_pnl("baseline" if rule == "loss_cap" else rule, cap=(rule == "loss_cap"))
|
||||
pf = p[np.isfinite(p)]
|
||||
sr = sharpe_t(torch.tensor(p, device=DEV, dtype=torch.float64))
|
||||
sk = float(((pf - pf.mean()) ** 3).mean() / (pf.std() ** 3 + 1e-12))
|
||||
worst = float(np.nanmin(p) / (np.nanstd(p) + 1e-12))
|
||||
yr = (1970 + dd / 365.25).astype(int)
|
||||
py = " ".join(f"{y}:{sharpe_t(torch.tensor(p[yr==y],device=DEV,dtype=torch.float64)):+.2f}" for y in range(2021, 2027) if (yr == y).sum() > 30)
|
||||
print(f"{rule:>14} {sr:>+7.2f} {sk:>+6.2f} {worst:>+7.1f} | {py}")
|
||||
results[rule] = (dd, p, sr, sk)
|
||||
|
||||
# diversifier check for combo (the principled don't-sell-into-storm rule)
|
||||
dd, p, _, _ = results["combo"]
|
||||
cR = np.zeros_like(cclose); cR[1:] = np.log(cclose)[1:] - np.log(cclose)[:-1]; cR = np.where(np.isfinite(cR), cR, 0.0)
|
||||
cf = np.where(np.isfinite(cfund), cfund, 0.0)
|
||||
cw, _ = compute_weights(cclose, cqv, cdays, CFG)
|
||||
mom = np.sum(cw[:-1] * (cR - cf)[1:], axis=1)
|
||||
mbd = {int(cdays[1:][i]): mom[i] for i in range(len(mom))}
|
||||
common = sorted(set(dd.tolist()) & set(mbd))
|
||||
va = np.array([p[np.where(dd == d)[0][0]] for d in common]); ma = np.array([mbd[d] for d in common])
|
||||
m = np.isfinite(va) & np.isfinite(ma)
|
||||
corr = float(np.corrcoef(va[m], ma[m])[0, 1])
|
||||
sv, sm = np.std(va[m]), np.std(ma[m])
|
||||
comb = ((1 / sv) * va[m] + (1 / sm) * ma[m]) / (1 / sv + 1 / sm)
|
||||
cyr = (1970 + np.array(common) / 365.25).astype(int)[m]
|
||||
print(f"\n===== DIVERSIFIER CHECK (combo tail-managed VRP vs momentum, {m.sum()}d) =====")
|
||||
print(f"VRP {sharpe_t(torch.tensor(va[m],device=DEV,dtype=torch.float64)):+.2f} momentum {sharpe_t(torch.tensor(ma[m],device=DEV,dtype=torch.float64)):+.2f} CORR {corr:+.2f} combined {sharpe_t(torch.tensor(comb,device=DEV,dtype=torch.float64)):+.2f}")
|
||||
print("combined per-year: " + " ".join(f"{y}:{sharpe_t(torch.tensor(comb[cyr==y],device=DEV,dtype=torch.float64)):+.2f}" for y in range(2021, 2027) if (cyr == y).sum() > 30))
|
||||
print("\nVERDICT: tail rule restores 2025-26 to positive + skew less extreme + combined>momentum = deployable diversifier.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user