4ac87ab385
Harness shape_lab (analog kNN causale, no look-ahead verificato) + 5 ricerche parallele. 4/5 famiglie = RUMORE (confermano dominanza mean-reversion): - analog kNN forma grezza: solo BTC-overfit, non robusto >=2 asset - encoding candele UP/DOWN/DOJI + body/shadow: hit-rate ~50%, muore a fee - DTW + template geometrici: DTW peggiora euclidea; template overfit - PIP/pivot/zig-zag: 0/48 config robuste 1/5 = EDGE REALE: ML walk-forward (LogisticRegression) sulle feature di forma. BTC logit W24H12 th0.58: FULL +219% / OOS +42% / Sharpe 2.72 / 8-9 anni+ / regge fee 0.20% RT (+60/+26). Causalita' verificata. Da validare a fondo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
258 lines
10 KiB
Python
258 lines
10 KiB
Python
"""Harness ONESTO per pattern *di forma* -> previsione dell'andamento successivo.
|
|
|
|
Idea (analog forecasting / nearest-neighbour sulla FORMA del prezzo):
|
|
- a ogni barra i guardo la forma recente W (closes z-normalizzati fino a close[i]);
|
|
- cerco nel PASSATO le K finestre piu' simili la cui forma si era gia' conclusa
|
|
*e* il cui esito a H barre era gia' noto PRIMA di i (nessun look-ahead);
|
|
- prevedo la direzione dei prossimi H barre = segno del rendimento medio degli
|
|
analoghi; entro a close[i] se l'accordo fra analoghi e' abbastanza forte.
|
|
|
|
Vincoli anti-look-ahead (gli stessi della famiglia squeeze fallita):
|
|
- la forma usa SOLO closes fino a close[i];
|
|
- la libreria di analoghi a decisione i contiene solo finestre che terminano in
|
|
e con e+H <= i-1 -> il loro esito e' interamente realizzato *prima* della barra i;
|
|
- ingresso eseguibile a close[i]; exit TP/SL intrabar o time-limit H.
|
|
|
|
Riusa l'engine netto-fee + OOS di explore_lab (simulate/evaluate/robust).
|
|
KDTree ricostruito ogni `rebuild` barre (causale): query O(log N), niente O(N^2).
|
|
|
|
Asset: ADA BNB BTC DOGE ETH LTC SOL XRP (1h, 15m; BTC/ETH anche 5m).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
from numpy.lib.stride_tricks import sliding_window_view
|
|
from scipy.spatial import cKDTree
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
from scripts.analysis.explore_lab import ( # noqa: E402
|
|
get_df, evaluate, robust, simulate, atr, ema, rsi, _dt, OOS_FRAC, ASSETS, FEE_RT,
|
|
)
|
|
|
|
|
|
# --------------------------- forma normalizzata ---------------------------
|
|
def znorm_windows(close: np.ndarray, W: int) -> tuple[np.ndarray, np.ndarray]:
|
|
"""Matrice delle finestre z-normalizzate per FORMA.
|
|
|
|
Ritorna (M, ends) dove M[k] = z-norm(close[e-W+1 .. e]) e ends[k] = e.
|
|
Z-norm per forma: (w - media)/std -> invariante a livello e scala -> confronto
|
|
sulla sola morfologia. Le finestre piatte (std=0) hanno norm tutta a 0.
|
|
"""
|
|
if len(close) < W:
|
|
return np.empty((0, W)), np.empty(0, dtype=int)
|
|
wins = sliding_window_view(close, W) # (N-W+1, W), wins[k] = close[k..k+W-1]
|
|
mu = wins.mean(axis=1, keepdims=True)
|
|
sd = wins.std(axis=1, keepdims=True)
|
|
sd = np.where(sd == 0, 1.0, sd)
|
|
M = (wins - mu) / sd
|
|
ends = np.arange(W - 1, len(close)) # finestra k termina in e = k+W-1
|
|
return M, ends
|
|
|
|
|
|
def fwd_return(close: np.ndarray, H: int) -> np.ndarray:
|
|
"""Rendimento forward a H barre per ogni indice: (close[i+H]-close[i])/close[i].
|
|
NaN dove i+H esce dai dati (non usabile come esito)."""
|
|
out = np.full(len(close), np.nan)
|
|
out[: len(close) - H] = (close[H:] - close[:-H]) / close[:-H]
|
|
return out
|
|
|
|
|
|
# --------------------------- analog forecasting causale ---------------------------
|
|
def analog_entries(df, W=24, H=12, K=50, rebuild=250, min_lib=800,
|
|
agree=0.60, conf_atr=0.0, tp_atr=None, sl_atr=None,
|
|
trend_max=None, ema_long=200) -> list[dict]:
|
|
"""Entries da nearest-neighbour sulla FORMA (causale, no look-ahead).
|
|
|
|
W: lunghezza finestra-forma. H: orizzonte previsione (= max_bars). K: n. analoghi.
|
|
rebuild: ogni quante barre si ricostruisce il KDTree (libreria cresce nel tempo).
|
|
min_lib: barre minime di storia prima di iniziare a operare.
|
|
agree: frazione minima di analoghi concordi sul segno per entrare (>0.5).
|
|
conf_atr: soglia |rendimento medio analoghi| in multipli di ATR-equivalente (0=off).
|
|
tp_atr/sl_atr: take-profit/stop in multipli di ATR (None = solo time-limit H).
|
|
trend_max: salta se |close-EMA(ema_long)|/ATR14 > trend_max (filtro trend, None=off).
|
|
"""
|
|
close = df["close"].values
|
|
n = len(close)
|
|
a = atr(df, 14)
|
|
M, ends = znorm_windows(close, W) # forme z-norm e indice di fine
|
|
end_pos = {int(e): k for k, e in enumerate(ends)}
|
|
fr = fwd_return(close, H) # esito H-barre per ogni indice
|
|
el = None
|
|
if trend_max is not None:
|
|
el = ema(close, ema_long)
|
|
|
|
entries: list[dict] = []
|
|
tree = None
|
|
lib_idx = None # indici e (fine finestra) nella libreria
|
|
next_rebuild = 0
|
|
|
|
for i in range(min_lib, n - 1):
|
|
# libreria causale: finestre la cui forma E il cui esito H sono < i
|
|
if tree is None or i >= next_rebuild:
|
|
eligible = ends[(ends <= i - 1 - H) & (ends >= W - 1)]
|
|
# esito noto e finito (fr non-NaN garantito da e+H <= i-1 < n)
|
|
eligible = eligible[~np.isnan(fr[eligible])]
|
|
if len(eligible) < max(K * 3, 200):
|
|
next_rebuild = i + rebuild
|
|
continue
|
|
tree = cKDTree(M[[end_pos[int(e)] for e in eligible]])
|
|
lib_idx = eligible
|
|
next_rebuild = i + rebuild
|
|
|
|
if tree is None:
|
|
continue
|
|
q = M[end_pos[i]]
|
|
if not np.isfinite(q).all():
|
|
continue
|
|
kk = min(K, len(lib_idx))
|
|
_, nn = tree.query(q, k=kk)
|
|
nn = np.atleast_1d(nn)
|
|
outs = fr[lib_idx[nn]] # rendimenti H-barre degli analoghi
|
|
outs = outs[~np.isnan(outs)]
|
|
if len(outs) < 5:
|
|
continue
|
|
mean_out = float(outs.mean())
|
|
d = 1 if mean_out > 0 else -1
|
|
frac = float(np.mean(np.sign(outs) == d))
|
|
if frac < agree:
|
|
continue
|
|
if conf_atr > 0:
|
|
if not (abs(mean_out) * close[i] >= conf_atr * a[i]):
|
|
continue
|
|
if trend_max is not None and a[i] > 0:
|
|
if abs(close[i] - el[i]) / a[i] > trend_max:
|
|
continue
|
|
e = {"i": i, "d": d, "max_bars": H}
|
|
if tp_atr is not None and a[i] > 0:
|
|
e["tp"] = close[i] + d * tp_atr * a[i]
|
|
if sl_atr is not None and a[i] > 0:
|
|
e["sl"] = close[i] - d * sl_atr * a[i]
|
|
entries.append(e)
|
|
return entries
|
|
|
|
|
|
# --------------------------- kNN grezzo cacheable (perf) ---------------------------
|
|
def analog_signals(df, W=24, H=12, K=50, rebuild=250, min_lib=800) -> dict:
|
|
"""Calcola UNA volta il forecast kNN grezzo per barra (causale), riusabile da
|
|
piu' filtri (agree/conf_atr/trend/tp/sl) senza ri-eseguire la query costosa.
|
|
|
|
Ritorna dict con array allineati per le barre che hanno un forecast valido:
|
|
i : indice barra (ingresso eseguibile a close[i])
|
|
mean_out : rendimento H-barre medio degli analoghi
|
|
frac : frazione di analoghi concordi col segno di mean_out (>=0.5)
|
|
d : segno previsto (+1/-1)
|
|
Identico, riga per riga, alla logica di analog_entries (stessa libreria causale,
|
|
stessa query, stessa soglia len(outs)>=5) ma SENZA i filtri di selezione.
|
|
"""
|
|
close = df["close"].values
|
|
n = len(close)
|
|
M, ends = znorm_windows(close, W)
|
|
end_pos = {int(e): k for k, e in enumerate(ends)}
|
|
fr = fwd_return(close, H)
|
|
|
|
out_i: list[int] = []
|
|
out_mean: list[float] = []
|
|
out_frac: list[float] = []
|
|
out_d: list[int] = []
|
|
tree = None
|
|
lib_idx = None
|
|
next_rebuild = 0
|
|
|
|
for i in range(min_lib, n - 1):
|
|
if tree is None or i >= next_rebuild:
|
|
eligible = ends[(ends <= i - 1 - H) & (ends >= W - 1)]
|
|
eligible = eligible[~np.isnan(fr[eligible])]
|
|
if len(eligible) < max(K * 3, 200):
|
|
next_rebuild = i + rebuild
|
|
continue
|
|
tree = cKDTree(M[[end_pos[int(e)] for e in eligible]])
|
|
lib_idx = eligible
|
|
next_rebuild = i + rebuild
|
|
if tree is None:
|
|
continue
|
|
q = M[end_pos[i]]
|
|
if not np.isfinite(q).all():
|
|
continue
|
|
kk = min(K, len(lib_idx))
|
|
_, nn = tree.query(q, k=kk)
|
|
nn = np.atleast_1d(nn)
|
|
outs = fr[lib_idx[nn]]
|
|
outs = outs[~np.isnan(outs)]
|
|
if len(outs) < 5:
|
|
continue
|
|
mean_out = float(outs.mean())
|
|
d = 1 if mean_out > 0 else -1
|
|
frac = float(np.mean(np.sign(outs) == d))
|
|
out_i.append(i); out_mean.append(mean_out); out_frac.append(frac); out_d.append(d)
|
|
|
|
return {
|
|
"i": np.asarray(out_i, dtype=int),
|
|
"mean_out": np.asarray(out_mean, dtype=float),
|
|
"frac": np.asarray(out_frac, dtype=float),
|
|
"d": np.asarray(out_d, dtype=int),
|
|
"H": H,
|
|
}
|
|
|
|
|
|
def entries_from_signals(df, sig: dict, agree=0.60, conf_atr=0.0,
|
|
tp_atr=None, sl_atr=None, trend_max=None, ema_long=200) -> list[dict]:
|
|
"""Applica i filtri di selezione al forecast grezzo di analog_signals (cheap).
|
|
Risultato identico ad analog_entries con gli stessi parametri (stesso W/H/K/rebuild
|
|
usati per costruire sig)."""
|
|
close = df["close"].values
|
|
a = atr(df, 14)
|
|
H = sig["H"]
|
|
el = ema(close, ema_long) if trend_max is not None else None
|
|
entries: list[dict] = []
|
|
for k in range(len(sig["i"])):
|
|
i = int(sig["i"][k]); d = int(sig["d"][k])
|
|
if sig["frac"][k] < agree:
|
|
continue
|
|
if conf_atr > 0 and not (abs(sig["mean_out"][k]) * close[i] >= conf_atr * a[i]):
|
|
continue
|
|
if trend_max is not None and a[i] > 0 and abs(close[i] - el[i]) / a[i] > trend_max:
|
|
continue
|
|
e = {"i": i, "d": d, "max_bars": H}
|
|
if tp_atr is not None and a[i] > 0:
|
|
e["tp"] = close[i] + d * tp_atr * a[i]
|
|
if sl_atr is not None and a[i] > 0:
|
|
e["sl"] = close[i] - d * sl_atr * a[i]
|
|
entries.append(e)
|
|
return entries
|
|
|
|
|
|
# --------------------------- verifica no look-ahead ---------------------------
|
|
def check_no_lookahead(df, W=24, H=12) -> bool:
|
|
"""La forma a i deve restare invariata se perturbo il FUTURO (>i).
|
|
Conferma che znorm_windows usa solo close fino a i."""
|
|
close = df["close"].values.copy()
|
|
M0, ends = znorm_windows(close, W)
|
|
pos = {int(e): k for k, e in enumerate(ends)}
|
|
i = len(close) // 2
|
|
q0 = M0[pos[i]].copy()
|
|
close2 = close.copy()
|
|
close2[i + 1:] *= 1.5 # stravolgo il futuro
|
|
M1, _ = znorm_windows(close2, W)
|
|
q1 = M1[pos[i]]
|
|
ok = np.allclose(q0, q1)
|
|
print(f" no-lookahead forma a i={i}: {'OK' if ok else 'VIOLATO'} "
|
|
f"(max diff {np.max(np.abs(q0 - q1)):.2e})")
|
|
return ok
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print("=" * 92)
|
|
print(" SHAPE_LAB — baseline analog forecasting (kNN sulla forma) | netto fee, OOS")
|
|
print("=" * 92)
|
|
df = get_df("BTC", "1h")
|
|
check_no_lookahead(df)
|
|
print("\n BTC 1h — sweep base W/H/K (time-exit a H barre):")
|
|
for W, H, K in [(24, 12, 50), (24, 24, 50), (48, 24, 80), (12, 6, 40), (48, 48, 100)]:
|
|
ents = analog_entries(df, W=W, H=H, K=K, agree=0.60)
|
|
evaluate(f"analog W{W}H{H}K{K}", ents, df)
|