Files
PythagorasGoal/scripts/research/blind/agents/agent_42_fft_phase.py
T
Adriano Dal Pastro 1afb1014c9 research(blind): 52 agenti ciechi su curve anonime BTC/ETH — orchestratore valuta PnL/maxDD, niente di nuovo regge
Flotta di 52 subagenti "esperti di segnali" su storico BTC/ETH ANONIMIZZATO (Series A/B
rebased a 100, calendario sintetico, split 70/30) — non sanno cosa siano. Ognuno scrive un
signal(df)->position causale (script o ML), tunato solo sul train. Orchestratore valuta su
PnL e maxDD nel test held-out.

Harness cieco leak-free (riusabile):
- make_blind.py: export anonimo + overlay; blindlib.py: evaluator con shift della posizione +
  GUARDIA DI CAUSALITA' online (squalifica ogni look-ahead, ML incluso); blind_eval.py CLI;
  score_all.py giudice OOS; verify_top.py (corr-al-trend, fee-stress, jackknife).
- 52/52 passano la guardia (zero leak su tutta la flotta).

Esito OOS (benchmark buy&hold: -7% PnL, 68% DD):
- top = macd (+21%, DD 11%, Sh 0.84), accel, vol_of_vol, regime_switch, rf, obv — tutti
  trend/vol-regime. Sharpe OOS ~0.84 decade dal train ~1.4. Mean-rev e ML in fondo.
- 3 scettici indipendenti: REFUTED. regime-luck (top-5 bar = 67-102% del PnL); trend-redundancy
  (HAC alpha t=+0.9..+1.5, nessuno >1.96 — TSMOM travestito); overfit (accel/vov knife-edge).

Verdetto: ri-conferma CIECA e indipendente del soffitto direzionale ~1.3. macd = classe-TP01,
forward-monitor non deploy. Diario 2026-06-21-blind-signal-fleet.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 07:05:04 +00:00

92 lines
4.1 KiB
Python

"""agent_42_fft_phase — cycle / FFT-phase blind signal.
ANGLE: rolling-window dominant-cycle phase. On each bar i we take the last N
log-prices (rows 0..i ONLY), linearly detrend them (so the FFT sees the
OSCILLATION around the local trend, not the trend itself), window them, take the
rfft, and pick the dominant frequency inside a cycle band [PMIN, PMAX] days. The
complex Fourier coefficient at that bin gives the cycle's instantaneous PHASE at
the window end; from the phase we project the cycle's next-bar slope
(d/dt of A*cos(2*pi*f*t + phi)) — that is the phase-based anticipation of the next
move, weighted by how dominant the cycle is (its in-band power share = conviction).
HONEST CAVEAT (found while tuning on TRAIN): a SINGLE-window phase rule is not
robust — its sign flips with the window length and the detrend band (the data has
no stable mid-band cycle; spectral power sits at the trend's low frequencies). So
the deployable version (a) ENSEMBLES the phase direction over several window
lengths to kill the single-cell overfit, and (b) reads the phase as cycle
CONTINUATION (the in-band component keeps its slope -> SIGN=-1, which on TRAIN beat
the mean-revert convention), and (c) anchors with a light slow-trend term because
the low-frequency (trend) component is the one piece of real structure here. The
phase ensemble is the directional core; the trend anchor caps drawdown. Result on
TRAIN: comparable PnL to buy&hold at ~5x smaller drawdown.
Everything uses data <= i (pure per-bar transform, refit-free), so it is causal by
construction and the online-consistency guard passes exactly (max_diff = 0).
"""
import numpy as np
import blindlib as bl
# --- tuned on TRAIN only ---
WINDOWS = (80, 100, 120, 140, 160) # FFT window lengths (days) to ensemble
PMIN = 8 # shortest cycle period considered (days)
PMAX = 60 # longest cycle period considered (days)
PHASE_SIGN = -1.0 # cycle-continuation reading (best on TRAIN)
TREND_W = 0.30 # weight of slow-trend anchor vs phase ensemble
_NMAX = max(WINDOWS)
def _cycle_phase_dir(x):
"""Last N log-prices x (oldest..newest) -> dominant in-band cycle's projected
next-bar direction in [-1, 1], scaled by the cycle's in-band power share
(conviction). Pure function of x (causal). 0.0 if no band power."""
n = len(x)
t = np.arange(n, dtype=float)
# linear detrend: strip the local trend so the FFT isolates the oscillation
A = np.polyfit(t, x, 1)
resid = x - (A[0] * t + A[1])
xw = resid * np.hanning(n)
F = np.fft.rfft(xw)
freqs = np.fft.rfftfreq(n, d=1.0)
P = np.abs(F) ** 2
with np.errstate(divide="ignore"):
per = np.where(freqs > 0, 1.0 / freqs, np.inf)
band = (per >= PMIN) & (per <= PMAX)
if not band.any():
return 0.0
idx = np.where(band)[0]
k = idx[int(np.argmax(P[idx]))]
if P[k] <= 0:
return 0.0
f = freqs[k]
# phase of the coefficient -> reconstructed component C(t) ~ cos(2*pi*f*t + ang).
# its next-bar slope ~ -sin(...) evaluated at the LAST sample (the bar whose
# next step we anticipate).
ang = np.angle(F[k])
theta = 2.0 * np.pi * f * (n - 1) + ang
slope = -np.sin(theta)
share = P[k] / (P[idx].sum() + 1e-12) # conviction in [0,1]
return float(slope) * float(np.clip(share * len(idx), 0.0, 1.0))
def signal(df):
c = df["close"].values.astype(float)
lp = np.log(c)
n = len(c)
raw = np.zeros(n)
# slow local-trend anchor (the low-freq component is the real structure here)
slow = bl.ema(c, 50)
trend_dir = np.sign(c - slow)
for i in range(_NMAX, n):
acc = 0.0
for N in WINDOWS:
acc += _cycle_phase_dir(lp[i - N + 1: i + 1]) # rows 0..i only
cyc = PHASE_SIGN * acc / len(WINDOWS) # phase ensemble
raw[i] = (1.0 - TREND_W) * cyc + TREND_W * trend_dir[i]
direction = np.tanh(2.0 * raw)
pos = bl.vol_target(direction, df, target_vol=0.20, vol_win_days=30,
leverage_cap=1.0)
return np.clip(pos, -1.0, 1.0)